From 4df528272ec5b3ffb7df5326b884f5480ef3bc9e Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Mon, 15 Jun 2026 16:10:48 +0700 Subject: [PATCH] refactor: implement safeString utility for type-safe data access and improve UI formatting across widgets and screens --- lib/screens/branches_screen.dart | 163 +++++++++++------ lib/screens/carousel_detail_screen.dart | 20 ++- lib/screens/loyalty_dashboard.dart | 84 +++++---- lib/screens/notification_detail_screen.dart | 34 ++-- lib/screens/notifications_screen.dart | 158 ++++++++++------- lib/screens/orders_screen.dart | 185 ++++++++++++++------ lib/screens/promo_detail_screen.dart | 18 +- lib/services/config.dart | 2 +- lib/utils/safe_cast.dart | 9 + lib/widgets/carousel_widget.dart | 10 +- lib/widgets/promo_card_widget.dart | 23 ++- lib/widgets/subscription_list_widget.dart | 124 +++++++------ 12 files changed, 521 insertions(+), 309 deletions(-) create mode 100644 lib/utils/safe_cast.dart diff --git a/lib/screens/branches_screen.dart b/lib/screens/branches_screen.dart index d033cdd..2f9a4b8 100644 --- a/lib/screens/branches_screen.dart +++ b/lib/screens/branches_screen.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:url_launcher/url_launcher.dart'; import '../services/odoo_service.dart'; +import '../utils/safe_cast.dart'; + class BranchesScreen extends StatefulWidget { const BranchesScreen({super.key}); @@ -69,8 +71,11 @@ class _BranchesScreenState extends State { } else { // Fallback: alphabetical sort _branches = List.from(branches) - ..sort((a, b) => (a['name'] as String? ?? '') - .compareTo(b['name'] as String? ?? '')); + ..sort( + (a, b) => (safeString(a['name']) ?? '').compareTo( + safeString(b['name']) ?? '', + ), + ); } _isLoading = false; }); @@ -79,7 +84,9 @@ class _BranchesScreenState extends State { if (mounted) { setState(() => _isLoading = false); 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 { const R = 6371.0; final dLat = _degToRad(lat - pos.latitude); 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(lat)) * sin(dLng / 2) * @@ -119,14 +127,16 @@ class _BranchesScreenState extends State { Future _launchMaps(String queryTerm) async { 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)) { await launchUrl(url, mode: LaunchMode.externalApplication); } else { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not open map.')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Could not open map.'))); } } } @@ -165,17 +175,24 @@ class _BranchesScreenState extends State { Container( width: double.infinity, color: colorScheme.surfaceContainerLow, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), child: Row( children: [ - Icon(Icons.location_off, size: 16, color: colorScheme.onSurfaceVariant), + Icon( + Icons.location_off, + size: 16, + color: colorScheme.onSurfaceVariant, + ), const SizedBox(width: 8), Expanded( child: Text( 'Location not available. Showing branches alphabetically.', style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + color: colorScheme.onSurfaceVariant, + ), ), ), ], @@ -185,16 +202,23 @@ class _BranchesScreenState extends State { Container( width: double.infinity, color: colorScheme.surfaceContainerLow, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), child: Row( children: [ - Icon(Icons.my_location, size: 16, color: colorScheme.secondary), + Icon( + Icons.my_location, + size: 16, + color: colorScheme.secondary, + ), const SizedBox(width: 8), Text( 'Sorted by distance from your location', style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + color: colorScheme.onSurfaceVariant, + ), ), ], ), @@ -203,34 +227,56 @@ class _BranchesScreenState extends State { // Branch list Expanded( child: _branches.isEmpty - ? const Center( - child: Text('No branches available.', style: TextStyle(fontSize: 16)), + ? ListView( + 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( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), itemCount: _branches.length, itemBuilder: (context, index) { final branch = _branches[index]; - final street = branch['street'] != null && branch['street'] != false + final street = + branch['street'] != null && + branch['street'] != false ? branch['street'] : ''; - final city = branch['city'] != null && branch['city'] != false + final city = + branch['city'] != null && + branch['city'] != false ? branch['city'] : ''; - final phone = branch['phone'] != null && branch['phone'] != false + final phone = + branch['phone'] != null && + branch['phone'] != false ? branch['phone'] : ''; - final addressParts = [street, city] - .where((e) => e.toString().isNotEmpty) - .join(', '); + final addressParts = [ + street, + city, + ].where((e) => e.toString().isNotEmpty).join(', '); final distance = _userPosition != null ? _distanceTo(_userPosition!, branch) : null; - final distanceLabel = - distance != null ? _formatDistance(distance) : ''; + final distanceLabel = distance != null + ? _formatDistance(distance) + : ''; return Container( margin: const EdgeInsets.only(bottom: 12), @@ -248,30 +294,31 @@ class _BranchesScreenState extends State { child: ListTile( contentPadding: const EdgeInsets.all(16), onTap: () => _launchMaps( - '${branch['name']} $addressParts'), + '${branch['name']} $addressParts', + ), leading: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(12), ), - child: Icon(Icons.storefront, - color: colorScheme.secondary), + child: Icon( + Icons.storefront, + color: colorScheme.secondary, + ), ), title: Text( branch['name'] ?? 'Mapan Branch', - style: theme - .textTheme - .titleMedium - ?.copyWith( - fontFamily: 'serif', - fontWeight: FontWeight.bold, - ), + style: theme.textTheme.titleMedium?.copyWith( + fontFamily: 'serif', + fontWeight: FontWeight.bold, + ), ), subtitle: Padding( padding: const EdgeInsets.only(top: 6.0), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.start, children: [ if (distanceLabel.isNotEmpty) ...[ Row( @@ -284,7 +331,8 @@ class _BranchesScreenState extends State { const SizedBox(width: 4), Text( '$distanceLabel away', - style: theme.textTheme.bodyMedium?.copyWith( + style: theme.textTheme.bodyMedium + ?.copyWith( color: colorScheme.primary, fontWeight: FontWeight.bold, ), @@ -303,31 +351,40 @@ class _BranchesScreenState extends State { const SizedBox(height: 4), Row( children: [ - Icon(Icons.phone, - size: 14, - color: colorScheme.onSurfaceVariant), + Icon( + Icons.phone, + size: 14, + color: + colorScheme.onSurfaceVariant, + ), const SizedBox(width: 4), - Text(phone, - style: theme - .textTheme - .bodySmall - ?.copyWith( - color: colorScheme.onSurfaceVariant)), + Text( + phone, + style: theme.textTheme.bodySmall + ?.copyWith( + color: colorScheme + .onSurfaceVariant, + ), + ), ], ), - ] + ], ], ), ), trailing: phone.isNotEmpty ? IconButton( - icon: Icon(Icons.chat_bubble, - color: colorScheme.onSurface), + icon: Icon( + Icons.chat_bubble, + color: colorScheme.onSurface, + ), onPressed: () => _launchWhatsApp(phone), tooltip: 'Chat on WhatsApp', ) - : Icon(Icons.chevron_right, - color: colorScheme.onSurfaceVariant), + : Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + ), ), ); }, diff --git a/lib/screens/carousel_detail_screen.dart b/lib/screens/carousel_detail_screen.dart index 5d86fa2..e4c890f 100644 --- a/lib/screens/carousel_detail_screen.dart +++ b/lib/screens/carousel_detail_screen.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import '../theme/app_theme.dart'; +import '../utils/safe_cast.dart'; /// Detail screen for a tapped carousel slide. /// Displays the banner image (base64 or network URL), title, and rich HTML detail content. @@ -12,10 +13,10 @@ class CarouselDetailScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final title = slide['name'] as String? ?? 'Slide Details'; - final bodyHtml = slide['body'] as String? ?? ''; - final base64Img = slide['image'] as String?; - final externalUrl = slide['image_url'] as String?; + final title = safeString(slide['name']) ?? 'Slide Details'; + final bodyHtml = safeString(slide['body']) ?? ''; + final base64Img = safeString(slide['image']); + final externalUrl = safeString(slide['image_url']); return Scaffold( appBar: AppBar(title: Text(title)), @@ -28,10 +29,7 @@ class CarouselDetailScreen extends StatelessWidget { Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge, - ), + child: Text(title, style: Theme.of(context).textTheme.titleLarge), ), if (bodyHtml.isNotEmpty) @@ -116,7 +114,11 @@ class CarouselDetailScreen extends StatelessWidget { height: 220, color: AppTheme.surfaceContainer, child: const Center( - child: Icon(Icons.image_rounded, size: 56, color: AppTheme.outlineVariant), + child: Icon( + Icons.image_rounded, + size: 56, + color: AppTheme.outlineVariant, + ), ), ); } diff --git a/lib/screens/loyalty_dashboard.dart b/lib/screens/loyalty_dashboard.dart index 966ec1a..f69cf46 100644 --- a/lib/screens/loyalty_dashboard.dart +++ b/lib/screens/loyalty_dashboard.dart @@ -52,9 +52,9 @@ class _LoyaltyDashboardState extends State { } catch (e) { if (mounted) { setState(() => _isLoading = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error loading data: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error loading data: $e'))); } } } @@ -150,15 +150,18 @@ class _LoyaltyCardTile extends StatelessWidget { child: Text( '${card['program_id']?[1] ?? 'Loyalty Program'}', style: theme.textTheme.titleLarge?.copyWith( - color: onPrimary, - fontFamily: 'serif', - ), + color: onPrimary, + fontFamily: 'serif', + ), softWrap: true, ), ), const SizedBox(width: 12), Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), decoration: BoxDecoration( color: accentColor, borderRadius: BorderRadius.circular(20), @@ -166,11 +169,13 @@ class _LoyaltyCardTile extends StatelessWidget { child: Text( tier, style: theme.textTheme.labelLarge?.copyWith( - color: colorScheme.primary.computeLuminance() > 0.5 ? Colors.black : Colors.white, - fontWeight: FontWeight.bold, - fontSize: 9, - letterSpacing: 0.8, - ), + color: colorScheme.primary.computeLuminance() > 0.5 + ? Colors.black + : Colors.white, + fontWeight: FontWeight.bold, + fontSize: 9, + letterSpacing: 0.8, + ), ), ), ], @@ -179,21 +184,21 @@ class _LoyaltyCardTile extends StatelessWidget { Text( 'MEMBERSHIP CODE', style: theme.textTheme.bodySmall?.copyWith( - color: onPrimary.withValues(alpha: 0.7), - fontWeight: FontWeight.bold, - fontSize: 10, - letterSpacing: 1.0, - ), + color: onPrimary.withValues(alpha: 0.7), + fontWeight: FontWeight.bold, + fontSize: 10, + letterSpacing: 1.0, + ), ), const SizedBox(height: 4), Text( '${card['code'] ?? 'N/A'}', style: theme.textTheme.titleMedium?.copyWith( - color: onPrimary, - fontFamily: 'monospace', - fontSize: 16, - fontWeight: FontWeight.bold, - ), + color: onPrimary, + fontFamily: 'monospace', + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), const SizedBox(height: 20), Row( @@ -206,11 +211,11 @@ class _LoyaltyCardTile extends StatelessWidget { Text( 'AVAILABLE POINTS', style: theme.textTheme.bodySmall?.copyWith( - color: onPrimary.withValues(alpha: 0.7), - fontWeight: FontWeight.bold, - fontSize: 10, - letterSpacing: 1.0, - ), + color: onPrimary.withValues(alpha: 0.7), + fontWeight: FontWeight.bold, + fontSize: 10, + letterSpacing: 1.0, + ), ), const SizedBox(height: 4), Row( @@ -224,20 +229,29 @@ class _LoyaltyCardTile extends StatelessWidget { Text( 'Dine & Save', style: theme.textTheme.bodyMedium?.copyWith( - color: onPrimary.withValues(alpha: 0.9), - fontStyle: FontStyle.italic, - ), + color: onPrimary.withValues(alpha: 0.9), + fontStyle: FontStyle.italic, + ), ), ], ), ], ), - Text( - '${card['points'] ?? 0}', - style: theme.textTheme.displayLarge?.copyWith( - color: accentColor, - fontWeight: FontWeight.bold, + const SizedBox(width: 16), + Expanded( + child: Align( + alignment: Alignment.bottomRight, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '${card['points'] ?? 0}', + style: theme.textTheme.displayMedium?.copyWith( + color: accentColor, + fontWeight: FontWeight.bold, + ), ), + ), + ), ), ], ), diff --git a/lib/screens/notification_detail_screen.dart b/lib/screens/notification_detail_screen.dart index 4cdcdca..0a60a36 100644 --- a/lib/screens/notification_detail_screen.dart +++ b/lib/screens/notification_detail_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import '../theme/app_theme.dart'; import '../services/odoo_service.dart'; +import '../utils/safe_cast.dart'; class NotificationDetailScreen extends StatelessWidget { final dynamic notif; @@ -10,13 +11,11 @@ class NotificationDetailScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final title = notif['title'] as String? ?? 'Notice'; - final bodyHtml = notif['body'] as String? ?? ''; + final title = safeString(notif['title']) ?? 'Notice'; + final bodyHtml = safeString(notif['body']) ?? ''; return Scaffold( - appBar: AppBar( - title: const Text('Notification Detail'), - ), + appBar: AppBar(title: const Text('Notification Detail')), body: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -44,9 +43,9 @@ class NotificationDetailScreen extends StatelessWidget { title, textAlign: TextAlign.center, style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: AppTheme.onSurface, - fontSize: 20, - ), + color: AppTheme.onSurface, + fontSize: 20, + ), ), ], ), @@ -75,8 +74,11 @@ class NotificationDetailScreen extends StatelessWidget { height: 150, color: AppTheme.surfaceContainerLow, child: const Center( - child: Icon(Icons.broken_image_outlined, - size: 48, color: AppTheme.onSurfaceVariant), + child: Icon( + Icons.broken_image_outlined, + size: 48, + color: AppTheme.onSurfaceVariant, + ), ), ); }, @@ -93,9 +95,9 @@ class NotificationDetailScreen extends StatelessWidget { child: Text( 'MESSAGE', style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: AppTheme.onSurfaceVariant, - letterSpacing: 1.2, - ), + color: AppTheme.onSurfaceVariant, + letterSpacing: 1.2, + ), ), ), const SizedBox(height: 8), @@ -116,9 +118,9 @@ class NotificationDetailScreen extends StatelessWidget { child: Text( 'No additional details.', style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), + color: AppTheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), ), ) : Padding( diff --git a/lib/screens/notifications_screen.dart b/lib/screens/notifications_screen.dart index 44485ee..f7b057e 100644 --- a/lib/screens/notifications_screen.dart +++ b/lib/screens/notifications_screen.dart @@ -5,6 +5,7 @@ import '../services/odoo_service.dart'; import '../services/notification_service.dart'; import '../theme/app_theme.dart'; import 'notification_detail_screen.dart'; +import '../utils/safe_cast.dart'; class NotificationsScreen extends StatefulWidget { const NotificationsScreen({super.key}); @@ -67,71 +68,98 @@ class _NotificationsScreenState extends State { appBar: AppBar(title: const Text('Notifications')), body: _isLoading ? const Center(child: CircularProgressIndicator()) - : _notifications.isEmpty - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.notifications_none, - size: 64, - color: AppTheme.onSurfaceVariant.withValues(alpha: 0.4), - ), - const SizedBox(height: 16), - Text( - 'No notifications yet.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.onSurfaceVariant, + : RefreshIndicator( + onRefresh: _fetchNotifications, + child: _notifications.isEmpty + ? CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.notifications_none, + size: 64, + color: AppTheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + ), + const SizedBox(height: 16), + Text( + 'No notifications yet.', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith( + color: AppTheme.onSurfaceVariant, + ), + ), + ], ), + ), + ), + ], + ) + : ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 20, ), - ], - ), - ) - : RefreshIndicator( - onRefresh: _fetchNotifications, - child: ListView.separated( - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 20), - itemCount: _notifications.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final notif = _notifications[index]; - final isUnread = !_readIds.contains((notif['id'] as int? ?? 0).toString()); - return _NotificationCard( - notif: notif, - isUnread: isUnread, - onTap: () async { - final prefs = await SharedPreferences.getInstance(); - final partnerId = OdooService().client?.sessionId?.partnerId ?? 0; - final keyReadNotificationIds = 'read_notification_ids_$partnerId'; - final readIds = prefs.getStringList(keyReadNotificationIds) ?? []; - final notifIdStr = (notif['id'] as int? ?? 0).toString(); - if (!readIds.contains(notifIdStr)) { - readIds.add(notifIdStr); - await prefs.setStringList(keyReadNotificationIds, readIds); + itemCount: _notifications.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final notif = _notifications[index]; + final isUnread = !_readIds.contains( + (notif['id'] as int? ?? 0).toString(), + ); + return _NotificationCard( + notif: notif, + isUnread: isUnread, + onTap: () async { + final prefs = await SharedPreferences.getInstance(); + final partnerId = + OdooService().client?.sessionId?.partnerId ?? 0; + final keyReadNotificationIds = + 'read_notification_ids_$partnerId'; + final readIds = + prefs.getStringList(keyReadNotificationIds) ?? + []; + final notifIdStr = (notif['id'] as int? ?? 0) + .toString(); + if (!readIds.contains(notifIdStr)) { + readIds.add(notifIdStr); + await prefs.setStringList( + keyReadNotificationIds, + readIds, + ); - // Recalculate and update system badge count - final unreadCount = _notifications - .where((n) => !readIds.contains((n['id'] as int? ?? 0).toString())) - .length; - await NotificationService().setBadge(unreadCount); - } + // Recalculate and update system badge count + final unreadCount = _notifications + .where( + (n) => !readIds.contains( + (n['id'] as int? ?? 0).toString(), + ), + ) + .length; + await NotificationService().setBadge(unreadCount); + } - if (!context.mounted) return; - await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - NotificationDetailScreen(notif: notif), - ), - ); - if (!context.mounted) return; - _fetchNotifications(); - }, - ); - }, - ), - ), + if (!context.mounted) return; + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + NotificationDetailScreen(notif: notif), + ), + ); + if (!context.mounted) return; + _fetchNotifications(); + }, + ); + }, + ), + ), ); } } @@ -149,7 +177,7 @@ class _NotificationCard extends StatelessWidget { @override Widget build(BuildContext context) { - final title = notif['title'] as String? ?? 'Notice'; + final title = safeString(notif['title']) ?? 'Notice'; return Material( color: AppTheme.surfaceContainerLow, @@ -191,9 +219,9 @@ class _NotificationCard extends StatelessWidget { child: Text( title, style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: AppTheme.onSurface, - fontWeight: isUnread ? FontWeight.bold : FontWeight.normal, - ), + color: AppTheme.onSurface, + fontWeight: isUnread ? FontWeight.bold : FontWeight.normal, + ), maxLines: 2, overflow: TextOverflow.ellipsis, ), diff --git a/lib/screens/orders_screen.dart b/lib/screens/orders_screen.dart index bb4b5f3..88fa834 100644 --- a/lib/screens/orders_screen.dart +++ b/lib/screens/orders_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../services/odoo_service.dart'; import '../theme/app_theme.dart'; +import '../utils/safe_cast.dart'; class OrdersScreen extends StatefulWidget { const OrdersScreen({super.key}); @@ -32,9 +33,9 @@ class _OrdersScreenState extends State { } catch (e) { if (mounted) { setState(() => _isLoading = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error loading history: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error loading history: $e'))); } } } @@ -44,8 +45,18 @@ class _OrdersScreenState extends State { try { final parsed = DateTime.parse(rawDate); final months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', ]; final year = parsed.year; final month = months[parsed.month - 1]; @@ -63,73 +74,89 @@ class _OrdersScreenState extends State { final theme = Theme.of(context); if (_isLoading) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return const Scaffold(body: Center(child: CircularProgressIndicator())); } return Scaffold( - appBar: AppBar( - title: const Text('Order & Points History'), - ), - body: _history.isEmpty - ? Center( - child: Padding( - padding: const EdgeInsets.all(40), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(28), - decoration: const BoxDecoration( - color: AppTheme.surfaceContainerLow, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.receipt_long_rounded, - size: 56, - color: AppTheme.secondary, + appBar: AppBar(title: const Text('Order & Points History')), + body: RefreshIndicator( + onRefresh: _fetchHistory, + child: _history.isEmpty + ? CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(40), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(28), + decoration: const BoxDecoration( + color: AppTheme.surfaceContainerLow, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.receipt_long_rounded, + size: 56, + color: AppTheme.secondary, + ), + ), + const SizedBox(height: 28), + Text( + 'No Orders Yet', + style: theme.textTheme.headlineMedium, + ), + const SizedBox(height: 12), + Text( + 'Your order history and points transactions will show up here after you make purchases.', + style: theme.textTheme.bodyLarge?.copyWith( + color: AppTheme.onSurfaceVariant, + height: 1.6, + ), + textAlign: TextAlign.center, + ), + ], + ), ), ), - const SizedBox(height: 28), - Text( - 'No Orders Yet', - style: theme.textTheme.headlineMedium, - ), - const SizedBox(height: 12), - Text( - 'Your order history and points transactions will show up here after you make purchases.', - style: theme.textTheme.bodyLarge?.copyWith( - color: AppTheme.onSurfaceVariant, - height: 1.6, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ) - : RefreshIndicator( - onRefresh: _fetchHistory, - child: ListView.builder( + ), + ], + ) + : ListView.builder( padding: const EdgeInsets.symmetric(vertical: 16), itemCount: _history.length, itemBuilder: (context, index) { final item = _history[index]; final isEarn = item['type'] == 'earn'; - final orderRef = item['order_ref'] as String? ?? ''; - final rawDate = item['date'] as String? ?? ''; - final posName = item['pos_name'] as String? ?? ''; + final orderRef = safeString(item['order_ref']) ?? ''; + final rawDate = safeString(item['date']) ?? ''; + 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( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, + ), color: AppTheme.surfaceContainerLow, child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), leading: Container( padding: const EdgeInsets.all(10), 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, ), child: Icon( @@ -176,6 +203,52 @@ class _OrdersScreenState extends State { 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, + ), + ), + ], + ), + ), ], ), ), @@ -191,7 +264,7 @@ class _OrdersScreenState extends State { ); }, ), - ), + ), ); } } diff --git a/lib/screens/promo_detail_screen.dart b/lib/screens/promo_detail_screen.dart index dce8a69..038dbcd 100644 --- a/lib/screens/promo_detail_screen.dart +++ b/lib/screens/promo_detail_screen.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import '../theme/app_theme.dart'; +import '../utils/safe_cast.dart'; /// Promo detail screen — mirrors notification detail but for promo highlights. /// Shows the promo image (full size), title, and rich HTML body content. @@ -12,9 +13,9 @@ class PromoDetailScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final title = promo['name'] as String? ?? 'Promo'; - final bodyHtml = promo['body'] as String? ?? ''; - final base64Img = promo['image_128'] as String?; + final title = safeString(promo['name']) ?? 'Promo'; + final bodyHtml = safeString(promo['body']) ?? ''; + final base64Img = safeString(promo['image_128']); return Scaffold( appBar: AppBar(title: Text(title)), @@ -28,10 +29,7 @@ class PromoDetailScreen extends StatelessWidget { Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge, - ), + child: Text(title, style: Theme.of(context).textTheme.titleLarge), ), if (bodyHtml.isNotEmpty) @@ -91,7 +89,11 @@ class PromoDetailScreen extends StatelessWidget { height: 220, color: AppTheme.surfaceContainer, 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, + ), ), ); } diff --git a/lib/services/config.dart b/lib/services/config.dart index e4715c4..6e07026 100644 --- a/lib/services/config.dart +++ b/lib/services/config.dart @@ -1,5 +1,5 @@ class AppConfig { static const String odooUrl = '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 } diff --git a/lib/utils/safe_cast.dart b/lib/utils/safe_cast.dart new file mode 100644 index 0000000..513e19d --- /dev/null +++ b/lib/utils/safe_cast.dart @@ -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; +} diff --git a/lib/widgets/carousel_widget.dart b/lib/widgets/carousel_widget.dart index 8fe168e..b3273a1 100644 --- a/lib/widgets/carousel_widget.dart +++ b/lib/widgets/carousel_widget.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:smooth_page_indicator/smooth_page_indicator.dart'; import '../screens/carousel_detail_screen.dart'; +import '../utils/safe_cast.dart'; /// Auto-scrolling carousel widget that shows slides from CMS. /// Each slide can have an uploaded image (base64) or external image URL. @@ -102,8 +103,8 @@ class _SlideImage extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final base64Img = slide['image'] as String?; - final externalUrl = slide['image_url'] as String?; + final base64Img = safeString(slide['image']); + final externalUrl = safeString(slide['image_url']); Widget image; @@ -144,10 +145,7 @@ class _SlideImage extends StatelessWidget { ), ], ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: image, - ), + child: ClipRRect(borderRadius: BorderRadius.circular(16), child: image), ); } diff --git a/lib/widgets/promo_card_widget.dart b/lib/widgets/promo_card_widget.dart index d63c277..09a6dd9 100644 --- a/lib/widgets/promo_card_widget.dart +++ b/lib/widgets/promo_card_widget.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import '../screens/promo_detail_screen.dart'; +import '../utils/safe_cast.dart'; /// Horizontal scrollable row of promo highlight cards. /// Tapping a card opens the full detail screen with rich text content. @@ -49,15 +50,19 @@ class _PromoCard extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final base64Img = promo['image_128'] as String?; - final title = promo['name'] as String? ?? ''; + final base64Img = safeString(promo['image_128']); + final title = safeString(promo['name']) ?? ''; Widget imageWidget; if (base64Img != null && base64Img.isNotEmpty) { try { final Uint8List bytes = base64Decode(base64Img); - imageWidget = Image.memory(bytes, fit: BoxFit.cover, - width: double.infinity, height: 110); + imageWidget = Image.memory( + bytes, + fit: BoxFit.cover, + width: double.infinity, + height: 110, + ); } catch (_) { imageWidget = _imagePlaceholder(colorScheme); } @@ -97,7 +102,9 @@ class _PromoCard extends StatelessWidget { height: 110, width: double.infinity, child: ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(15)), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(15), + ), child: imageWidget, ), ), @@ -124,7 +131,11 @@ class _PromoCard extends StatelessWidget { return Container( color: colorScheme.surfaceContainer, child: Center( - child: Icon(Icons.local_offer_rounded, size: 32, color: colorScheme.outline), + child: Icon( + Icons.local_offer_rounded, + size: 32, + color: colorScheme.outline, + ), ), ); } diff --git a/lib/widgets/subscription_list_widget.dart b/lib/widgets/subscription_list_widget.dart index 2479358..e10915e 100644 --- a/lib/widgets/subscription_list_widget.dart +++ b/lib/widgets/subscription_list_widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../utils/safe_cast.dart'; /// "My Subscriptions" list displayed on the home tab below the loyalty card. /// Renders each subscription as a beautiful standalone ticket card visually distinct @@ -23,11 +24,11 @@ class SubscriptionListWidget extends StatelessWidget { child: Text( 'MY SUBSCRIPTIONS', style: theme.textTheme.labelLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - letterSpacing: 1.2, - fontSize: 11, - fontWeight: FontWeight.bold, - ), + color: colorScheme.onSurfaceVariant, + letterSpacing: 1.2, + fontSize: 11, + fontWeight: FontWeight.bold, + ), ), ), ...subscriptions.map((sub) => _SubscriptionCard(sub: sub)), @@ -68,8 +69,18 @@ class _SubscriptionCard extends StatelessWidget { String _monthName(int m) { const months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', ]; return months[m - 1]; } @@ -80,9 +91,9 @@ class _SubscriptionCard extends StatelessWidget { final colorScheme = theme.colorScheme; final programName = sub['program_id'] is List - ? (sub['program_id'][1] as String? ?? 'Subscription') + ? (safeString(sub['program_id'][1]) ?? 'Subscription') : 'Subscription'; - final code = sub['code'] as String? ?? ''; + final code = safeString(sub['code']) ?? ''; final startDate = _formatDate(sub['subscription_start_date']); final endDate = _formatDate(sub['subscription_end_date']); final active = _isActive(); @@ -106,24 +117,32 @@ class _SubscriptionCard extends StatelessWidget { Row( children: [ Icon( - Icons.local_activity_rounded, // Distinct ticket pass icon + Icons + .local_activity_rounded, // Distinct ticket pass icon size: 18, - color: active ? colorScheme.primary : colorScheme.outline, + color: active + ? colorScheme.primary + : colorScheme.outline, ), const SizedBox(width: 8), Text( 'SUBSCRIPTION PASS', // Distinct title style: theme.textTheme.labelLarge?.copyWith( - color: active ? colorScheme.primary : colorScheme.outline, - fontWeight: FontWeight.bold, - fontSize: 10, - letterSpacing: 1.2, - ), + color: active + ? colorScheme.primary + : colorScheme.outline, + fontWeight: FontWeight.bold, + fontSize: 10, + letterSpacing: 1.2, + ), ), ], ), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), decoration: BoxDecoration( color: active ? const Color(0xFF1B5E20).withValues(alpha: 0.10) @@ -157,10 +176,10 @@ class _SubscriptionCard extends StatelessWidget { Text( programName, style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - fontFamily: 'serif', - ), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + fontFamily: 'serif', + ), ), const SizedBox(height: 12), Row( @@ -172,18 +191,18 @@ class _SubscriptionCard extends StatelessWidget { Text( 'Pass Code', // Distinct field label style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 10, - ), + color: colorScheme.onSurfaceVariant, + fontSize: 10, + ), ), const SizedBox(height: 2), Text( code.isNotEmpty ? code : 'N/A', style: theme.textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), ), ], ), @@ -194,23 +213,24 @@ class _SubscriptionCard extends StatelessWidget { Text( 'Claim Balance', style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 10, - ), + color: colorScheme.onSurfaceVariant, + fontSize: 10, + ), ), const SizedBox(height: 2), Text( '${(sub['points'] as num).toDouble() % 1 == 0 ? (sub['points'] as num).toInt() : sub['points']} Claims', style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.bold, - ), + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), ), ], ), ], ), - 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), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -221,17 +241,17 @@ class _SubscriptionCard extends StatelessWidget { Text( 'Valid From', style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 10, - ), + color: colorScheme.onSurfaceVariant, + fontSize: 10, + ), ), const SizedBox(height: 2), Text( startDate, style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), ), ], ), @@ -241,17 +261,17 @@ class _SubscriptionCard extends StatelessWidget { Text( 'Expires On', style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 10, - ), + color: colorScheme.onSurfaceVariant, + fontSize: 10, + ), ), const SizedBox(height: 2), Text( endDate, style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), ), ], ), @@ -273,7 +293,7 @@ class TicketClipper extends CustomClipper { final path = Path(); path.lineTo(0, 0); path.lineTo(size.width, 0); - + // Right side notch at height 49 (approx. center of vertical separator space) const double notchY = 49.0; const double notchRadius = 8.0; @@ -285,7 +305,7 @@ class TicketClipper extends CustomClipper { ); path.lineTo(size.width, size.height); path.lineTo(0, size.height); - + // Left side notch path.lineTo(0, notchY + notchRadius); path.arcToPoint( @@ -317,11 +337,7 @@ class DashedLinePainter extends CustomPainter { ..strokeWidth = 1.0; while (startX < size.width) { - canvas.drawLine( - Offset(startX, 0), - Offset(startX + dashWidth, 0), - paint, - ); + canvas.drawLine(Offset(startX, 0), Offset(startX + dashWidth, 0), paint); startX += dashWidth + dashSpace; } }