From 0a8c12c1d32aee4ad1bf45dddd676f3ad7416237 Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Wed, 31 Dec 2025 09:40:23 -0800 Subject: [PATCH] Enhance UI with Material Design 3 and fix cart quantity handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI Improvements: - Menu items displayed as attractive cards with icons and better typography - Restaurant selection upgraded to card-based layout with shadows - Animated pulsing beacon scanner with gradient effect - Enhanced item customization sheet with drag handle and pill-style pricing - Category headers with highlighted background and borders - Business and service point names now shown in app bar Persistent Login: - Created AuthStorage service for credential persistence using SharedPreferences - Auto-restore authentication on app launch - Seamless login flow: scan → browse → login on cart add - Users stay logged in across app restarts Cart Functionality Fixes: - Fixed duplicate item handling: now properly increments quantity - Prevented adding inactive items by skipping unselected modifiers - Fixed self-referential items (item cannot be its own child) - Added debug logging for cart state tracking - Success messages now show accurate item counts Technical Improvements: - AppState tracks business/service point names for display - Beacon scanner passes location names through navigation - Quantity calculation checks existing cart items before adding - Better null safety with firstOrNull pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- lib/app/app_state.dart | 13 +- lib/screens/beacon_scan_screen.dart | 47 +++- lib/screens/login_screen.dart | 7 + lib/screens/menu_browse_screen.dart | 269 +++++++++++++++++----- lib/screens/restaurant_select_screen.dart | 74 +++++- lib/screens/splash_screen.dart | 15 +- lib/services/api.dart | 4 +- lib/services/auth_storage.dart | 45 ++++ 8 files changed, 403 insertions(+), 71 deletions(-) create mode 100644 lib/services/auth_storage.dart diff --git a/lib/app/app_state.dart b/lib/app/app_state.dart index 875dc12..638f9af 100644 --- a/lib/app/app_state.dart +++ b/lib/app/app_state.dart @@ -2,7 +2,9 @@ import "package:flutter/foundation.dart"; class AppState extends ChangeNotifier { int? _selectedBusinessId; + String? _selectedBusinessName; int? _selectedServicePointId; + String? _selectedServicePointName; int? _userId; @@ -12,7 +14,9 @@ class AppState extends ChangeNotifier { int? get selectedBusinessId => _selectedBusinessId; + String? get selectedBusinessName => _selectedBusinessName; int? get selectedServicePointId => _selectedServicePointId; + String? get selectedServicePointName => _selectedServicePointName; int? get userId => _userId; bool get isLoggedIn => _userId != null && _userId! > 0; @@ -44,9 +48,16 @@ class AppState extends ChangeNotifier { notifyListeners(); } - void setBusinessAndServicePoint(int businessId, int servicePointId) { + void setBusinessAndServicePoint( + int businessId, + int servicePointId, { + String? businessName, + String? servicePointName, + }) { _selectedBusinessId = businessId; + _selectedBusinessName = businessName; _selectedServicePointId = servicePointId; + _selectedServicePointName = servicePointName; _cartOrderId = null; _cartOrderUuid = null; diff --git a/lib/screens/beacon_scan_screen.dart b/lib/screens/beacon_scan_screen.dart index 25d04ea..9e31a42 100644 --- a/lib/screens/beacon_scan_screen.dart +++ b/lib/screens/beacon_scan_screen.dart @@ -15,7 +15,7 @@ class BeaconScanScreen extends StatefulWidget { State createState() => _BeaconScanScreenState(); } -class _BeaconScanScreenState extends State { +class _BeaconScanScreenState extends State with SingleTickerProviderStateMixin { String _status = 'Initializing...'; bool _permissionsGranted = false; bool _scanning = false; @@ -24,12 +24,28 @@ class _BeaconScanScreenState extends State { final Map> _beaconRssiSamples = {}; // UUID -> List of RSSI values final Map _beaconDetectionCount = {}; // UUID -> detection count + late AnimationController _pulseController; + late Animation _pulseAnimation; + @override void initState() { super.initState(); + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(reverse: true); + _pulseAnimation = Tween(begin: 0.8, end: 1.2).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); _startScanFlow(); } + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); + } + Future _startScanFlow() async { // Step 1: Request permissions setState(() => _status = 'Requesting permissions...'); @@ -221,7 +237,12 @@ class _BeaconScanScreenState extends State { // Update app state with selected business and service point final appState = context.read(); - appState.setBusinessAndServicePoint(mapping.businessId, mapping.servicePointId); + appState.setBusinessAndServicePoint( + mapping.businessId, + mapping.servicePointId, + businessName: mapping.businessName, + servicePointName: mapping.servicePointName, + ); // Update API business ID for headers Api.setBusinessId(mapping.businessId); @@ -291,7 +312,27 @@ class _BeaconScanScreenState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ if (_scanning) - const CircularProgressIndicator(color: Colors.white) + ScaleTransition( + scale: _pulseAnimation, + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + Colors.blue.withAlpha(102), + Colors.blue.withAlpha(26), + ], + ), + ), + child: const Icon( + Icons.bluetooth_searching, + color: Colors.white, + size: 48, + ), + ), + ) else if (_permissionsGranted) const Icon(Icons.bluetooth_searching, color: Colors.white70, size: 64) else diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 01ed52e..0e3441a 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -4,6 +4,7 @@ import "package:provider/provider.dart"; import "../app/app_router.dart"; import "../app/app_state.dart"; import "../services/api.dart"; +import "../services/auth_storage.dart"; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -45,6 +46,12 @@ class _LoginScreenState extends State { if (!mounted) return; + // Save credentials for persistent login + await AuthStorage.saveAuth( + userId: result.userId, + token: result.token, + ); + final appState = context.read(); appState.setUserId(result.userId); diff --git a/lib/screens/menu_browse_screen.dart b/lib/screens/menu_browse_screen.dart index 0b53813..0c48c0c 100644 --- a/lib/screens/menu_browse_screen.dart +++ b/lib/screens/menu_browse_screen.dart @@ -81,7 +81,10 @@ class _MenuBrowseScreenState extends State { if (item.isRootItem) { _itemsByCategory.putIfAbsent(item.categoryId, () => []).add(item); } else { - _itemsByParent.putIfAbsent(item.parentItemId, () => []).add(item); + // Prevent an item from being its own child + if (item.itemId != item.parentItemId) { + _itemsByParent.putIfAbsent(item.parentItemId, () => []).add(item); + } } } @@ -105,10 +108,28 @@ class _MenuBrowseScreenState extends State { @override Widget build(BuildContext context) { final appState = context.watch(); + final businessName = appState.selectedBusinessName ?? "Menu"; return Scaffold( appBar: AppBar( - title: const Text("Menu"), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + businessName, + style: const TextStyle(fontSize: 18), + ), + if (appState.selectedServicePointName != null) + Text( + appState.selectedServicePointName!, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.normal, + ), + ), + ], + ), actions: [ IconButton( icon: Badge( @@ -171,8 +192,18 @@ class _MenuBrowseScreenState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.all(16), + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 24, 16, 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + width: 1, + ), + ), + ), child: Text( categoryName, style: Theme.of(context).textTheme.titleLarge?.copyWith( @@ -194,29 +225,101 @@ class _MenuBrowseScreenState extends State { Widget _buildMenuItem(MenuItem item) { final hasModifiers = _itemsByParent.containsKey(item.itemId); - return ListTile( - title: Text(item.name), - subtitle: item.description.isNotEmpty - ? Text( - item.description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ) - : null, - trailing: Text( - "\$${item.price.toStringAsFixed(2)}", - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant.withAlpha(128), + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + if (hasModifiers) { + _showItemCustomization(item); + } else { + _addToCart(item, {}); + } + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Food icon + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.restaurant, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 16), + // Item details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (item.description.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + item.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + if (hasModifiers) ...[ + const SizedBox(height: 4), + Text( + "Customizable", + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + // Price and add button + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + "\$${item.price.toStringAsFixed(2)}", + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 4), + Icon( + Icons.add_circle, + color: Theme.of(context).colorScheme.primary, + size: 20, + ), + ], + ), + ], + ), ), ), - onTap: () { - if (hasModifiers) { - _showItemCustomization(item); - } else { - _addToCart(item, {}); - } - }, ); } @@ -302,13 +405,23 @@ class _MenuBrowseScreenState extends State { // ignore: avoid_print print("DEBUG: About to add root item ${item.itemId} to cart ${cart.orderId}"); - // Add root item + // Check if this item already exists in the cart (as a root item) + final existingItem = cart.lineItems.where( + (li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted + ).firstOrNull; + + final newQuantity = (existingItem?.quantity ?? 0) + 1; + + // ignore: avoid_print + print("DEBUG: Existing quantity: ${existingItem?.quantity ?? 0}, new quantity: $newQuantity"); + + // Add root item (or update quantity if it exists) cart = await Api.setLineItem( orderId: cart.orderId, parentOrderLineItemId: 0, itemId: item.itemId, isSelected: true, - quantity: 1, + quantity: newQuantity, ); // ignore: avoid_print @@ -337,13 +450,18 @@ class _MenuBrowseScreenState extends State { // Refresh cart to get final state cart = await Api.getCart(orderId: cart.orderId); + + // ignore: avoid_print + print("DEBUG: Final cart state - itemCount=${cart.itemCount}, lineItems=${cart.lineItems.length}"); + print("DEBUG: Root items: ${cart.lineItems.where((li) => li.parentOrderLineItemId == 0 && !li.isDeleted).map((li) => 'ItemID=${li.itemId}, Qty=${li.quantity}, Deleted=${li.isDeleted}').join(', ')}"); + appState.updateCartItemCount(cart.itemCount); if (!mounted) return; final message = selectedModifierIds.isEmpty - ? "Added ${item.name} to cart" - : "Added ${item.name} with ${selectedModifierIds.length} customizations"; + ? "Added ${item.name} to cart (${cart.itemCount} item${cart.itemCount == 1 ? '' : 's'})" + : "Added ${item.name} with ${selectedModifierIds.length} customizations (${cart.itemCount} item${cart.itemCount == 1 ? '' : 's'})"; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message)), @@ -374,6 +492,13 @@ class _MenuBrowseScreenState extends State { for (final child in children) { final isSelected = selectedItemIds.contains(child.itemId); + // Only add selected items to the cart + if (!isSelected) { + // ignore: avoid_print + print("DEBUG: Skipping unselected child ItemID=${child.itemId} (${child.name})"); + continue; + } + // ignore: avoid_print print("DEBUG: Processing child ItemID=${child.itemId} (${child.name}), isSelected=$isSelected"); @@ -382,30 +507,28 @@ class _MenuBrowseScreenState extends State { orderId: orderId, parentOrderLineItemId: parentOrderLineItemId, itemId: child.itemId, - isSelected: isSelected, + isSelected: true, ); // ignore: avoid_print print("DEBUG: setLineItem response: cart has ${cart.lineItems.length} line items"); - // Recursively add grandchildren if this modifier was selected - if (isSelected) { - // Find the OrderLineItemID of this modifier we just added - final childLineItem = cart.lineItems.lastWhere( - (li) => li.itemId == child.itemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted, - orElse: () => throw StateError('Child line item not found for ItemID=${child.itemId}'), - ); + // Find the OrderLineItemID of this modifier we just added + final childLineItem = cart.lineItems.lastWhere( + (li) => li.itemId == child.itemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted, + orElse: () => throw StateError('Child line item not found for ItemID=${child.itemId}'), + ); - // ignore: avoid_print - print("DEBUG: Child modifier OrderLineItemID=${childLineItem.orderLineItemId}"); + // ignore: avoid_print + print("DEBUG: Child modifier OrderLineItemID=${childLineItem.orderLineItemId}"); - await _addModifiersRecursively( - orderId, - childLineItem.orderLineItemId, - child.itemId, - selectedItemIds, - ); - } + // Recursively add grandchildren + await _addModifiersRecursively( + orderId, + childLineItem.orderLineItemId, + child.itemId, + selectedItemIds, + ); } } } @@ -518,34 +641,62 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> { children: [ // Header Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, - boxShadow: [ - BoxShadow( - color: Colors.black.withAlpha(25), - blurRadius: 4, - offset: const Offset(0, 2), + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + width: 1, ), - ], + ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row( + children: [ + Container( + width: 12, + height: 4, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurfaceVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + ], + ), + const SizedBox(height: 16), Text( widget.item.name, - style: Theme.of(context).textTheme.titleLarge?.copyWith( + style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, ), ), if (widget.item.description.isNotEmpty) ...[ const SizedBox(height: 8), - Text(widget.item.description), + Text( + widget.item.description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), ], - const SizedBox(height: 8), - Text( - "Base price: \$${widget.item.price.toStringAsFixed(2)}", - style: const TextStyle(fontWeight: FontWeight.w500), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + "Base: \$${widget.item.price.toStringAsFixed(2)}", + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), ), ], ), diff --git a/lib/screens/restaurant_select_screen.dart b/lib/screens/restaurant_select_screen.dart index 91d35aa..909297c 100644 --- a/lib/screens/restaurant_select_screen.dart +++ b/lib/screens/restaurant_select_screen.dart @@ -111,15 +111,77 @@ class _RestaurantSelectScreenState extends State { ); } - return ListView.separated( + return ListView.builder( + padding: const EdgeInsets.all(16), itemCount: items.length, - separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, i) { final r = items[i]; - return ListTile( - title: Text(r.name), - trailing: const Icon(Icons.chevron_right), - onTap: () => _selectBusinessAndContinue(r), + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(15), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Material( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.surface, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => _selectBusinessAndContinue(r), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.store, + size: 32, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + r.name, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + "Tap to view menu", + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios, + color: Theme.of(context).colorScheme.onSurfaceVariant, + size: 20, + ), + ], + ), + ), + ), + ), ); }, ); diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index cd400be..5096c76 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -4,6 +4,8 @@ import "package:provider/provider.dart"; import "../app/app_router.dart"; import "../app/app_state.dart"; +import "../services/api.dart"; +import "../services/auth_storage.dart"; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @@ -19,7 +21,18 @@ class _SplashScreenState extends State { void initState() { super.initState(); - _timer = Timer(const Duration(milliseconds: 2400), () { + _timer = Timer(const Duration(milliseconds: 2400), () async { + if (!mounted) return; + + // Check for saved authentication credentials + final credentials = await AuthStorage.loadAuth(); + if (credentials != null) { + // Restore authentication state + Api.setAuthToken(credentials.token); + final appState = context.read(); + appState.setUserId(credentials.userId); + } + if (!mounted) return; // Always go to beacon scan first - allows browsing without login diff --git a/lib/services/api.dart b/lib/services/api.dart index 329559e..ea63ec0 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -5,6 +5,7 @@ import "../models/cart.dart"; import "../models/menu_item.dart"; import "../models/restaurant.dart"; import "../models/service_point.dart"; +import "auth_storage.dart"; class ApiRawResponse { final int statusCode; @@ -197,9 +198,10 @@ class Api { return response; } - static void logout() { + static Future logout() async { setAuthToken(null); clearCookies(); + await AuthStorage.clearAuth(); } // ------------------------- diff --git a/lib/services/auth_storage.dart b/lib/services/auth_storage.dart new file mode 100644 index 0000000..5c3bf04 --- /dev/null +++ b/lib/services/auth_storage.dart @@ -0,0 +1,45 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class AuthStorage { + static const _keyUserId = 'auth_user_id'; + static const _keyUserToken = 'auth_user_token'; + + /// Save authentication credentials + static Future saveAuth({ + required int userId, + required String token, + }) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_keyUserId, userId); + await prefs.setString(_keyUserToken, token); + } + + /// Load saved authentication credentials + static Future loadAuth() async { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt(_keyUserId); + final token = prefs.getString(_keyUserToken); + + if (userId != null && token != null && token.isNotEmpty) { + return AuthCredentials(userId: userId, token: token); + } + return null; + } + + /// Clear authentication credentials (logout) + static Future clearAuth() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keyUserId); + await prefs.remove(_keyUserToken); + } +} + +class AuthCredentials { + final int userId; + final String token; + + const AuthCredentials({ + required this.userId, + required this.token, + }); +}