Enhance UI with Material Design 3 and fix cart quantity handling
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 <noreply@anthropic.com>
This commit is contained in:
parent
c445664df8
commit
0a8c12c1d3
8 changed files with 403 additions and 71 deletions
|
|
@ -2,7 +2,9 @@ import "package:flutter/foundation.dart";
|
||||||
|
|
||||||
class AppState extends ChangeNotifier {
|
class AppState extends ChangeNotifier {
|
||||||
int? _selectedBusinessId;
|
int? _selectedBusinessId;
|
||||||
|
String? _selectedBusinessName;
|
||||||
int? _selectedServicePointId;
|
int? _selectedServicePointId;
|
||||||
|
String? _selectedServicePointName;
|
||||||
|
|
||||||
int? _userId;
|
int? _userId;
|
||||||
|
|
||||||
|
|
@ -12,7 +14,9 @@ class AppState extends ChangeNotifier {
|
||||||
|
|
||||||
|
|
||||||
int? get selectedBusinessId => _selectedBusinessId;
|
int? get selectedBusinessId => _selectedBusinessId;
|
||||||
|
String? get selectedBusinessName => _selectedBusinessName;
|
||||||
int? get selectedServicePointId => _selectedServicePointId;
|
int? get selectedServicePointId => _selectedServicePointId;
|
||||||
|
String? get selectedServicePointName => _selectedServicePointName;
|
||||||
|
|
||||||
int? get userId => _userId;
|
int? get userId => _userId;
|
||||||
bool get isLoggedIn => _userId != null && _userId! > 0;
|
bool get isLoggedIn => _userId != null && _userId! > 0;
|
||||||
|
|
@ -44,9 +48,16 @@ class AppState extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setBusinessAndServicePoint(int businessId, int servicePointId) {
|
void setBusinessAndServicePoint(
|
||||||
|
int businessId,
|
||||||
|
int servicePointId, {
|
||||||
|
String? businessName,
|
||||||
|
String? servicePointName,
|
||||||
|
}) {
|
||||||
_selectedBusinessId = businessId;
|
_selectedBusinessId = businessId;
|
||||||
|
_selectedBusinessName = businessName;
|
||||||
_selectedServicePointId = servicePointId;
|
_selectedServicePointId = servicePointId;
|
||||||
|
_selectedServicePointName = servicePointName;
|
||||||
|
|
||||||
_cartOrderId = null;
|
_cartOrderId = null;
|
||||||
_cartOrderUuid = null;
|
_cartOrderUuid = null;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ class BeaconScanScreen extends StatefulWidget {
|
||||||
State<BeaconScanScreen> createState() => _BeaconScanScreenState();
|
State<BeaconScanScreen> createState() => _BeaconScanScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BeaconScanScreenState extends State<BeaconScanScreen> {
|
class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerProviderStateMixin {
|
||||||
String _status = 'Initializing...';
|
String _status = 'Initializing...';
|
||||||
bool _permissionsGranted = false;
|
bool _permissionsGranted = false;
|
||||||
bool _scanning = false;
|
bool _scanning = false;
|
||||||
|
|
@ -24,12 +24,28 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> {
|
||||||
final Map<String, List<int>> _beaconRssiSamples = {}; // UUID -> List of RSSI values
|
final Map<String, List<int>> _beaconRssiSamples = {}; // UUID -> List of RSSI values
|
||||||
final Map<String, int> _beaconDetectionCount = {}; // UUID -> detection count
|
final Map<String, int> _beaconDetectionCount = {}; // UUID -> detection count
|
||||||
|
|
||||||
|
late AnimationController _pulseController;
|
||||||
|
late Animation<double> _pulseAnimation;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_pulseController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1500),
|
||||||
|
)..repeat(reverse: true);
|
||||||
|
_pulseAnimation = Tween<double>(begin: 0.8, end: 1.2).animate(
|
||||||
|
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
||||||
|
);
|
||||||
_startScanFlow();
|
_startScanFlow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pulseController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _startScanFlow() async {
|
Future<void> _startScanFlow() async {
|
||||||
// Step 1: Request permissions
|
// Step 1: Request permissions
|
||||||
setState(() => _status = 'Requesting permissions...');
|
setState(() => _status = 'Requesting permissions...');
|
||||||
|
|
@ -221,7 +237,12 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> {
|
||||||
|
|
||||||
// Update app state with selected business and service point
|
// Update app state with selected business and service point
|
||||||
final appState = context.read<AppState>();
|
final appState = context.read<AppState>();
|
||||||
appState.setBusinessAndServicePoint(mapping.businessId, mapping.servicePointId);
|
appState.setBusinessAndServicePoint(
|
||||||
|
mapping.businessId,
|
||||||
|
mapping.servicePointId,
|
||||||
|
businessName: mapping.businessName,
|
||||||
|
servicePointName: mapping.servicePointName,
|
||||||
|
);
|
||||||
|
|
||||||
// Update API business ID for headers
|
// Update API business ID for headers
|
||||||
Api.setBusinessId(mapping.businessId);
|
Api.setBusinessId(mapping.businessId);
|
||||||
|
|
@ -291,7 +312,27 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> {
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
if (_scanning)
|
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)
|
else if (_permissionsGranted)
|
||||||
const Icon(Icons.bluetooth_searching, color: Colors.white70, size: 64)
|
const Icon(Icons.bluetooth_searching, color: Colors.white70, size: 64)
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import "package:provider/provider.dart";
|
||||||
import "../app/app_router.dart";
|
import "../app/app_router.dart";
|
||||||
import "../app/app_state.dart";
|
import "../app/app_state.dart";
|
||||||
import "../services/api.dart";
|
import "../services/api.dart";
|
||||||
|
import "../services/auth_storage.dart";
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
const LoginScreen({super.key});
|
const LoginScreen({super.key});
|
||||||
|
|
@ -45,6 +46,12 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Save credentials for persistent login
|
||||||
|
await AuthStorage.saveAuth(
|
||||||
|
userId: result.userId,
|
||||||
|
token: result.token,
|
||||||
|
);
|
||||||
|
|
||||||
final appState = context.read<AppState>();
|
final appState = context.read<AppState>();
|
||||||
appState.setUserId(result.userId);
|
appState.setUserId(result.userId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,12 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
if (item.isRootItem) {
|
if (item.isRootItem) {
|
||||||
_itemsByCategory.putIfAbsent(item.categoryId, () => []).add(item);
|
_itemsByCategory.putIfAbsent(item.categoryId, () => []).add(item);
|
||||||
} else {
|
} else {
|
||||||
|
// Prevent an item from being its own child
|
||||||
|
if (item.itemId != item.parentItemId) {
|
||||||
_itemsByParent.putIfAbsent(item.parentItemId, () => []).add(item);
|
_itemsByParent.putIfAbsent(item.parentItemId, () => []).add(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sort items within each category by sortOrder
|
// Sort items within each category by sortOrder
|
||||||
for (final list in _itemsByCategory.values) {
|
for (final list in _itemsByCategory.values) {
|
||||||
|
|
@ -105,10 +108,28 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appState = context.watch<AppState>();
|
final appState = context.watch<AppState>();
|
||||||
|
final businessName = appState.selectedBusinessName ?? "Menu";
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
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: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Badge(
|
icon: Badge(
|
||||||
|
|
@ -171,8 +192,18 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
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(
|
child: Text(
|
||||||
categoryName,
|
categoryName,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
|
@ -194,22 +225,17 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
Widget _buildMenuItem(MenuItem item) {
|
Widget _buildMenuItem(MenuItem item) {
|
||||||
final hasModifiers = _itemsByParent.containsKey(item.itemId);
|
final hasModifiers = _itemsByParent.containsKey(item.itemId);
|
||||||
|
|
||||||
return ListTile(
|
return Container(
|
||||||
title: Text(item.name),
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
subtitle: item.description.isNotEmpty
|
decoration: BoxDecoration(
|
||||||
? Text(
|
color: Theme.of(context).colorScheme.surface,
|
||||||
item.description,
|
borderRadius: BorderRadius.circular(12),
|
||||||
maxLines: 2,
|
border: Border.all(
|
||||||
overflow: TextOverflow.ellipsis,
|
color: Theme.of(context).colorScheme.outlineVariant.withAlpha(128),
|
||||||
)
|
|
||||||
: null,
|
|
||||||
trailing: Text(
|
|
||||||
"\$${item.price.toStringAsFixed(2)}",
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (hasModifiers) {
|
if (hasModifiers) {
|
||||||
_showItemCustomization(item);
|
_showItemCustomization(item);
|
||||||
|
|
@ -217,6 +243,83 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
_addToCart(item, {});
|
_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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,13 +405,23 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print("DEBUG: About to add root item ${item.itemId} to cart ${cart.orderId}");
|
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(
|
cart = await Api.setLineItem(
|
||||||
orderId: cart.orderId,
|
orderId: cart.orderId,
|
||||||
parentOrderLineItemId: 0,
|
parentOrderLineItemId: 0,
|
||||||
itemId: item.itemId,
|
itemId: item.itemId,
|
||||||
isSelected: true,
|
isSelected: true,
|
||||||
quantity: 1,
|
quantity: newQuantity,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
|
|
@ -337,13 +450,18 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
|
|
||||||
// Refresh cart to get final state
|
// Refresh cart to get final state
|
||||||
cart = await Api.getCart(orderId: cart.orderId);
|
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);
|
appState.updateCartItemCount(cart.itemCount);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final message = selectedModifierIds.isEmpty
|
final message = selectedModifierIds.isEmpty
|
||||||
? "Added ${item.name} to cart"
|
? "Added ${item.name} to cart (${cart.itemCount} item${cart.itemCount == 1 ? '' : 's'})"
|
||||||
: "Added ${item.name} with ${selectedModifierIds.length} customizations";
|
: "Added ${item.name} with ${selectedModifierIds.length} customizations (${cart.itemCount} item${cart.itemCount == 1 ? '' : 's'})";
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(message)),
|
SnackBar(content: Text(message)),
|
||||||
|
|
@ -374,6 +492,13 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
for (final child in children) {
|
for (final child in children) {
|
||||||
final isSelected = selectedItemIds.contains(child.itemId);
|
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
|
// ignore: avoid_print
|
||||||
print("DEBUG: Processing child ItemID=${child.itemId} (${child.name}), isSelected=$isSelected");
|
print("DEBUG: Processing child ItemID=${child.itemId} (${child.name}), isSelected=$isSelected");
|
||||||
|
|
||||||
|
|
@ -382,14 +507,12 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
orderId: orderId,
|
orderId: orderId,
|
||||||
parentOrderLineItemId: parentOrderLineItemId,
|
parentOrderLineItemId: parentOrderLineItemId,
|
||||||
itemId: child.itemId,
|
itemId: child.itemId,
|
||||||
isSelected: isSelected,
|
isSelected: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print("DEBUG: setLineItem response: cart has ${cart.lineItems.length} line items");
|
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
|
// Find the OrderLineItemID of this modifier we just added
|
||||||
final childLineItem = cart.lineItems.lastWhere(
|
final childLineItem = cart.lineItems.lastWhere(
|
||||||
(li) => li.itemId == child.itemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted,
|
(li) => li.itemId == child.itemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted,
|
||||||
|
|
@ -399,6 +522,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print("DEBUG: Child modifier OrderLineItemID=${childLineItem.orderLineItemId}");
|
print("DEBUG: Child modifier OrderLineItemID=${childLineItem.orderLineItemId}");
|
||||||
|
|
||||||
|
// Recursively add grandchildren
|
||||||
await _addModifiersRecursively(
|
await _addModifiersRecursively(
|
||||||
orderId,
|
orderId,
|
||||||
childLineItem.orderLineItemId,
|
childLineItem.orderLineItemId,
|
||||||
|
|
@ -408,7 +532,6 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Recursive item customization sheet with full rule support
|
/// Recursive item customization sheet with full rule support
|
||||||
class _ItemCustomizationSheet extends StatefulWidget {
|
class _ItemCustomizationSheet extends StatefulWidget {
|
||||||
|
|
@ -518,34 +641,62 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> {
|
||||||
children: [
|
children: [
|
||||||
// Header
|
// Header
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
boxShadow: [
|
border: Border(
|
||||||
BoxShadow(
|
bottom: BorderSide(
|
||||||
color: Colors.black.withAlpha(25),
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
blurRadius: 4,
|
width: 1,
|
||||||
offset: const Offset(0, 2),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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(
|
Text(
|
||||||
widget.item.name,
|
widget.item.name,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.item.description.isNotEmpty) ...[
|
if (widget.item.description.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(widget.item.description),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
"Base price: \$${widget.item.price.toStringAsFixed(2)}",
|
widget.item.description,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -111,15 +111,77 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.separated(
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final r = items[i];
|
final r = items[i];
|
||||||
return ListTile(
|
return Container(
|
||||||
title: Text(r.name),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
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),
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import "package:provider/provider.dart";
|
||||||
|
|
||||||
import "../app/app_router.dart";
|
import "../app/app_router.dart";
|
||||||
import "../app/app_state.dart";
|
import "../app/app_state.dart";
|
||||||
|
import "../services/api.dart";
|
||||||
|
import "../services/auth_storage.dart";
|
||||||
|
|
||||||
class SplashScreen extends StatefulWidget {
|
class SplashScreen extends StatefulWidget {
|
||||||
const SplashScreen({super.key});
|
const SplashScreen({super.key});
|
||||||
|
|
@ -19,7 +21,18 @@ class _SplashScreenState extends State<SplashScreen> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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>();
|
||||||
|
appState.setUserId(credentials.userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// Always go to beacon scan first - allows browsing without login
|
// Always go to beacon scan first - allows browsing without login
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import "../models/cart.dart";
|
||||||
import "../models/menu_item.dart";
|
import "../models/menu_item.dart";
|
||||||
import "../models/restaurant.dart";
|
import "../models/restaurant.dart";
|
||||||
import "../models/service_point.dart";
|
import "../models/service_point.dart";
|
||||||
|
import "auth_storage.dart";
|
||||||
|
|
||||||
class ApiRawResponse {
|
class ApiRawResponse {
|
||||||
final int statusCode;
|
final int statusCode;
|
||||||
|
|
@ -197,9 +198,10 @@ class Api {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void logout() {
|
static Future<void> logout() async {
|
||||||
setAuthToken(null);
|
setAuthToken(null);
|
||||||
clearCookies();
|
clearCookies();
|
||||||
|
await AuthStorage.clearAuth();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------
|
// -------------------------
|
||||||
|
|
|
||||||
45
lib/services/auth_storage.dart
Normal file
45
lib/services/auth_storage.dart
Normal file
|
|
@ -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<void> 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<AuthCredentials?> 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<void> 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue