refactor: implement safeString utility for type-safe data access and improve UI formatting across widgets and screens

This commit is contained in:
Suherdy Yacob 2026-06-15 16:10:48 +07:00
parent 3bd6eb83ac
commit 4df528272e
12 changed files with 521 additions and 309 deletions

View File

@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../services/odoo_service.dart'; import '../services/odoo_service.dart';
import '../utils/safe_cast.dart';
class BranchesScreen extends StatefulWidget { class BranchesScreen extends StatefulWidget {
const BranchesScreen({super.key}); const BranchesScreen({super.key});
@ -69,8 +71,11 @@ class _BranchesScreenState extends State<BranchesScreen> {
} else { } else {
// Fallback: alphabetical sort // Fallback: alphabetical sort
_branches = List<dynamic>.from(branches) _branches = List<dynamic>.from(branches)
..sort((a, b) => (a['name'] as String? ?? '') ..sort(
.compareTo(b['name'] as String? ?? '')); (a, b) => (safeString(a['name']) ?? '').compareTo(
safeString(b['name']) ?? '',
),
);
} }
_isLoading = false; _isLoading = false;
}); });
@ -79,7 +84,9 @@ class _BranchesScreenState extends State<BranchesScreen> {
if (mounted) { if (mounted) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error loading branches. Check connection.')), const SnackBar(
content: Text('Error loading branches. Check connection.'),
),
); );
} }
} }
@ -94,7 +101,8 @@ class _BranchesScreenState extends State<BranchesScreen> {
const R = 6371.0; const R = 6371.0;
final dLat = _degToRad(lat - pos.latitude); final dLat = _degToRad(lat - pos.latitude);
final dLng = _degToRad(lng - pos.longitude); final dLng = _degToRad(lng - pos.longitude);
final a = sin(dLat / 2) * sin(dLat / 2) + final a =
sin(dLat / 2) * sin(dLat / 2) +
cos(_degToRad(pos.latitude)) * cos(_degToRad(pos.latitude)) *
cos(_degToRad(lat)) * cos(_degToRad(lat)) *
sin(dLng / 2) * sin(dLng / 2) *
@ -119,14 +127,16 @@ class _BranchesScreenState extends State<BranchesScreen> {
Future<void> _launchMaps(String queryTerm) async { Future<void> _launchMaps(String queryTerm) async {
final query = Uri.encodeComponent(queryTerm); final query = Uri.encodeComponent(queryTerm);
final url = Uri.parse('https://www.google.com/maps/search/?api=1&query=$query'); final url = Uri.parse(
'https://www.google.com/maps/search/?api=1&query=$query',
);
if (await canLaunchUrl(url)) { if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication); await launchUrl(url, mode: LaunchMode.externalApplication);
} else { } else {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
const SnackBar(content: Text('Could not open map.')), context,
); ).showSnackBar(const SnackBar(content: Text('Could not open map.')));
} }
} }
} }
@ -165,10 +175,17 @@ class _BranchesScreenState extends State<BranchesScreen> {
Container( Container(
width: double.infinity, width: double.infinity,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
child: Row( child: Row(
children: [ children: [
Icon(Icons.location_off, size: 16, color: colorScheme.onSurfaceVariant), Icon(
Icons.location_off,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
@ -185,10 +202,17 @@ class _BranchesScreenState extends State<BranchesScreen> {
Container( Container(
width: double.infinity, width: double.infinity,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
child: Row( child: Row(
children: [ children: [
Icon(Icons.my_location, size: 16, color: colorScheme.secondary), Icon(
Icons.my_location,
size: 16,
color: colorScheme.secondary,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Sorted by distance from your location', 'Sorted by distance from your location',
@ -203,34 +227,56 @@ class _BranchesScreenState extends State<BranchesScreen> {
// Branch list // Branch list
Expanded( Expanded(
child: _branches.isEmpty child: _branches.isEmpty
? const Center( ? ListView(
child: Text('No branches available.', style: TextStyle(fontSize: 16)), physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: const Center(
child: Text(
'No branches available.',
style: TextStyle(fontSize: 16),
),
),
),
],
) )
: ListView.builder( : ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
itemCount: _branches.length, itemCount: _branches.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final branch = _branches[index]; final branch = _branches[index];
final street = branch['street'] != null && branch['street'] != false final street =
branch['street'] != null &&
branch['street'] != false
? branch['street'] ? branch['street']
: ''; : '';
final city = branch['city'] != null && branch['city'] != false final city =
branch['city'] != null &&
branch['city'] != false
? branch['city'] ? branch['city']
: ''; : '';
final phone = branch['phone'] != null && branch['phone'] != false final phone =
branch['phone'] != null &&
branch['phone'] != false
? branch['phone'] ? branch['phone']
: ''; : '';
final addressParts = [street, city] final addressParts = [
.where((e) => e.toString().isNotEmpty) street,
.join(', '); city,
].where((e) => e.toString().isNotEmpty).join(', ');
final distance = _userPosition != null final distance = _userPosition != null
? _distanceTo(_userPosition!, branch) ? _distanceTo(_userPosition!, branch)
: null; : null;
final distanceLabel = final distanceLabel = distance != null
distance != null ? _formatDistance(distance) : ''; ? _formatDistance(distance)
: '';
return Container( return Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
@ -248,22 +294,22 @@ class _BranchesScreenState extends State<BranchesScreen> {
child: ListTile( child: ListTile(
contentPadding: const EdgeInsets.all(16), contentPadding: const EdgeInsets.all(16),
onTap: () => _launchMaps( onTap: () => _launchMaps(
'${branch['name']} $addressParts'), '${branch['name']} $addressParts',
),
leading: Container( leading: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.secondaryContainer, color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Icon(Icons.storefront, child: Icon(
color: colorScheme.secondary), Icons.storefront,
color: colorScheme.secondary,
),
), ),
title: Text( title: Text(
branch['name'] ?? 'Mapan Branch', branch['name'] ?? 'Mapan Branch',
style: theme style: theme.textTheme.titleMedium?.copyWith(
.textTheme
.titleMedium
?.copyWith(
fontFamily: 'serif', fontFamily: 'serif',
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@ -271,7 +317,8 @@ class _BranchesScreenState extends State<BranchesScreen> {
subtitle: Padding( subtitle: Padding(
padding: const EdgeInsets.only(top: 6.0), padding: const EdgeInsets.only(top: 6.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
if (distanceLabel.isNotEmpty) ...[ if (distanceLabel.isNotEmpty) ...[
Row( Row(
@ -284,7 +331,8 @@ class _BranchesScreenState extends State<BranchesScreen> {
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'$distanceLabel away', '$distanceLabel away',
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium
?.copyWith(
color: colorScheme.primary, color: colorScheme.primary,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@ -303,31 +351,40 @@ class _BranchesScreenState extends State<BranchesScreen> {
const SizedBox(height: 4), const SizedBox(height: 4),
Row( Row(
children: [ children: [
Icon(Icons.phone, Icon(
Icons.phone,
size: 14, size: 14,
color: colorScheme.onSurfaceVariant), color:
colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(phone, Text(
style: theme phone,
.textTheme style: theme.textTheme.bodySmall
.bodySmall
?.copyWith( ?.copyWith(
color: colorScheme.onSurfaceVariant)), color: colorScheme
.onSurfaceVariant,
),
),
], ],
), ),
] ],
], ],
), ),
), ),
trailing: phone.isNotEmpty trailing: phone.isNotEmpty
? IconButton( ? IconButton(
icon: Icon(Icons.chat_bubble, icon: Icon(
color: colorScheme.onSurface), Icons.chat_bubble,
color: colorScheme.onSurface,
),
onPressed: () => _launchWhatsApp(phone), onPressed: () => _launchWhatsApp(phone),
tooltip: 'Chat on WhatsApp', tooltip: 'Chat on WhatsApp',
) )
: Icon(Icons.chevron_right, : Icon(
color: colorScheme.onSurfaceVariant), Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
), ),
); );
}, },

View File

@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/flutter_html.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../utils/safe_cast.dart';
/// Detail screen for a tapped carousel slide. /// Detail screen for a tapped carousel slide.
/// Displays the banner image (base64 or network URL), title, and rich HTML detail content. /// Displays the banner image (base64 or network URL), title, and rich HTML detail content.
@ -12,10 +13,10 @@ class CarouselDetailScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final title = slide['name'] as String? ?? 'Slide Details'; final title = safeString(slide['name']) ?? 'Slide Details';
final bodyHtml = slide['body'] as String? ?? ''; final bodyHtml = safeString(slide['body']) ?? '';
final base64Img = slide['image'] as String?; final base64Img = safeString(slide['image']);
final externalUrl = slide['image_url'] as String?; final externalUrl = safeString(slide['image_url']);
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(title)), appBar: AppBar(title: Text(title)),
@ -28,10 +29,7 @@ class CarouselDetailScreen extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
child: Text( child: Text(title, style: Theme.of(context).textTheme.titleLarge),
title,
style: Theme.of(context).textTheme.titleLarge,
),
), ),
if (bodyHtml.isNotEmpty) if (bodyHtml.isNotEmpty)
@ -116,7 +114,11 @@ class CarouselDetailScreen extends StatelessWidget {
height: 220, height: 220,
color: AppTheme.surfaceContainer, color: AppTheme.surfaceContainer,
child: const Center( child: const Center(
child: Icon(Icons.image_rounded, size: 56, color: AppTheme.outlineVariant), child: Icon(
Icons.image_rounded,
size: 56,
color: AppTheme.outlineVariant,
),
), ),
); );
} }

View File

@ -52,9 +52,9 @@ class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Error loading data: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Error loading data: $e')));
} }
} }
} }
@ -158,7 +158,10 @@ class _LoyaltyCardTile extends StatelessWidget {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: accentColor, color: accentColor,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@ -166,7 +169,9 @@ class _LoyaltyCardTile extends StatelessWidget {
child: Text( child: Text(
tier, tier,
style: theme.textTheme.labelLarge?.copyWith( style: theme.textTheme.labelLarge?.copyWith(
color: colorScheme.primary.computeLuminance() > 0.5 ? Colors.black : Colors.white, color: colorScheme.primary.computeLuminance() > 0.5
? Colors.black
: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 9, fontSize: 9,
letterSpacing: 0.8, letterSpacing: 0.8,
@ -232,13 +237,22 @@ class _LoyaltyCardTile extends StatelessWidget {
), ),
], ],
), ),
Text( const SizedBox(width: 16),
Expanded(
child: Align(
alignment: Alignment.bottomRight,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'${card['points'] ?? 0}', '${card['points'] ?? 0}',
style: theme.textTheme.displayLarge?.copyWith( style: theme.textTheme.displayMedium?.copyWith(
color: accentColor, color: accentColor,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
),
),
),
], ],
), ),
], ],

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/flutter_html.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../services/odoo_service.dart'; import '../services/odoo_service.dart';
import '../utils/safe_cast.dart';
class NotificationDetailScreen extends StatelessWidget { class NotificationDetailScreen extends StatelessWidget {
final dynamic notif; final dynamic notif;
@ -10,13 +11,11 @@ class NotificationDetailScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final title = notif['title'] as String? ?? 'Notice'; final title = safeString(notif['title']) ?? 'Notice';
final bodyHtml = notif['body'] as String? ?? ''; final bodyHtml = safeString(notif['body']) ?? '';
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('Notification Detail')),
title: const Text('Notification Detail'),
),
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -75,8 +74,11 @@ class NotificationDetailScreen extends StatelessWidget {
height: 150, height: 150,
color: AppTheme.surfaceContainerLow, color: AppTheme.surfaceContainerLow,
child: const Center( child: const Center(
child: Icon(Icons.broken_image_outlined, child: Icon(
size: 48, color: AppTheme.onSurfaceVariant), Icons.broken_image_outlined,
size: 48,
color: AppTheme.onSurfaceVariant,
),
), ),
); );
}, },

View File

@ -5,6 +5,7 @@ import '../services/odoo_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import 'notification_detail_screen.dart'; import 'notification_detail_screen.dart';
import '../utils/safe_cast.dart';
class NotificationsScreen extends StatefulWidget { class NotificationsScreen extends StatefulWidget {
const NotificationsScreen({super.key}); const NotificationsScreen({super.key});
@ -67,52 +68,79 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
appBar: AppBar(title: const Text('Notifications')), appBar: AppBar(title: const Text('Notifications')),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _notifications.isEmpty : RefreshIndicator(
? Center( onRefresh: _fetchNotifications,
child: _notifications.isEmpty
? CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
Icons.notifications_none, Icons.notifications_none,
size: 64, size: 64,
color: AppTheme.onSurfaceVariant.withValues(alpha: 0.4), color: AppTheme.onSurfaceVariant.withValues(
alpha: 0.4,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No notifications yet.', 'No notifications yet.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge
?.copyWith(
color: AppTheme.onSurfaceVariant, color: AppTheme.onSurfaceVariant,
), ),
), ),
], ],
), ),
),
),
],
) )
: RefreshIndicator( : ListView.separated(
onRefresh: _fetchNotifications,
child: ListView.separated(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 20), horizontal: 16,
vertical: 20,
),
itemCount: _notifications.length, itemCount: _notifications.length,
separatorBuilder: (_, _) => const SizedBox(height: 8), separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final notif = _notifications[index]; final notif = _notifications[index];
final isUnread = !_readIds.contains((notif['id'] as int? ?? 0).toString()); final isUnread = !_readIds.contains(
(notif['id'] as int? ?? 0).toString(),
);
return _NotificationCard( return _NotificationCard(
notif: notif, notif: notif,
isUnread: isUnread, isUnread: isUnread,
onTap: () async { onTap: () async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final partnerId = OdooService().client?.sessionId?.partnerId ?? 0; final partnerId =
final keyReadNotificationIds = 'read_notification_ids_$partnerId'; OdooService().client?.sessionId?.partnerId ?? 0;
final readIds = prefs.getStringList(keyReadNotificationIds) ?? []; final keyReadNotificationIds =
final notifIdStr = (notif['id'] as int? ?? 0).toString(); 'read_notification_ids_$partnerId';
final readIds =
prefs.getStringList(keyReadNotificationIds) ??
[];
final notifIdStr = (notif['id'] as int? ?? 0)
.toString();
if (!readIds.contains(notifIdStr)) { if (!readIds.contains(notifIdStr)) {
readIds.add(notifIdStr); readIds.add(notifIdStr);
await prefs.setStringList(keyReadNotificationIds, readIds); await prefs.setStringList(
keyReadNotificationIds,
readIds,
);
// Recalculate and update system badge count // Recalculate and update system badge count
final unreadCount = _notifications final unreadCount = _notifications
.where((n) => !readIds.contains((n['id'] as int? ?? 0).toString())) .where(
(n) => !readIds.contains(
(n['id'] as int? ?? 0).toString(),
),
)
.length; .length;
await NotificationService().setBadge(unreadCount); await NotificationService().setBadge(unreadCount);
} }
@ -149,7 +177,7 @@ class _NotificationCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final title = notif['title'] as String? ?? 'Notice'; final title = safeString(notif['title']) ?? 'Notice';
return Material( return Material(
color: AppTheme.surfaceContainerLow, color: AppTheme.surfaceContainerLow,

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/odoo_service.dart'; import '../services/odoo_service.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../utils/safe_cast.dart';
class OrdersScreen extends StatefulWidget { class OrdersScreen extends StatefulWidget {
const OrdersScreen({super.key}); const OrdersScreen({super.key});
@ -32,9 +33,9 @@ class _OrdersScreenState extends State<OrdersScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Error loading history: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Error loading history: $e')));
} }
} }
} }
@ -44,8 +45,18 @@ class _OrdersScreenState extends State<OrdersScreen> {
try { try {
final parsed = DateTime.parse(rawDate); final parsed = DateTime.parse(rawDate);
final months = [ final months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jan',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' 'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]; ];
final year = parsed.year; final year = parsed.year;
final month = months[parsed.month - 1]; final month = months[parsed.month - 1];
@ -63,17 +74,20 @@ class _OrdersScreenState extends State<OrdersScreen> {
final theme = Theme.of(context); final theme = Theme.of(context);
if (_isLoading) { if (_isLoading) {
return const Scaffold( return const Scaffold(body: Center(child: CircularProgressIndicator()));
body: Center(child: CircularProgressIndicator()),
);
} }
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('Order & Points History')),
title: const Text('Order & Points History'), body: RefreshIndicator(
), onRefresh: _fetchHistory,
body: _history.isEmpty child: _history.isEmpty
? Center( ? CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(40), padding: const EdgeInsets.all(40),
child: Column( child: Column(
@ -108,28 +122,41 @@ class _OrdersScreenState extends State<OrdersScreen> {
], ],
), ),
), ),
),
),
],
) )
: RefreshIndicator( : ListView.builder(
onRefresh: _fetchHistory,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
itemCount: _history.length, itemCount: _history.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = _history[index]; final item = _history[index];
final isEarn = item['type'] == 'earn'; final isEarn = item['type'] == 'earn';
final orderRef = item['order_ref'] as String? ?? ''; final orderRef = safeString(item['order_ref']) ?? '';
final rawDate = item['date'] as String? ?? ''; final rawDate = safeString(item['date']) ?? '';
final posName = item['pos_name'] as String? ?? ''; final posName = safeString(item['pos_name']) ?? '';
final programType = safeString(item['program_type']) ?? 'loyalty';
final isSubscription = programType == 'subscription';
final programName = safeString(item['program_name']) ?? '';
return Card( return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 6,
),
color: AppTheme.surfaceContainerLow, color: AppTheme.surfaceContainerLow,
child: ListTile( child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
leading: Container( leading: Container(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: (isEarn ? const Color(0xFF2E7D32) : const Color(0xFFC62828)).withValues(alpha: 0.1), color: (isEarn
? const Color(0xFF2E7D32)
: const Color(0xFFC62828))
.withValues(alpha: 0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
@ -176,6 +203,52 @@ class _OrdersScreenState extends State<OrdersScreen> {
color: AppTheme.onSurfaceVariant, color: AppTheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isSubscription
? const Color(0xFF1B5E20).withValues(alpha: 0.08)
: theme.colorScheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSubscription
? const Color(0xFF1B5E20).withValues(alpha: 0.2)
: theme.colorScheme.primary.withValues(alpha: 0.2),
width: 0.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isSubscription
? Icons.local_activity_rounded
: Icons.card_membership_rounded,
size: 10,
color: isSubscription
? const Color(0xFF2E7D32)
: theme.colorScheme.primary,
),
const SizedBox(width: 4),
Text(
isSubscription
? 'Subscription Claim'
: (programName.isNotEmpty
? programName
: 'Loyalty Program'),
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
color: isSubscription
? const Color(0xFF2E7D32)
: theme.colorScheme.primary,
),
),
],
),
),
], ],
), ),
), ),

View File

@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/flutter_html.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../utils/safe_cast.dart';
/// Promo detail screen mirrors notification detail but for promo highlights. /// Promo detail screen mirrors notification detail but for promo highlights.
/// Shows the promo image (full size), title, and rich HTML body content. /// Shows the promo image (full size), title, and rich HTML body content.
@ -12,9 +13,9 @@ class PromoDetailScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final title = promo['name'] as String? ?? 'Promo'; final title = safeString(promo['name']) ?? 'Promo';
final bodyHtml = promo['body'] as String? ?? ''; final bodyHtml = safeString(promo['body']) ?? '';
final base64Img = promo['image_128'] as String?; final base64Img = safeString(promo['image_128']);
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(title)), appBar: AppBar(title: Text(title)),
@ -28,10 +29,7 @@ class PromoDetailScreen extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
child: Text( child: Text(title, style: Theme.of(context).textTheme.titleLarge),
title,
style: Theme.of(context).textTheme.titleLarge,
),
), ),
if (bodyHtml.isNotEmpty) if (bodyHtml.isNotEmpty)
@ -91,7 +89,11 @@ class PromoDetailScreen extends StatelessWidget {
height: 220, height: 220,
color: AppTheme.surfaceContainer, color: AppTheme.surfaceContainer,
child: const Center( child: const Center(
child: Icon(Icons.local_offer_rounded, size: 56, color: AppTheme.outlineVariant), child: Icon(
Icons.local_offer_rounded,
size: 56,
color: AppTheme.outlineVariant,
),
), ),
); );
} }

View File

@ -1,5 +1,5 @@
class AppConfig { class AppConfig {
static const String odooUrl = static const String odooUrl =
'https://odoodev.mapan.co.id'; // Default local dev url 'https://odoodev.mapan.co.id'; // Default local dev url
static const String odooDb = 'mapangroup_o19_260605'; // Default local dev db static const String odooDb = 'mapangroup_o19_260615'; // Default local dev db
} }

9
lib/utils/safe_cast.dart Normal file
View File

@ -0,0 +1,9 @@
/// Safely converts a dynamic value from Odoo JSON-RPC to a String.
/// Odoo returns `false` (boolean) for unset fields, which causes standard
/// Dart type casts (like `value as String?`) to crash.
String? safeString(dynamic value) {
if (value is String) {
return value;
}
return null;
}

View File

@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:smooth_page_indicator/smooth_page_indicator.dart'; import 'package:smooth_page_indicator/smooth_page_indicator.dart';
import '../screens/carousel_detail_screen.dart'; import '../screens/carousel_detail_screen.dart';
import '../utils/safe_cast.dart';
/// Auto-scrolling carousel widget that shows slides from CMS. /// Auto-scrolling carousel widget that shows slides from CMS.
/// Each slide can have an uploaded image (base64) or external image URL. /// Each slide can have an uploaded image (base64) or external image URL.
@ -102,8 +103,8 @@ class _SlideImage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final base64Img = slide['image'] as String?; final base64Img = safeString(slide['image']);
final externalUrl = slide['image_url'] as String?; final externalUrl = safeString(slide['image_url']);
Widget image; Widget image;
@ -144,10 +145,7 @@ class _SlideImage extends StatelessWidget {
), ),
], ],
), ),
child: ClipRRect( child: ClipRRect(borderRadius: BorderRadius.circular(16), child: image),
borderRadius: BorderRadius.circular(16),
child: image,
),
); );
} }

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../screens/promo_detail_screen.dart'; import '../screens/promo_detail_screen.dart';
import '../utils/safe_cast.dart';
/// Horizontal scrollable row of promo highlight cards. /// Horizontal scrollable row of promo highlight cards.
/// Tapping a card opens the full detail screen with rich text content. /// Tapping a card opens the full detail screen with rich text content.
@ -49,15 +50,19 @@ class _PromoCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final base64Img = promo['image_128'] as String?; final base64Img = safeString(promo['image_128']);
final title = promo['name'] as String? ?? ''; final title = safeString(promo['name']) ?? '';
Widget imageWidget; Widget imageWidget;
if (base64Img != null && base64Img.isNotEmpty) { if (base64Img != null && base64Img.isNotEmpty) {
try { try {
final Uint8List bytes = base64Decode(base64Img); final Uint8List bytes = base64Decode(base64Img);
imageWidget = Image.memory(bytes, fit: BoxFit.cover, imageWidget = Image.memory(
width: double.infinity, height: 110); bytes,
fit: BoxFit.cover,
width: double.infinity,
height: 110,
);
} catch (_) { } catch (_) {
imageWidget = _imagePlaceholder(colorScheme); imageWidget = _imagePlaceholder(colorScheme);
} }
@ -97,7 +102,9 @@ class _PromoCard extends StatelessWidget {
height: 110, height: 110,
width: double.infinity, width: double.infinity,
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(15)), borderRadius: const BorderRadius.vertical(
top: Radius.circular(15),
),
child: imageWidget, child: imageWidget,
), ),
), ),
@ -124,7 +131,11 @@ class _PromoCard extends StatelessWidget {
return Container( return Container(
color: colorScheme.surfaceContainer, color: colorScheme.surfaceContainer,
child: Center( child: Center(
child: Icon(Icons.local_offer_rounded, size: 32, color: colorScheme.outline), child: Icon(
Icons.local_offer_rounded,
size: 32,
color: colorScheme.outline,
),
), ),
); );
} }

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../utils/safe_cast.dart';
/// "My Subscriptions" list displayed on the home tab below the loyalty card. /// "My Subscriptions" list displayed on the home tab below the loyalty card.
/// Renders each subscription as a beautiful standalone ticket card visually distinct /// Renders each subscription as a beautiful standalone ticket card visually distinct
@ -68,8 +69,18 @@ class _SubscriptionCard extends StatelessWidget {
String _monthName(int m) { String _monthName(int m) {
const months = [ const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jan',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' 'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]; ];
return months[m - 1]; return months[m - 1];
} }
@ -80,9 +91,9 @@ class _SubscriptionCard extends StatelessWidget {
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final programName = sub['program_id'] is List final programName = sub['program_id'] is List
? (sub['program_id'][1] as String? ?? 'Subscription') ? (safeString(sub['program_id'][1]) ?? 'Subscription')
: 'Subscription'; : 'Subscription';
final code = sub['code'] as String? ?? ''; final code = safeString(sub['code']) ?? '';
final startDate = _formatDate(sub['subscription_start_date']); final startDate = _formatDate(sub['subscription_start_date']);
final endDate = _formatDate(sub['subscription_end_date']); final endDate = _formatDate(sub['subscription_end_date']);
final active = _isActive(); final active = _isActive();
@ -106,15 +117,20 @@ class _SubscriptionCard extends StatelessWidget {
Row( Row(
children: [ children: [
Icon( Icon(
Icons.local_activity_rounded, // Distinct ticket pass icon Icons
.local_activity_rounded, // Distinct ticket pass icon
size: 18, size: 18,
color: active ? colorScheme.primary : colorScheme.outline, color: active
? colorScheme.primary
: colorScheme.outline,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'SUBSCRIPTION PASS', // Distinct title 'SUBSCRIPTION PASS', // Distinct title
style: theme.textTheme.labelLarge?.copyWith( style: theme.textTheme.labelLarge?.copyWith(
color: active ? colorScheme.primary : colorScheme.outline, color: active
? colorScheme.primary
: colorScheme.outline,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 10, fontSize: 10,
letterSpacing: 1.2, letterSpacing: 1.2,
@ -123,7 +139,10 @@ class _SubscriptionCard extends StatelessWidget {
], ],
), ),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: active color: active
? const Color(0xFF1B5E20).withValues(alpha: 0.10) ? const Color(0xFF1B5E20).withValues(alpha: 0.10)
@ -210,7 +229,8 @@ class _SubscriptionCard extends StatelessWidget {
), ),
], ],
), ),
if (sub['subscription_start_date'] != null && sub['subscription_start_date'] != false) ...[ if (sub['subscription_start_date'] != null &&
sub['subscription_start_date'] != false) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@ -317,11 +337,7 @@ class DashedLinePainter extends CustomPainter {
..strokeWidth = 1.0; ..strokeWidth = 1.0;
while (startX < size.width) { while (startX < size.width) {
canvas.drawLine( canvas.drawLine(Offset(startX, 0), Offset(startX + dashWidth, 0), paint);
Offset(startX, 0),
Offset(startX + dashWidth, 0),
paint,
);
startX += dashWidth + dashSpace; startX += dashWidth + dashSpace;
} }
} }