From c4792189dde6511ec5974668a8cdb22bb3d72208 Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Fri, 23 Jan 2026 19:51:54 -0800 Subject: [PATCH] App Store Version 2: Beacon scanning, preload caching, business selector Features: - Beacon scanner service for detecting nearby beacons - Beacon cache for offline-first beacon resolution - Preload cache for instant menu display - Business selector screen for multi-location support - Rescan button widget for quick beacon refresh - Sign-in dialog for guest checkout flow - Task type model for server tasks Improvements: - Enhanced menu browsing with category filtering - Improved cart view with better modifier display - Order history with detailed order tracking - Chat screen improvements - Better error handling in API service Fixes: - CashApp payment return crash fix - Modifier nesting issues resolved - Auto-expand modifier groups Co-Authored-By: Claude Opus 4.5 --- lib/app/app_router.dart | 3 + lib/app/app_state.dart | 21 + lib/main.dart | 6 +- lib/models/menu_item.dart | 26 +- lib/models/restaurant.dart | 11 + lib/models/task_type.dart | 239 ++++++ lib/screens/about_screen.dart | 4 +- lib/screens/account_screen.dart | 7 + lib/screens/business_selector_screen.dart | 435 ++++++++++ lib/screens/cart_view_screen.dart | 8 +- lib/screens/chat_screen.dart | 184 +++- lib/screens/login_screen.dart | 19 +- lib/screens/menu_browse_screen.dart | 787 ++++++++++-------- lib/screens/order_detail_screen.dart | 4 + lib/screens/order_history_screen.dart | 4 + lib/screens/restaurant_select_screen.dart | 361 ++++---- lib/screens/splash_screen.dart | 575 ++++--------- lib/services/api.dart | 232 +++++- lib/services/beacon_cache.dart | 47 ++ lib/services/beacon_scanner_service.dart | 204 +++++ lib/services/preload_cache.dart | 114 +++ lib/services/stripe_service.dart | 10 +- lib/widgets/rescan_button.dart | 239 ++++++ lib/widgets/sign_in_dialog.dart | 404 +++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 64 ++ pubspec.yaml | 1 + 27 files changed, 3004 insertions(+), 1007 deletions(-) create mode 100644 lib/models/task_type.dart create mode 100644 lib/screens/business_selector_screen.dart create mode 100644 lib/services/beacon_cache.dart create mode 100644 lib/services/beacon_scanner_service.dart create mode 100644 lib/services/preload_cache.dart create mode 100644 lib/widgets/rescan_button.dart create mode 100644 lib/widgets/sign_in_dialog.dart diff --git a/lib/app/app_router.dart b/lib/app/app_router.dart index e6c729e..ad4a307 100644 --- a/lib/app/app_router.dart +++ b/lib/app/app_router.dart @@ -5,6 +5,7 @@ import "../screens/about_screen.dart"; import "../screens/address_edit_screen.dart"; import "../screens/address_list_screen.dart"; import "../screens/beacon_scan_screen.dart"; +import "../screens/business_selector_screen.dart"; import "../screens/signup_screen.dart"; import "../screens/cart_view_screen.dart"; import "../screens/group_order_invite_screen.dart"; @@ -21,6 +22,7 @@ class AppRoutes { static const String splash = "/"; static const String login = "/login"; static const String beaconScan = "/beacon-scan"; + static const String businessSelector = "/business-selector"; static const String orderTypeSelect = "/order-type"; static const String groupOrderInvite = "/group-invite"; static const String restaurantSelect = "/restaurants"; @@ -38,6 +40,7 @@ class AppRoutes { splash: (_) => const SplashScreen(), login: (_) => const LoginScreen(), beaconScan: (_) => const BeaconScanScreen(), + businessSelector: (_) => const BusinessSelectorScreen(), orderTypeSelect: (_) => const OrderTypeSelectScreen(), groupOrderInvite: (_) => const GroupOrderInviteScreen(), restaurantSelect: (_) => const RestaurantSelectScreen(), diff --git a/lib/app/app_state.dart b/lib/app/app_state.dart index f3278ca..ad51874 100644 --- a/lib/app/app_state.dart +++ b/lib/app/app_state.dart @@ -12,6 +12,10 @@ class AppState extends ChangeNotifier { int? _selectedServicePointId; String? _selectedServicePointName; + // Parent business info (for back navigation when selected from business selector) + int? _parentBusinessId; + String? _parentBusinessName; + int? _userId; OrderType? _orderType; @@ -25,11 +29,17 @@ class AppState extends ChangeNotifier { List _groupOrderInvites = []; + String? _brandColor; + int? get selectedBusinessId => _selectedBusinessId; String? get selectedBusinessName => _selectedBusinessName; int? get selectedServicePointId => _selectedServicePointId; String? get selectedServicePointName => _selectedServicePointName; + int? get parentBusinessId => _parentBusinessId; + String? get parentBusinessName => _parentBusinessName; + bool get hasParentBusiness => _parentBusinessId != null; + int? get userId => _userId; bool get isLoggedIn => _userId != null && _userId! > 0; @@ -49,6 +59,8 @@ class AppState extends ChangeNotifier { List get groupOrderInvites => _groupOrderInvites; bool get isGroupOrder => _groupOrderInvites.isNotEmpty; + String? get brandColor => _brandColor; + bool get hasLocationSelection => _selectedBusinessId != null && _selectedServicePointId != null; @@ -78,11 +90,15 @@ class AppState extends ChangeNotifier { int servicePointId, { String? businessName, String? servicePointName, + int? parentBusinessId, + String? parentBusinessName, }) { _selectedBusinessId = businessId; _selectedBusinessName = businessName; _selectedServicePointId = servicePointId; _selectedServicePointName = servicePointName; + _parentBusinessId = parentBusinessId; + _parentBusinessName = parentBusinessName; _cartOrderId = null; _cartOrderUuid = null; @@ -152,6 +168,11 @@ class AppState extends ChangeNotifier { notifyListeners(); } + void setBrandColor(String? color) { + _brandColor = color; + notifyListeners(); + } + void clearAll() { _selectedBusinessId = null; _selectedServicePointId = null; diff --git a/lib/main.dart b/lib/main.dart index 99c0382..81c4361 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,9 +12,9 @@ final GlobalKey rootScaffoldMessengerKey = void main() { WidgetsFlutterBinding.ensureInitialized(); - // Initialize Stripe with test publishable key - // This will be updated dynamically when processing payments if needed - Stripe.publishableKey = 'pk_test_sPBNzSyJ9HcEPJGC7dSo8NqN'; + // Initialize Stripe with live publishable key (must match backend mode) + // Backend is in live mode - see api/config/stripe.cfm + Stripe.publishableKey = 'pk_live_Wqj4yGmtTghVJu7oufnWmU5H'; runApp(const PayfritApp()); } diff --git a/lib/models/menu_item.dart b/lib/models/menu_item.dart index e277f94..b367773 100644 --- a/lib/models/menu_item.dart +++ b/lib/models/menu_item.dart @@ -31,19 +31,19 @@ class MenuItem { factory MenuItem.fromJson(Map json) { return MenuItem( - itemId: (json["ItemID"] as num).toInt(), - categoryId: (json["ItemCategoryID"] as num).toInt(), - categoryName: (json["ItemCategoryName"] as String?) ?? "", - name: (json["ItemName"] as String?) ?? "", - description: (json["ItemDescription"] as String?) ?? "", - parentItemId: (json["ItemParentItemID"] as num?)?.toInt() ?? 0, - price: (json["ItemPrice"] as num?)?.toDouble() ?? 0.0, - isActive: _parseBool(json["ItemIsActive"]), - isCheckedByDefault: _parseBool(json["ItemIsCheckedByDefault"]), - requiresChildSelection: _parseBool(json["ItemRequiresChildSelection"]), - maxNumSelectionReq: (json["ItemMaxNumSelectionReq"] as num?)?.toInt() ?? 0, - isCollapsible: _parseBool(json["ItemIsCollapsible"]), - sortOrder: (json["ItemSortOrder"] as num?)?.toInt() ?? 0, + itemId: (json["ItemID"] ?? json["itemid"] as num?)?.toInt() ?? 0, + categoryId: (json["ItemCategoryID"] ?? json["itemcategoryid"] as num?)?.toInt() ?? 0, + categoryName: (json["ItemCategoryName"] ?? json["itemcategoryname"] as String?) ?? "", + name: (json["ItemName"] ?? json["itemname"] as String?) ?? "", + description: (json["ItemDescription"] ?? json["itemdescription"] as String?) ?? "", + parentItemId: (json["ItemParentItemID"] ?? json["itemparentitemid"] as num?)?.toInt() ?? 0, + price: (json["ItemPrice"] ?? json["itemprice"] as num?)?.toDouble() ?? 0.0, + isActive: _parseBool(json["ItemIsActive"] ?? json["itemisactive"]), + isCheckedByDefault: _parseBool(json["ItemIsCheckedByDefault"] ?? json["itemischeckedbydefault"]), + requiresChildSelection: _parseBool(json["ItemRequiresChildSelection"] ?? json["itemrequireschildselection"]), + maxNumSelectionReq: (json["ItemMaxNumSelectionReq"] ?? json["itemmaxnumselectionreq"] as num?)?.toInt() ?? 0, + isCollapsible: _parseBool(json["ItemIsCollapsible"] ?? json["itemiscollapsible"]), + sortOrder: (json["ItemSortOrder"] ?? json["itemsortorder"] as num?)?.toInt() ?? 0, ); } diff --git a/lib/models/restaurant.dart b/lib/models/restaurant.dart index cc534a2..e9a141a 100644 --- a/lib/models/restaurant.dart +++ b/lib/models/restaurant.dart @@ -1,16 +1,27 @@ class Restaurant { final int businessId; final String name; + final String city; + final String address; + final double? distanceMiles; const Restaurant({ required this.businessId, required this.name, + this.city = "", + this.address = "", + this.distanceMiles, }); factory Restaurant.fromJson(Map json) { return Restaurant( businessId: (json["BusinessID"] as num).toInt(), name: (json["BusinessName"] as String?) ?? "Unnamed", + city: (json["AddressCity"] as String?) ?? "", + address: (json["AddressLine1"] as String?) ?? "", + distanceMiles: json["DistanceMiles"] != null + ? (json["DistanceMiles"] as num).toDouble() + : null, ); } } diff --git a/lib/models/task_type.dart b/lib/models/task_type.dart new file mode 100644 index 0000000..dcf3731 --- /dev/null +++ b/lib/models/task_type.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; + +/// Represents a requestable task type (for bell icon menu) +class TaskType { + final int taskTypeId; + final String taskTypeName; + final String taskTypeDescription; + final String taskTypeIcon; + final String taskTypeColor; + + const TaskType({ + required this.taskTypeId, + required this.taskTypeName, + required this.taskTypeDescription, + required this.taskTypeIcon, + required this.taskTypeColor, + }); + + factory TaskType.fromJson(Map json) { + return TaskType( + taskTypeId: (json['tasktypeid'] as num?)?.toInt() ?? (json['TaskTypeID'] as num?)?.toInt() ?? 0, + taskTypeName: json['tasktypename'] as String? ?? json['TaskTypeName'] as String? ?? '', + taskTypeDescription: json['tasktypedescription'] as String? ?? json['TaskTypeDescription'] as String? ?? '', + taskTypeIcon: json['tasktypeicon'] as String? ?? json['TaskTypeIcon'] as String? ?? 'notifications', + taskTypeColor: json['tasktypecolor'] as String? ?? json['TaskTypeColor'] as String? ?? '#9C27B0', + ); + } + + /// Get the Flutter icon for this task type + IconData get icon => _iconMap[taskTypeIcon] ?? Icons.notifications; + + /// Get the color for this task type (from the configured color) + Color get color { + try { + final hex = taskTypeColor.replaceFirst('#', ''); + return Color(int.parse('FF$hex', radix: 16)); + } catch (_) { + return Colors.purple; + } + } + + /// Get the icon color for this task type (uses the configured color) + Color get iconColor => color; + + /// Available icons for task types + static const Map _iconMap = { + // Service & Staff + 'room_service': Icons.room_service, + 'support_agent': Icons.support_agent, + 'person': Icons.person, + 'groups': Icons.groups, + + // Payment & Money + 'attach_money': Icons.attach_money, + 'payments': Icons.payments, + 'receipt': Icons.receipt, + 'credit_card': Icons.credit_card, + + // Communication + 'chat': Icons.chat, + 'message': Icons.message, + 'call': Icons.call, + 'notifications': Icons.notifications, + + // Food & Drink + 'restaurant': Icons.restaurant, + 'local_bar': Icons.local_bar, + 'coffee': Icons.coffee, + 'icecream': Icons.icecream, + 'cake': Icons.cake, + 'local_pizza': Icons.local_pizza, + 'lunch_dining': Icons.lunch_dining, + 'fastfood': Icons.fastfood, + 'ramen_dining': Icons.ramen_dining, + 'bakery_dining': Icons.bakery_dining, + + // Drinks & Refills + 'water_drop': Icons.water_drop, + 'local_drink': Icons.local_drink, + 'wine_bar': Icons.wine_bar, + 'sports_bar': Icons.sports_bar, + 'liquor': Icons.liquor, + + // Hookah & Fire + 'local_fire_department': Icons.local_fire_department, + 'whatshot': Icons.whatshot, + 'smoke_free': Icons.smoke_free, + + // Cleaning & Maintenance + 'cleaning_services': Icons.cleaning_services, + 'delete_sweep': Icons.delete_sweep, + 'auto_fix_high': Icons.auto_fix_high, + + // Supplies & Items + 'inventory': Icons.inventory, + 'shopping_basket': Icons.shopping_basket, + 'add_box': Icons.add_box, + 'note_add': Icons.note_add, + + // Entertainment + 'music_note': Icons.music_note, + 'tv': Icons.tv, + 'sports_esports': Icons.sports_esports, + 'celebration': Icons.celebration, + + // Comfort & Amenities + 'ac_unit': Icons.ac_unit, + 'wb_sunny': Icons.wb_sunny, + 'light_mode': Icons.light_mode, + 'volume_up': Icons.volume_up, + 'volume_down': Icons.volume_down, + + // Health & Safety + 'medical_services': Icons.medical_services, + 'health_and_safety': Icons.health_and_safety, + 'child_care': Icons.child_care, + 'accessible': Icons.accessible, + + // Location & Navigation + 'directions': Icons.directions, + 'meeting_room': Icons.meeting_room, + 'wc': Icons.wc, + 'local_parking': Icons.local_parking, + + // General + 'help': Icons.help, + 'info': Icons.info, + 'star': Icons.star, + 'favorite': Icons.favorite, + 'thumb_up': Icons.thumb_up, + 'check_circle': Icons.check_circle, + 'warning': Icons.warning, + 'error': Icons.error, + 'schedule': Icons.schedule, + 'event': Icons.event, + }; + + /// Icon colors + static const Map _iconColorMap = { + // Service & Staff - Orange tones + 'room_service': Colors.orange, + 'support_agent': Colors.orange, + 'person': Colors.orange, + 'groups': Colors.orange, + + // Payment & Money - Green tones + 'attach_money': Colors.green, + 'payments': Colors.green, + 'receipt': Colors.green, + 'credit_card': Colors.green, + + // Communication - Blue tones + 'chat': Colors.blue, + 'message': Colors.blue, + 'call': Colors.blue, + 'notifications': Colors.purple, + + // Food & Drink - Brown/Amber tones + 'restaurant': Colors.brown, + 'local_bar': Colors.purple, + 'coffee': Colors.brown, + 'icecream': Colors.pink, + 'cake': Colors.pink, + 'local_pizza': Colors.orange, + 'lunch_dining': Colors.brown, + 'fastfood': Colors.amber, + 'ramen_dining': Colors.orange, + 'bakery_dining': Colors.brown, + + // Drinks & Refills - Blue tones + 'water_drop': Colors.lightBlue, + 'local_drink': Colors.lightBlue, + 'wine_bar': Colors.red, + 'sports_bar': Colors.amber, + 'liquor': Colors.amber, + + // Hookah & Fire - Red/Orange tones + 'local_fire_department': Colors.red, + 'whatshot': Colors.deepOrange, + 'smoke_free': Colors.grey, + + // Cleaning & Maintenance - Teal tones + 'cleaning_services': Colors.teal, + 'delete_sweep': Colors.teal, + 'auto_fix_high': Colors.teal, + + // Supplies & Items - Indigo tones + 'inventory': Colors.indigo, + 'shopping_basket': Colors.indigo, + 'add_box': Colors.indigo, + 'note_add': Colors.indigo, + + // Entertainment - Purple tones + 'music_note': Colors.purple, + 'tv': Colors.purple, + 'sports_esports': Colors.purple, + 'celebration': Colors.pink, + + // Comfort & Amenities - Cyan tones + 'ac_unit': Colors.cyan, + 'wb_sunny': Colors.amber, + 'light_mode': Colors.amber, + 'volume_up': Colors.blueGrey, + 'volume_down': Colors.blueGrey, + + // Health & Safety - Red tones + 'medical_services': Colors.red, + 'health_and_safety': Colors.green, + 'child_care': Colors.pink, + 'accessible': Colors.blue, + + // Location & Navigation - Grey/Blue tones + 'directions': Colors.blue, + 'meeting_room': Colors.blueGrey, + 'wc': Colors.blueGrey, + 'local_parking': Colors.blue, + + // General + 'help': Colors.grey, + 'info': Colors.blue, + 'star': Colors.amber, + 'favorite': Colors.red, + 'thumb_up': Colors.green, + 'check_circle': Colors.green, + 'warning': Colors.orange, + 'error': Colors.red, + 'schedule': Colors.blueGrey, + 'event': Colors.blue, + }; + + /// Get list of available icon names for UI + static List get availableIcons => _iconMap.keys.toList(); + + /// Get icon by name + static IconData getIconByName(String name) => _iconMap[name] ?? Icons.notifications; + + /// Get color by icon name + static Color getColorByName(String name) => _iconColorMap[name] ?? Colors.purple; +} diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index 3df53a1..5bc7d2d 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -117,8 +117,8 @@ class _AboutScreenState extends State { _buildSectionHeader(context, 'Contact'), const SizedBox(height: 12), ListTile( - leading: const Icon(Icons.email_outlined), - title: const Text('support@payfrit.com'), + leading: const Icon(Icons.help_outline), + title: const Text('help.payfrit.com'), contentPadding: EdgeInsets.zero, ), ListTile( diff --git a/lib/screens/account_screen.dart b/lib/screens/account_screen.dart index b52ac11..01a0e1f 100644 --- a/lib/screens/account_screen.dart +++ b/lib/screens/account_screen.dart @@ -6,6 +6,7 @@ import '../app/app_state.dart'; import '../app/app_router.dart'; import '../services/api.dart'; import '../services/auth_storage.dart'; +import '../widgets/rescan_button.dart'; class AccountScreen extends StatefulWidget { const AccountScreen({super.key}); @@ -326,6 +327,9 @@ class _AccountScreenState extends State { return Scaffold( appBar: AppBar( title: const Text('Account'), + actions: const [ + RescanButton(), + ], ), body: Center( child: Padding( @@ -373,6 +377,9 @@ class _AccountScreenState extends State { return Scaffold( appBar: AppBar( title: const Text('Account'), + actions: const [ + RescanButton(), + ], ), body: ListView( children: [ diff --git a/lib/screens/business_selector_screen.dart b/lib/screens/business_selector_screen.dart new file mode 100644 index 0000000..7c2d0bb --- /dev/null +++ b/lib/screens/business_selector_screen.dart @@ -0,0 +1,435 @@ +import "package:flutter/material.dart"; +import "package:provider/provider.dart"; + +import "../app/app_router.dart"; +import "../app/app_state.dart"; +import "../services/api.dart"; +import "../widgets/rescan_button.dart"; + +class BusinessSelectorScreen extends StatefulWidget { + const BusinessSelectorScreen({super.key}); + + @override + State createState() => _BusinessSelectorScreenState(); +} + +class _BusinessSelectorScreenState extends State { + static const String _imageBaseUrl = "https://biz.payfrit.com/uploads"; + + String? _parentName; + int? _parentBusinessId; + int? _servicePointId; + String? _servicePointName; + List<_BusinessItem>? _businesses; + bool _loading = false; + bool _initialized = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialized) { + _initialized = true; + _initializeData(); + } + } + + Future _initializeData() async { + final args = ModalRoute.of(context)?.settings.arguments as Map?; + + final BeaconBusinessMapping? mapping = args?["mapping"] as BeaconBusinessMapping?; + final List? children = args?["children"] as List?; + + if (mapping != null && mapping.businesses.isNotEmpty) { + // From beacon mapping + setState(() { + _parentName = mapping.parent?.businessName ?? mapping.businessName; + _parentBusinessId = mapping.parent?.businessId ?? mapping.businessId; + _servicePointId = mapping.servicePointId; + _servicePointName = mapping.servicePointName; + _businesses = mapping.businesses.map((b) => _BusinessItem( + businessId: b.businessId, + businessName: b.businessName, + servicePointId: b.servicePointId, + servicePointName: b.servicePointName, + )).toList(); + }); + } else if (children != null && children.isNotEmpty) { + // From menu_browse_screen with children list + setState(() { + _parentName = (args?["parentBusinessName"] as String?) ?? "this location"; + _parentBusinessId = args?["parentBusinessId"] as int?; + _servicePointId = args?["servicePointId"] as int?; + _servicePointName = args?["servicePointName"] as String?; + _businesses = children.map((c) => _BusinessItem( + businessId: c.businessId, + businessName: c.businessName, + servicePointId: _servicePointId ?? 0, + servicePointName: _servicePointName ?? "", + )).toList(); + }); + } else if (args?["parentBusinessId"] != null) { + // From menu back button - need to fetch children + _parentBusinessId = args?["parentBusinessId"] as int?; + _parentName = args?["parentBusinessName"] as String? ?? "this location"; + _servicePointId = args?["servicePointId"] as int?; + _servicePointName = args?["servicePointName"] as String?; + + setState(() => _loading = true); + try { + final fetchedChildren = await Api.getChildBusinesses(businessId: _parentBusinessId!); + if (mounted) { + setState(() { + _businesses = fetchedChildren.map((c) => _BusinessItem( + businessId: c.businessId, + businessName: c.businessName, + servicePointId: _servicePointId ?? 0, + servicePointName: _servicePointName ?? "", + )).toList(); + _loading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() => _loading = false); + } + } + } + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return Scaffold( + backgroundColor: Colors.black, + body: const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ); + } + + if (_businesses == null || _businesses!.isEmpty) { + return Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "No businesses available", + style: TextStyle(color: Colors.white, fontSize: 18), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect), + child: const Text("Browse Restaurants"), + ), + ], + ), + ), + ); + } + + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Column( + children: [ + // Large header banner with back button overlay + _buildHeaderBanner(context, _parentBusinessId, _parentName ?? ""), + // Message + const Padding( + padding: EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + "Please select one of the businesses below:", + style: TextStyle( + color: Colors.white70, + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 16), + // Business list with header images + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: _businesses!.length, + itemBuilder: (context, index) { + final business = _businesses![index]; + return _BusinessCardWithHeader( + businessId: business.businessId, + businessName: business.businessName, + imageBaseUrl: _imageBaseUrl, + onTap: () => _selectBusiness(context, business, _parentBusinessId, _parentName), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeaderBanner(BuildContext context, int? parentBusinessId, String parentName) { + const imageBaseUrl = _imageBaseUrl; + return SizedBox( + height: 200, + width: double.infinity, + child: Stack( + children: [ + // Background image + if (parentBusinessId != null) + Positioned.fill( + child: Image.network( + "$_imageBaseUrl/headers/$parentBusinessId.png", + fit: BoxFit.fitWidth, + errorBuilder: (context, error, stackTrace) { + return Image.network( + "$_imageBaseUrl/headers/$parentBusinessId.jpg", + fit: BoxFit.fitWidth, + errorBuilder: (context, error, stackTrace) { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.grey.shade800, + Colors.grey.shade900, + ], + ), + ), + ); + }, + ); + }, + ), + ), + // Subtle gradient overlay at bottom for readability of text below + Positioned( + bottom: 0, + left: 0, + right: 0, + height: 40, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withAlpha(150), + ], + ), + ), + ), + ), + // Back button + Positioned( + top: 8, + left: 8, + child: IconButton( + onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect), + icon: const Icon(Icons.arrow_back), + color: Colors.white, + style: IconButton.styleFrom( + backgroundColor: Colors.black54, + ), + ), + ), + // Rescan button + Positioned( + top: 8, + right: 8, + child: Container( + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(20), + ), + child: const RescanButton(iconColor: Colors.white), + ), + ), + ], + ), + ); + } + + void _selectBusiness(BuildContext context, _BusinessItem business, int? parentBusinessId, String? parentBusinessName) { + final appState = context.read(); + + // Clear any existing cart + appState.clearCart(); + + // Set the selected business and service point (with parent info for back navigation) + appState.setBusinessAndServicePoint( + business.businessId, + business.servicePointId, + businessName: business.businessName, + servicePointName: business.servicePointName, + parentBusinessId: parentBusinessId, + parentBusinessName: parentBusinessName, + ); + appState.setOrderType(OrderType.dineIn); + Api.setBusinessId(business.businessId); + + // Navigate to menu + Navigator.of(context).pushReplacementNamed( + AppRoutes.menuBrowse, + arguments: { + "businessId": business.businessId, + "servicePointId": business.servicePointId, + }, + ); + } +} + +/// Internal business item for unified handling +class _BusinessItem { + final int businessId; + final String businessName; + final int servicePointId; + final String servicePointName; + + const _BusinessItem({ + required this.businessId, + required this.businessName, + required this.servicePointId, + required this.servicePointName, + }); +} + +/// Business card with header image background +class _BusinessCardWithHeader extends StatelessWidget { + final int businessId; + final String businessName; + final String imageBaseUrl; + final VoidCallback onTap; + + const _BusinessCardWithHeader({ + required this.businessId, + required this.businessName, + required this.imageBaseUrl, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + height: 120, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(100), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + children: [ + // Header image background + Positioned.fill( + child: Image.network( + "$imageBaseUrl/headers/$businessId.png", + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Image.network( + "$imageBaseUrl/headers/$businessId.jpg", + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + // Fallback to logo centered + return Container( + color: Colors.grey.shade800, + child: Center( + child: Image.network( + "$imageBaseUrl/logos/$businessId.png", + width: 60, + height: 60, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Image.network( + "$imageBaseUrl/logos/$businessId.jpg", + width: 60, + height: 60, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Text( + businessName.isNotEmpty ? businessName[0].toUpperCase() : "?", + style: const TextStyle( + color: Colors.white54, + fontSize: 36, + fontWeight: FontWeight.bold, + ), + ); + }, + ); + }, + ), + ), + ); + }, + ); + }, + ), + ), + // Gradient overlay + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withAlpha(180), + ], + ), + ), + ), + ), + // Business name and arrow at bottom + Positioned( + bottom: 12, + left: 16, + right: 16, + child: Row( + children: [ + Expanded( + child: Text( + businessName, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + offset: Offset(0, 1), + blurRadius: 3, + color: Colors.black54, + ), + ], + ), + ), + ), + const Icon( + Icons.arrow_forward_ios, + color: Colors.white70, + size: 20, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/cart_view_screen.dart b/lib/screens/cart_view_screen.dart index d9e579e..519db2b 100644 --- a/lib/screens/cart_view_screen.dart +++ b/lib/screens/cart_view_screen.dart @@ -8,6 +8,7 @@ import '../models/menu_item.dart'; import '../services/api.dart'; import '../services/order_polling_service.dart'; import '../services/stripe_service.dart'; +import '../widgets/rescan_button.dart'; /// Helper class to store modifier breadcrumb paths class ModifierPath { @@ -160,8 +161,8 @@ class _CartViewScreenState extends State { // Use cart's businessId if appState doesn't have one (e.g., delivery/takeaway flow) final businessId = appState.selectedBusinessId ?? cart.businessId; if (businessId > 0) { - final menuItems = await Api.listMenuItems(businessId: businessId); - _menuItemsById = {for (var item in menuItems) item.itemId: item}; + final result = await Api.listMenuItems(businessId: businessId); + _menuItemsById = {for (var item in result.items) item.itemId: item}; } setState(() { @@ -540,6 +541,9 @@ class _CartViewScreenState extends State { title: const Text("Cart"), backgroundColor: Colors.black, foregroundColor: Colors.white, + actions: const [ + RescanButton(iconColor: Colors.white), + ], ), body: _isLoading ? const Center(child: CircularProgressIndicator()) diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 600e230..bff57a8 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -8,15 +8,25 @@ import '../services/chat_service.dart'; import '../services/auth_storage.dart'; class ChatScreen extends StatefulWidget { - final int taskId; + final int? taskId; // null if task needs to be created final String userType; // 'customer' or 'worker' final String? otherPartyName; + // Required for creating task when taskId is null + final int? businessId; + final int? servicePointId; + final int? orderId; + final int? userId; + const ChatScreen({ super.key, - required this.taskId, + this.taskId, required this.userType, this.otherPartyName, + this.businessId, + this.servicePointId, + this.orderId, + this.userId, }); @override @@ -28,6 +38,7 @@ class _ChatScreenState extends State { final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); final List _messages = []; + final List _pendingMessages = []; // Messages queued before task created bool _isLoading = true; bool _isConnecting = false; @@ -36,6 +47,8 @@ class _ChatScreenState extends State { String? _otherUserName; String? _error; bool _chatEnded = false; + bool _isCreatingTask = false; // True while creating task in background + int? _taskId; // Actual task ID (may be null initially) StreamSubscription? _messageSubscription; StreamSubscription? _typingSubscription; @@ -47,15 +60,69 @@ class _ChatScreenState extends State { void initState() { super.initState(); _otherUserName = widget.otherPartyName; + _taskId = widget.taskId; _initializeChat(); } Future _initializeChat() async { // Ensure auth is loaded first before any API calls await _ensureAuth(); - // Then load messages and connect - await _loadMessages(); - _connectToChat(); + + // If no taskId provided, we need to create the task + if (_taskId == null) { + setState(() { + _isCreatingTask = true; + _isLoading = false; // Allow user to see chat UI immediately + }); + await _createTask(); + } else { + // Then load messages and connect + await _loadMessages(); + _connectToChat(); + } + } + + Future _createTask() async { + try { + final taskId = await Api.createChatTask( + businessId: widget.businessId!, + servicePointId: widget.servicePointId!, + orderId: widget.orderId, + userId: widget.userId, + ); + + if (!mounted) return; + + setState(() { + _taskId = taskId; + _isCreatingTask = false; + }); + + // Now load messages and connect + await _loadMessages(); + _connectToChat(); + + // Send any pending messages that were queued + _sendPendingMessages(); + } catch (e) { + if (mounted) { + setState(() { + _error = 'Failed to start chat: $e'; + _isCreatingTask = false; + }); + } + } + } + + Future _sendPendingMessages() async { + if (_pendingMessages.isEmpty || _taskId == null) return; + + final messages = List.from(_pendingMessages); + _pendingMessages.clear(); + + for (final text in messages) { + await _sendMessageText(text); + } } Future _ensureAuth() async { @@ -81,13 +148,15 @@ class _ChatScreenState extends State { } Future _loadMessages() async { + if (_taskId == null) return; + setState(() { _isLoading = true; _error = null; }); try { - final result = await Api.getChatMessages(taskId: widget.taskId); + final result = await Api.getChatMessages(taskId: _taskId!); if (mounted) { final wasClosed = result.chatClosed && !_chatEnded; setState(() { @@ -138,7 +207,7 @@ class _ChatScreenState extends State { // Mark as read if from the other party if (message.senderType != widget.userType) { Api.markChatMessagesRead( - taskId: widget.taskId, + taskId: _taskId!, readerType: widget.userType, ); } @@ -214,7 +283,7 @@ class _ChatScreenState extends State { }); final connected = await _chatService.connect( - taskId: widget.taskId, + taskId: _taskId!, userToken: token, userType: widget.userType, ); @@ -237,15 +306,17 @@ class _ChatScreenState extends State { } Future _pollNewMessages() async { + if (_taskId == null) return; + try { final lastMessageId = _messages.isNotEmpty ? _messages.last.messageId : 0; final result = await Api.getChatMessages( - taskId: widget.taskId, + taskId: _taskId!, afterMessageId: lastMessageId, ); if (mounted) { - // Check if chat has been closed by worker + // Check if chat has been closed (by worker or system auto-close) if (result.chatClosed && !_chatEnded) { setState(() { _chatEnded = true; @@ -253,7 +324,7 @@ class _ChatScreenState extends State { _pollTimer?.cancel(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: const Text('This chat has been closed by staff', style: TextStyle(color: Colors.black)), + content: const Text('This chat has ended', style: TextStyle(color: Colors.black)), backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), @@ -307,18 +378,43 @@ class _ChatScreenState extends State { final text = _messageController.text.trim(); if (text.isEmpty || _isSending || _chatEnded) return; - setState(() => _isSending = true); + _messageController.clear(); _chatService.setTyping(false); + // If task not created yet, queue the message + if (_taskId == null) { + setState(() { + _pendingMessages.add(text); + // Add optimistic message to UI + _messages.add(ChatMessage( + messageId: -(_pendingMessages.length), // Negative ID for pending + taskId: 0, + senderUserId: widget.userId ?? 0, + senderType: widget.userType, + senderName: 'Me', + text: text, + createdOn: DateTime.now(), + isRead: false, + )); + }); + _scrollToBottom(); + return; + } + + await _sendMessageText(text); + } + + Future _sendMessageText(String text) async { + if (_taskId == null) return; + + setState(() => _isSending = true); + try { bool sentViaWebSocket = false; if (_chatService.isConnected) { // Try to send via WebSocket sentViaWebSocket = _chatService.sendMessage(text); - if (sentViaWebSocket) { - _messageController.clear(); - } } if (!sentViaWebSocket) { @@ -331,12 +427,11 @@ class _ChatScreenState extends State { } await Api.sendChatMessage( - taskId: widget.taskId, + taskId: _taskId!, message: text, userId: userId, senderType: widget.userType, ); - _messageController.clear(); // Refresh messages since we used HTTP await _loadMessages(); @@ -344,12 +439,28 @@ class _ChatScreenState extends State { } catch (e) { if (mounted) { final message = e.toString().replaceAll('StateError: ', ''); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to send: $message'), - backgroundColor: Colors.red, - ), - ); + // Check if chat was closed - update state and show appropriate message + if (message.contains('chat has ended') || message.contains('chat_closed')) { + setState(() { + _chatEnded = true; + }); + _pollTimer?.cancel(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('This chat has ended', style: TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to send: $message'), + backgroundColor: Colors.red, + ), + ); + } } } finally { if (mounted) { @@ -462,6 +573,33 @@ class _ChatScreenState extends State { ), body: Column( children: [ + if (_isCreatingTask) + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + color: Colors.blue.shade50, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.blue.shade700, + ), + ), + const SizedBox(width: 12), + Text( + 'Finding available staff...', + style: TextStyle( + color: Colors.blue.shade700, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), if (_chatEnded) Container( width: double.infinity, diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 2d65a53..2e741bd 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -180,10 +180,23 @@ class _LoginScreenState extends State { ), actions: [ TextButton( - onPressed: () { + onPressed: () async { Navigator.of(context).pop(); - // Start fresh - go to restaurant select - Navigator.of(this.context).pushReplacementNamed(AppRoutes.restaurantSelect); + // Abandon the old order and stay at the same business with a clean cart + try { + await Api.abandonOrder(orderId: cart.orderId); + } catch (e) { + // Ignore - proceed anyway + } + final appState = this.context.read(); + appState.setBusinessAndServicePoint( + cart.businessId, + cart.servicePointId, + businessName: cart.businessName, + servicePointName: cart.servicePointName, + ); + appState.clearCart(); + Navigator.of(this.context).pushReplacementNamed(AppRoutes.menuBrowse); }, child: const Text("Start Fresh"), ), diff --git a/lib/screens/menu_browse_screen.dart b/lib/screens/menu_browse_screen.dart index dc9df87..8c41f9b 100644 --- a/lib/screens/menu_browse_screen.dart +++ b/lib/screens/menu_browse_screen.dart @@ -5,8 +5,12 @@ import "../app/app_router.dart"; import "../app/app_state.dart"; import "../models/cart.dart"; import "../models/menu_item.dart"; +import "../models/task_type.dart"; import "../services/api.dart"; import "../services/auth_storage.dart"; +import "../services/preload_cache.dart"; +import "../widgets/rescan_button.dart"; +import "../widgets/sign_in_dialog.dart"; import "chat_screen.dart"; class MenuBrowseScreen extends StatefulWidget { @@ -31,6 +35,12 @@ class _MenuBrowseScreenState extends State { // Track which category is currently expanded (null = none) int? _expandedCategoryId; + // Task types for bell icon - fetched on load + List _taskTypes = []; + + // Brand color for HUD gradients (decorative, from business settings) + Color _brandColor = const Color(0xFF1B4D3E); // Default forest green + int? _asIntNullable(dynamic v) { if (v == null) return null; if (v is int) return v; @@ -60,53 +70,138 @@ class _MenuBrowseScreenState extends State { final u = appState.userId; final args = ModalRoute.of(context)?.settings.arguments; + int? b; + int? sp; + if (args is Map) { - final b = _asIntNullable(args["BusinessID"]) ?? _asIntNullable(args["businessId"]); - final sp = _asIntNullable(args["ServicePointID"]) ?? _asIntNullable(args["servicePointId"]); + b = _asIntNullable(args["BusinessID"]) ?? _asIntNullable(args["businessId"]); + sp = _asIntNullable(args["ServicePointID"]) ?? _asIntNullable(args["servicePointId"]); + } - if (_businessId != b || _servicePointId != sp || _userId != u || _future == null) { - _businessId = b; - _servicePointId = sp; - _userId = u; + // Fall back to AppState if args don't have businessId + b ??= appState.selectedBusinessId; + sp ??= appState.selectedServicePointId; - if (_businessId != null && _businessId! > 0) { - _future = _loadMenu(); - } else { - _future = Future.value([]); - } + if (_businessId != b || _servicePointId != sp || _userId != u || _future == null) { + _businessId = b; + _servicePointId = sp; + _userId = u; + + if (_businessId != null && _businessId! > 0) { + _future = _loadMenu(); + } else { + _future = Future.value([]); } } } Future> _loadMenu() async { - final items = await Api.listMenuItems(businessId: _businessId!); + // Preload task types in background (for faster service bell response) + _preloadTaskTypes(); + + final result = await Api.listMenuItems(businessId: _businessId!); + + // If no menu items, check for child businesses + if (result.items.isEmpty) { + final children = await Api.getChildBusinesses(businessId: _businessId!); + if (children.isNotEmpty && mounted) { + // Navigate to business selector with the children + final appState = context.read(); + Navigator.of(context).pushReplacementNamed( + AppRoutes.businessSelector, + arguments: { + 'parentBusinessId': _businessId, + 'parentBusinessName': appState.selectedBusinessName, + 'servicePointId': _servicePointId, + 'servicePointName': appState.selectedServicePointName, + 'children': children, + }, + ); + return []; // Return empty, we're navigating away + } + } + + // Parse brand color if provided - use as default HUD color + if (result.brandColor != null && result.brandColor!.isNotEmpty) { + try { + final hex = result.brandColor!.replaceFirst('#', ''); + _brandColor = Color(int.parse('FF$hex', radix: 16)); + // Also store in AppState for other screens + if (mounted) { + context.read().setBrandColor(result.brandColor); + } + } catch (_) { + // Keep default color on parse error + } + } setState(() { - _allItems = items; + _allItems = result.items; _organizeItems(); }); - return items; + return result.items; } bool _isCallingServer = false; - /// Show bottom sheet with choice: Server Visit or Chat (dine-in) or just Chat (non-dine-in) + /// Preload task types in background for faster service bell response + Future _preloadTaskTypes() async { + if (_businessId == null) return; + try { + // Use PreloadCache for faster response (may be cached from previous visits) + final types = await PreloadCache.getTaskTypes(_businessId!); + if (mounted) { + setState(() => _taskTypes = types); + } + } catch (e) { + debugPrint('[MenuBrowse] Failed to preload task types: $e'); + } + } + + /// Show bottom sheet with dynamic task type options Future _handleCallServer(AppState appState) async { if (_businessId == null) return; // For non-dine-in without a service point, use 0 as placeholder final servicePointId = _servicePointId ?? 0; + final userId = appState.userId; - // Check for active chat first + // Use preloaded task types if available, otherwise fetch from cache int? activeTaskId; + List taskTypes = _taskTypes; + try { - activeTaskId = await Api.getActiveChat( - businessId: _businessId!, - servicePointId: servicePointId, - ); + debugPrint('[MenuBrowse] Checking for active chat: businessId=$_businessId, servicePointId=$servicePointId, userId=$userId'); + + // Only fetch task types if not preloaded, but always check for active chat + if (taskTypes.isEmpty) { + final results = await Future.wait([ + PreloadCache.getTaskTypes(_businessId!), + Api.getActiveChat(businessId: _businessId!, servicePointId: servicePointId, userId: userId) + .then((v) => v) + .catchError((e) { + debugPrint('[MenuBrowse] getActiveChat error: $e'); + return null; + }), + ]); + taskTypes = results[0] as List; + activeTaskId = results[1] as int?; + } else { + // Task types preloaded, just check for active chat + activeTaskId = await Api.getActiveChat( + businessId: _businessId!, + servicePointId: servicePointId, + userId: userId, + ).catchError((e) { + debugPrint('[MenuBrowse] getActiveChat error: $e'); + return null; + }); + } + debugPrint('[MenuBrowse] Got ${taskTypes.length} task types, activeTaskId=$activeTaskId'); } catch (e) { - // Continue without active chat + // Fall back to showing default options if API fails + debugPrint('[MenuBrowse] Failed to fetch task types: $e'); } if (!mounted) return; @@ -138,49 +233,8 @@ class _MenuBrowseScreenState extends State { style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 16), - // Only show "Request Server Visit" for dine-in orders - if (isDineIn && _servicePointId != null) ...[ - ListTile( - leading: const CircleAvatar( - backgroundColor: Colors.orange, - child: Icon(Icons.room_service, color: Colors.white), - ), - title: const Text('Request Server Visit'), - subtitle: const Text('Staff will come to your table'), - onTap: () { - Navigator.pop(context); - _sendServerRequest(appState); - }, - ), - const Divider(), - ], - // Show either "Rejoin Chat" OR "Chat with Staff" - never both - if (activeTaskId != null) - ListTile( - leading: const CircleAvatar( - backgroundColor: Colors.green, - child: Icon(Icons.chat_bubble, color: Colors.white), - ), - title: const Text('Rejoin Chat'), - subtitle: const Text('Continue your conversation'), - onTap: () { - Navigator.pop(context); - _rejoinChat(activeTaskId!); - }, - ) - else - ListTile( - leading: const CircleAvatar( - backgroundColor: Colors.blue, - child: Icon(Icons.chat, color: Colors.white), - ), - title: const Text('Chat with Staff'), - subtitle: const Text('Send a message to our team'), - onTap: () { - Navigator.pop(context); - _startChat(appState); - }, - ), + // Build task type options dynamically + ..._buildTaskTypeOptions(taskTypes, appState, isDineIn, activeTaskId), ], ), ), @@ -188,6 +242,145 @@ class _MenuBrowseScreenState extends State { ); } + /// Build list tiles for each task type + List _buildTaskTypeOptions( + List taskTypes, + AppState appState, + bool isDineIn, + int? activeTaskId, + ) { + debugPrint('[MenuBrowse] _buildTaskTypeOptions called with ${taskTypes.length} task types, activeTaskId=$activeTaskId'); + final widgets = []; + + for (final taskType in taskTypes) { + // Chat task type - identified by icon ('chat' or 'message') or name containing 'chat' + final iconLower = taskType.taskTypeIcon.toLowerCase(); + final isChat = iconLower == 'chat' || + iconLower == 'message' || + iconLower.contains('chat') || + iconLower.contains('message') || + taskType.taskTypeName.toLowerCase().contains('chat'); + debugPrint('[MenuBrowse] TaskType: id=${taskType.taskTypeId}, name="${taskType.taskTypeName}", icon="${taskType.taskTypeIcon}", isChat=$isChat'); + if (isChat) { + debugPrint('[MenuBrowse] Building chat option: activeTaskId=$activeTaskId'); + if (activeTaskId != null) { + debugPrint('[MenuBrowse] Showing REJOIN option'); + widgets.add(ListTile( + leading: const CircleAvatar( + backgroundColor: Colors.green, + child: Icon(Icons.chat_bubble, color: Colors.white), + ), + title: const Text('Rejoin Chat'), + subtitle: const Text('Continue your conversation'), + onTap: () { + debugPrint('[MenuBrowse] Rejoin tapped'); + Navigator.pop(context); + _rejoinChat(activeTaskId); + }, + )); + } else { + debugPrint('[MenuBrowse] Showing START option'); + widgets.add(ListTile( + leading: CircleAvatar( + backgroundColor: taskType.iconColor, + child: Icon(taskType.icon, color: Colors.white), + ), + title: Text(taskType.taskTypeName), + subtitle: Text(taskType.taskTypeDescription.isNotEmpty + ? taskType.taskTypeDescription + : 'Send a message to our team'), + onTap: () { + debugPrint('[MenuBrowse] Start chat tapped'); + Navigator.pop(context); + _startChat(appState); + }, + )); + } + widgets.add(const Divider()); + } + // All other task types - only show for dine-in with service point + else if (isDineIn && _servicePointId != null) { + widgets.add(ListTile( + leading: CircleAvatar( + backgroundColor: taskType.iconColor, + child: Icon(taskType.icon, color: Colors.white), + ), + title: Text(taskType.taskTypeName), + subtitle: Text(taskType.taskTypeDescription.isNotEmpty + ? taskType.taskTypeDescription + : 'Request this service'), + onTap: () { + Navigator.pop(context); + _sendTaskRequest(appState, taskType); + }, + )); + widgets.add(const Divider()); + } + } + + // Remove trailing divider if present + if (widgets.isNotEmpty && widgets.last is Divider) { + widgets.removeLast(); + } + + return widgets; + } + + /// Send a task request (server call, pay cash, custom, etc.) + Future _sendTaskRequest(AppState appState, TaskType taskType) async { + if (_isCallingServer) return; + setState(() => _isCallingServer = true); + + try { + await Api.callServer( + businessId: _businessId!, + servicePointId: _servicePointId!, + orderId: appState.cartOrderId, + userId: appState.userId, + message: taskType.taskTypeName, + taskTypeId: taskType.taskTypeId, + ); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + Icon(taskType.icon, color: Colors.white), + const SizedBox(width: 8), + Expanded(child: Text("${taskType.taskTypeName} requested", style: const TextStyle(color: Colors.white))), + ], + ), + backgroundColor: taskType.color, + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), + ), + ); + } catch (e) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error, color: Colors.black), + const SizedBox(width: 8), + Expanded(child: Text("Failed to send request: $e", style: const TextStyle(color: Colors.black))), + ], + ), + backgroundColor: Colors.red.shade100, + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), + ), + ); + } finally { + if (mounted) { + setState(() => _isCallingServer = false); + } + } + } + /// Check if user is logged in, prompt login if not /// Returns true if logged in, false if user needs to log in Future _ensureLoggedIn() async { @@ -226,8 +419,13 @@ class _MenuBrowseScreenState extends State { /// Rejoin an existing active chat Future _rejoinChat(int taskId) async { - if (!await _ensureLoggedIn()) return; + debugPrint('[MenuBrowse] Rejoining chat: taskId=$taskId'); + if (!await _ensureLoggedIn()) { + debugPrint('[MenuBrowse] Login required for rejoin - user cancelled or failed'); + return; + } + debugPrint('[MenuBrowse] Navigating to chat screen'); if (!mounted) return; Navigator.push( context, @@ -240,115 +438,56 @@ class _MenuBrowseScreenState extends State { ); } - /// Send a server visit request (ping) - Future _sendServerRequest(AppState appState) async { - if (_isCallingServer) return; - setState(() => _isCallingServer = true); - - try { - await Api.callServer( - businessId: _businessId!, - servicePointId: _servicePointId!, - orderId: appState.cartOrderId, - userId: appState.userId, - ); - - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Row( - children: [ - Icon(Icons.check_circle, color: Colors.black), - SizedBox(width: 8), - Expanded(child: Text("Server has been notified", style: TextStyle(color: Colors.black))), - ], - ), - backgroundColor: const Color(0xFF90EE90), - behavior: SnackBarBehavior.floating, - margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), - ), - ); - } catch (e) { - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.error, color: Colors.black), - const SizedBox(width: 8), - Expanded(child: Text("Failed to call server: $e", style: const TextStyle(color: Colors.black))), - ], - ), - backgroundColor: Colors.red.shade100, - behavior: SnackBarBehavior.floating, - margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), - ), - ); - } finally { - if (mounted) { - setState(() => _isCallingServer = false); - } - } - } - /// Start a new chat with staff Future _startChat(AppState appState) async { if (_isCallingServer) return; - // Check login first - if (!await _ensureLoggedIn()) return; - - setState(() => _isCallingServer = true); - - try { - // Reload auth to get userId - final auth = await AuthStorage.loadAuth(); - final userId = auth?.userId; - - // Create new chat - final taskId = await Api.createChatTask( - businessId: _businessId!, - servicePointId: _servicePointId!, - orderId: appState.cartOrderId, - userId: userId, - ); - - if (!mounted) return; - - // Navigate to chat screen - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatScreen( - taskId: taskId, - userType: 'customer', - ), - ), - ); - } catch (e) { - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.error, color: Colors.black), - const SizedBox(width: 8), - Expanded(child: Text("Failed to start chat: $e", style: const TextStyle(color: Colors.black))), - ], - ), - backgroundColor: Colors.red.shade100, - behavior: SnackBarBehavior.floating, - margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), - ), - ); - } finally { + // Check we have required info - businessId is always required + if (_businessId == null) { + debugPrint('[MenuBrowse] Cannot start chat - missing businessId'); if (mounted) { - setState(() => _isCallingServer = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Unable to start chat - please try again'), + backgroundColor: Colors.orange, + ), + ); } + return; } + + // Check login first + if (!await _ensureLoggedIn()) { + debugPrint('[MenuBrowse] Login cancelled or failed'); + return; + } + + // Get userId for chat screen + final auth = await AuthStorage.loadAuth(); + final userId = auth?.userId; + + // For non-dine-in users without a service point, use 0 + final servicePointId = _servicePointId ?? 0; + + debugPrint('[MenuBrowse] Starting chat immediately: businessId=$_businessId, servicePointId=$servicePointId, userId=$userId, isDineIn=${appState.isDineIn}'); + + if (!mounted) return; + + // Navigate to chat screen IMMEDIATELY - let it create the task in background + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen( + taskId: null, // Task will be created by ChatScreen + userType: 'customer', + businessId: _businessId, + servicePointId: servicePointId, + orderId: appState.cartOrderId, + userId: userId, + ), + ), + ); + debugPrint('[MenuBrowse] Navigated to ChatScreen (task will be created in background)'); } void _organizeItems() { @@ -411,71 +550,51 @@ class _MenuBrowseScreenState extends State { return Scaffold( appBar: AppBar( - title: Row( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + // If this business has a parent, go back to business selector + if (appState.hasParentBusiness) { + Navigator.of(context).pushReplacementNamed( + AppRoutes.businessSelector, + arguments: { + "parentBusinessId": appState.parentBusinessId, + "parentBusinessName": appState.parentBusinessName, + "servicePointId": appState.selectedServicePointId, + "servicePointName": appState.selectedServicePointName, + }, + ); + } else { + Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); + } + }, + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - // Business logo - if (_businessId != null) - Padding( - padding: const EdgeInsets.only(right: 12), - child: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: SizedBox( - width: 36, - height: 36, - child: Image.network( - "$_imageBaseUrl/logos/$_businessId.png", - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Image.network( - "$_imageBaseUrl/logos/$_businessId.jpg", - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - Icons.store, - size: 20, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ); - }, - ); - }, - ), - ), + Text( + businessName, + style: const TextStyle(fontSize: 18), + ), + // Only show table name for dine-in orders (beacon detected) + if (appState.isDineIn && appState.selectedServicePointName != null) + Text( + appState.selectedServicePointName!, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.normal, ), ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - businessName, - style: const TextStyle(fontSize: 18), - ), - // Only show table name for dine-in orders (beacon detected) - if (appState.isDineIn && appState.selectedServicePointName != null) - Text( - appState.selectedServicePointName!, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.normal, - ), - ), - ], - ), - ), ], ), actions: [ - // Call Server (dine-in) or Chat (non-dine-in) button + // Rescan for table button + const RescanButton(), + // Service bell for dine-in, chat bubble for non-dine-in IconButton( - icon: Icon(appState.isDineIn ? Icons.room_service : Icons.chat_bubble_outline), - tooltip: appState.isDineIn ? "Call Server" : "Chat", + icon: Icon(appState.isDineIn ? Icons.notifications_active : Icons.chat_bubble_outline), + tooltip: appState.isDineIn ? "Call Server" : "Chat With Staff", onPressed: () => _handleCallServer(appState), ), IconButton( @@ -561,35 +680,7 @@ class _MenuBrowseScreenState extends State { color: const Color(0xFFF0F0F0), child: Column( children: [ - // Top gradient transition from category bar - Container( - height: 12, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - const Color(0xFF1B4D3E).withAlpha(60), - const Color(0xFFF0F0F0), - ], - ), - ), - ), ...items.map((item) => _buildMenuItem(item)), - // Bottom fade-out gradient to show end of expanded section - Container( - height: 24, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - const Color(0xFFF0F0F0), - const Color(0xFF1B4D3E).withAlpha(60), - ], - ), - ), - ), ], ), ), @@ -619,74 +710,35 @@ class _MenuBrowseScreenState extends State { return Container( width: double.infinity, height: 180, - child: Stack( - fit: StackFit.expand, - children: [ - // Header background image - Image.network( - "$_imageBaseUrl/headers/$_businessId.png", - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Image.network( - "$_imageBaseUrl/headers/$_businessId.jpg", - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - // No header image - show gradient background - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Theme.of(context).colorScheme.primary, - Theme.of(context).colorScheme.secondary, - ], - ), + color: _brandColor, // Fill empty space with business brand color + child: Center( + child: Image.network( + "$_imageBaseUrl/headers/$_businessId.png", + fit: BoxFit.fitWidth, + width: double.infinity, + errorBuilder: (context, error, stackTrace) { + return Image.network( + "$_imageBaseUrl/headers/$_businessId.jpg", + fit: BoxFit.fitWidth, + width: double.infinity, + errorBuilder: (context, error, stackTrace) { + // No header image - show gradient with brand color + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + _brandColor, + _brandColor.withAlpha(200), + ], ), - ); - }, - ); - }, - ), - // Top edge gradient - Positioned( - top: 0, - left: 0, - right: 0, - height: 16, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withAlpha(180), - Colors.black.withAlpha(0), - ], - ), - ), - ), - ), - // Bottom edge gradient - Positioned( - bottom: 0, - left: 0, - right: 0, - height: 16, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withAlpha(0), - Colors.black.withAlpha(200), - ], - ), - ), - ), - ), - ], + ), + ); + }, + ); + }, + ), ), ); } @@ -800,7 +852,7 @@ class _MenuBrowseScreenState extends State { children: [ // Category image background or styled text fallback _buildCategoryBackground(categoryId, categoryName), - // Top edge gradient (subtle forest green) + // Top edge gradient (brand color) Positioned( top: 0, left: 0, @@ -812,14 +864,14 @@ class _MenuBrowseScreenState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - const Color(0xFF1B4D3E).withAlpha(120), - const Color(0xFF1B4D3E).withAlpha(0), + _brandColor.withAlpha(150), + _brandColor.withAlpha(0), ], ), ), ), ), - // Bottom edge gradient (subtle forest green) + // Bottom edge gradient (brand color) Positioned( bottom: 0, left: 0, @@ -831,8 +883,46 @@ class _MenuBrowseScreenState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - const Color(0xFF1B4D3E).withAlpha(0), - const Color(0xFF1B4D3E).withAlpha(150), + _brandColor.withAlpha(0), + _brandColor.withAlpha(150), + ], + ), + ), + ), + ), + // Left edge gradient (brand color) + Positioned( + top: 0, + bottom: 0, + left: 0, + width: 16, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + _brandColor.withAlpha(150), + _brandColor.withAlpha(0), + ], + ), + ), + ), + ), + // Right edge gradient (brand color) + Positioned( + top: 0, + bottom: 0, + right: 0, + width: 16, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + _brandColor.withAlpha(0), + _brandColor.withAlpha(150), ], ), ), @@ -870,13 +960,7 @@ class _MenuBrowseScreenState extends State { color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(16), - onTap: () { - if (hasModifiers) { - _showItemCustomization(item); - } else { - _addToCart(item, {}); - } - }, + onTap: () => _showItemCustomization(item), child: Padding( padding: const EdgeInsets.all(12), child: Row( @@ -964,30 +1048,23 @@ class _MenuBrowseScreenState extends State { } Future _addToCart(MenuItem item, Set selectedModifierIds, {int quantity = 1}) async { - // Check if user is logged in - if not, navigate to login + // Check if user is logged in - if not, show inline sign-in dialog if (_userId == null) { - final shouldLogin = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text("Login Required"), - content: const Text("Please login to add items to your cart."), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text("Cancel"), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text("Login"), - ), - ], - ), - ); + final signedIn = await SignInDialog.show(context); - if (shouldLogin == true && mounted) { - Navigator.of(context).pushNamed(AppRoutes.login); + if (!mounted) return; + + if (signedIn) { + // Refresh user ID from app state after successful sign-in + final appState = context.read(); + setState(() { + _userId = appState.userId; + }); + // Continue with adding to cart below + } else { + // User cancelled sign-in + return; } - return; } if (_businessId == null || _servicePointId == null) { @@ -1065,7 +1142,7 @@ class _MenuBrowseScreenState extends State { (li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted ).firstOrNull; - final newQuantity = (existingItem?.quantity ?? 0) + 1; + final newQuantity = (existingItem?.quantity ?? 0) + quantity; cart = await Api.setLineItem( orderId: cart.orderId, diff --git a/lib/screens/order_detail_screen.dart b/lib/screens/order_detail_screen.dart index 250e16a..5d27be2 100644 --- a/lib/screens/order_detail_screen.dart +++ b/lib/screens/order_detail_screen.dart @@ -3,6 +3,7 @@ import 'package:intl/intl.dart'; import '../models/order_detail.dart'; import '../services/api.dart'; +import '../widgets/rescan_button.dart'; class OrderDetailScreen extends StatefulWidget { final int orderId; @@ -54,6 +55,9 @@ class _OrderDetailScreenState extends State { return Scaffold( appBar: AppBar( title: Text('Order #${widget.orderId}'), + actions: const [ + RescanButton(), + ], ), body: _buildBody(), ); diff --git a/lib/screens/order_history_screen.dart b/lib/screens/order_history_screen.dart index 0ef80e8..07b6664 100644 --- a/lib/screens/order_history_screen.dart +++ b/lib/screens/order_history_screen.dart @@ -3,6 +3,7 @@ import 'package:intl/intl.dart'; import '../models/order_history.dart'; import '../services/api.dart'; +import '../widgets/rescan_button.dart'; import 'order_detail_screen.dart'; class OrderHistoryScreen extends StatefulWidget { @@ -55,6 +56,9 @@ class _OrderHistoryScreenState extends State { return Scaffold( appBar: AppBar( title: const Text('Order History'), + actions: const [ + RescanButton(), + ], ), body: _buildBody(), ); diff --git a/lib/screens/restaurant_select_screen.dart b/lib/screens/restaurant_select_screen.dart index 46c51d6..ac52507 100644 --- a/lib/screens/restaurant_select_screen.dart +++ b/lib/screens/restaurant_select_screen.dart @@ -2,12 +2,14 @@ import "package:flutter/material.dart"; import "package:provider/provider.dart"; +import "package:geolocator/geolocator.dart"; import "../app/app_state.dart"; import "../models/menu_item.dart"; import "../models/restaurant.dart"; import "../models/service_point.dart"; import "../services/api.dart"; +import "../widgets/rescan_button.dart"; class RestaurantSelectScreen extends StatefulWidget { const RestaurantSelectScreen({super.key}); @@ -34,7 +36,7 @@ class _RestaurantSelectScreenState extends State { @override void initState() { super.initState(); - _restaurantsFuture = _loadRestaurants(); + _restaurantsFuture = _loadRestaurantsWithLocation(); // Clear order type when arriving at restaurant select (no beacon = not dine-in) // This ensures the table change icon doesn't appear for delivery/takeaway orders @@ -44,11 +46,32 @@ class _RestaurantSelectScreenState extends State { }); } - Future> _loadRestaurants() async { - final raw = await Api.listRestaurantsRaw(); + Future> _loadRestaurantsWithLocation() async { + double? lat; + double? lng; + + // Try to get user location for distance-based sorting + try { + final permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.always || permission == LocationPermission.whileInUse) { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.low, + timeLimit: Duration(seconds: 5), + ), + ); + lat = position.latitude; + lng = position.longitude; + debugPrint('[RestaurantSelect] Got location: $lat, $lng'); + } + } catch (e) { + debugPrint('[RestaurantSelect] Location error (continuing without): $e'); + } + + final raw = await Api.listRestaurantsRaw(lat: lat, lng: lng); _debugLastRaw = raw.rawBody; _debugLastStatus = raw.statusCode; - return Api.listRestaurants(); + return Api.listRestaurants(lat: lat, lng: lng); } Future _loadMenuForBusiness(int businessId) async { @@ -113,7 +136,7 @@ class _RestaurantSelectScreenState extends State { Api.setBusinessId(restaurant.businessId); // Navigate to full menu browse screen - Navigator.of(context).pushReplacementNamed( + Navigator.of(context).pushNamed( '/menu_browse', arguments: { 'businessId': restaurant.businessId, @@ -122,15 +145,48 @@ class _RestaurantSelectScreenState extends State { ); } + Future _onWillPop() async { + final shouldExit = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Exit App?"), + content: const Text("Are you sure you want to exit?"), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text("Exit"), + ), + ], + ), + ); + return shouldExit ?? false; + } + @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - appBar: AppBar( + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldExit = await _onWillPop(); + if (shouldExit && context.mounted) { + Navigator.of(context).pop(); + } + }, + child: Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( backgroundColor: Colors.black, foregroundColor: Colors.white, title: const Text("Nearby Restaurants"), elevation: 0, + actions: const [ + RescanButton(iconColor: Colors.white), + ], ), body: FutureBuilder>( future: _restaurantsFuture, @@ -147,7 +203,7 @@ class _RestaurantSelectScreenState extends State { message: snapshot.error.toString(), statusCode: _debugLastStatus, raw: _debugLastRaw, - onRetry: () => setState(() => _restaurantsFuture = _loadRestaurants()), + onRetry: () => setState(() => _restaurantsFuture = _loadRestaurantsWithLocation()), ); } @@ -158,7 +214,7 @@ class _RestaurantSelectScreenState extends State { message: "No Payfrit restaurants nearby.", statusCode: _debugLastStatus, raw: _debugLastRaw, - onRetry: () => setState(() => _restaurantsFuture = _loadRestaurants()), + onRetry: () => setState(() => _restaurantsFuture = _loadRestaurantsWithLocation()), ); } @@ -182,6 +238,7 @@ class _RestaurantSelectScreenState extends State { ); }, ), + ), ); } } @@ -205,191 +262,125 @@ class _RestaurantBar extends StatelessWidget { required this.imageBaseUrl, }); - Widget _buildLogoPlaceholder(BuildContext context) { - return Container( - width: 56, - height: 56, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Theme.of(context).colorScheme.primary, - Theme.of(context).colorScheme.tertiary, - ], - ), - ), - alignment: Alignment.center, - child: Text( - restaurant.name.isNotEmpty ? restaurant.name[0].toUpperCase() : "?", - style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - ); - } - @override Widget build(BuildContext context) { return Column( children: [ - // Restaurant header bar with logo + // Restaurant card with header image (matches business selector style) GestureDetector( onTap: onTap, child: Container( - height: 80, - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + height: 120, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: isExpanded - ? Theme.of(context).colorScheme.primaryContainer - : Colors.grey.shade900, - ), - child: Stack( - children: [ - // Background header image (subtle) - ignorePointer so taps go through - IgnorePointer( - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Opacity( - opacity: 0.3, - child: SizedBox( - width: double.infinity, - height: 80, - child: Image.network( - "$imageBaseUrl/headers/${restaurant.businessId}.png", - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (context, error, stackTrace) { - return Image.network( - "$imageBaseUrl/headers/${restaurant.businessId}.jpg", - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (context, error, stackTrace) { - return const SizedBox.shrink(); - }, - ); - }, - ), - ), - ), - ), - ), - // Sharp gradient edges - Positioned( - left: 0, - top: 0, - bottom: 0, - width: 20, - child: Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(12), - bottomLeft: Radius.circular(12), - ), - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - isExpanded - ? Colors.transparent - : Colors.grey.shade900, - Colors.transparent, - ], - ), - ), - ), - ), - Positioned( - right: 0, - top: 0, - bottom: 0, - width: 20, - child: Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topRight: Radius.circular(12), - bottomRight: Radius.circular(12), - ), - gradient: LinearGradient( - begin: Alignment.centerRight, - end: Alignment.centerLeft, - colors: [ - isExpanded - ? Colors.transparent - : Colors.grey.shade900, - Colors.transparent, - ], - ), - ), - ), - ), - // Content - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - // Logo (56x56 recommended, or 112x112 for 2x) - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: SizedBox( - width: 56, - height: 56, - child: Image.network( - "$imageBaseUrl/logos/${restaurant.businessId}.png", - fit: BoxFit.cover, - gaplessPlayback: true, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - // Show placeholder immediately, image loads on top - return Stack( - children: [ - _buildLogoPlaceholder(context), - if (frame != null) child, - ], - ); - }, - errorBuilder: (context, error, stackTrace) { - return Image.network( - "$imageBaseUrl/logos/${restaurant.businessId}.jpg", - fit: BoxFit.cover, - gaplessPlayback: true, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - return Stack( - children: [ - _buildLogoPlaceholder(context), - if (frame != null) child, - ], - ); - }, - errorBuilder: (context, error, stackTrace) { - return _buildLogoPlaceholder(context); - }, - ); - }, - ), - ), - ), - const SizedBox(width: 16), - // Name - Expanded( - child: Text( - restaurant.name, - style: TextStyle( - color: isExpanded - ? Theme.of(context).colorScheme.onPrimaryContainer - : Colors.white, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(100), + blurRadius: 8, + offset: const Offset(0, 4), ), ], ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + children: [ + // Header image background + Positioned.fill( + child: Image.network( + "$imageBaseUrl/headers/${restaurant.businessId}.png", + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Image.network( + "$imageBaseUrl/headers/${restaurant.businessId}.jpg", + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + // Fallback to logo centered + return Container( + color: Colors.grey.shade800, + child: Center( + child: Image.network( + "$imageBaseUrl/logos/${restaurant.businessId}.png", + width: 60, + height: 60, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Image.network( + "$imageBaseUrl/logos/${restaurant.businessId}.jpg", + width: 60, + height: 60, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Text( + restaurant.name.isNotEmpty ? restaurant.name[0].toUpperCase() : "?", + style: const TextStyle( + color: Colors.white54, + fontSize: 36, + fontWeight: FontWeight.bold, + ), + ); + }, + ); + }, + ), + ), + ); + }, + ); + }, + ), + ), + // Gradient overlay + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withAlpha(180), + ], + ), + ), + ), + ), + // Business name and arrow at bottom + Positioned( + bottom: 12, + left: 16, + right: 16, + child: Row( + children: [ + Expanded( + child: Text( + restaurant.name, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + offset: Offset(0, 1), + blurRadius: 3, + color: Colors.black54, + ), + ], + ), + ), + ), + const Icon( + Icons.arrow_forward_ios, + color: Colors.white70, + size: 20, + ), + ], + ), + ), + ], + ), + ), ), ), @@ -560,7 +551,7 @@ class _CategorySection extends StatelessWidget { Api.setBusinessId(restaurant.businessId); // Navigate to full menu browse screen - Navigator.of(context).pushReplacementNamed( + Navigator.of(context).pushNamed( '/menu_browse', arguments: { 'businessId': restaurant.businessId, diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index a5876ae..4af740f 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -6,10 +6,11 @@ import "package:dchs_flutter_beacon/dchs_flutter_beacon.dart"; import "../app/app_router.dart"; import "../app/app_state.dart"; -import "../models/cart.dart"; import "../services/api.dart"; import "../services/auth_storage.dart"; +import "../services/beacon_cache.dart"; import "../services/beacon_permissions.dart"; +import "../services/preload_cache.dart"; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @@ -19,6 +20,9 @@ class SplashScreen extends StatefulWidget { } class _SplashScreenState extends State with TickerProviderStateMixin { + // Track if permissions were freshly granted (needs Bluetooth warmup delay) + bool _permissionsWereFreshlyGranted = false; + // Bouncing logo animation late AnimationController _bounceController; double _x = 100; @@ -40,25 +44,11 @@ class _SplashScreenState extends State with TickerProviderStateMix "connecting...", ]; - // Beacon scanning state - Map _uuidToBeaconId = {}; + // Beacon scanning state - new approach: scan all, then lookup final Map> _beaconRssiSamples = {}; final Map _beaconDetectionCount = {}; bool _scanComplete = false; - BeaconResult? _bestBeacon; - - // Existing cart state - ActiveCartInfo? _existingCart; - BeaconBusinessMapping? _beaconMapping; - - // Skip scan state - bool _scanSkipped = false; - - // Navigation state - true once we start navigating away - bool _navigating = false; - - // Minimum display time for splash screen - late DateTime _splashStartTime; + BeaconLookupResult? _bestBeacon; static const List _colors = [ Colors.white, @@ -76,9 +66,6 @@ class _SplashScreenState extends State with TickerProviderStateMix super.initState(); print('[Splash] 🚀 Starting with bouncing logo + beacon scan'); - // Record start time for minimum display duration - _splashStartTime = DateTime.now(); - // Start bouncing animation _bounceController = AnimationController( vsync: this, @@ -109,23 +96,29 @@ class _SplashScreenState extends State with TickerProviderStateMix const logoWidth = 180.0; const logoHeight = 60.0; + // Skip if screen size not yet available + if (size.width <= logoWidth || size.height <= logoHeight) return; + + final maxX = size.width - logoWidth; + final maxY = size.height - logoHeight; + setState(() { _x += _dx; _y += _dy; // Bounce off edges and change color - if (_x <= 0 || _x >= size.width - logoWidth) { + if (_x <= 0 || _x >= maxX) { _dx = -_dx; _changeColor(); } - if (_y <= 0 || _y >= size.height - logoHeight) { + if (_y <= 0 || _y >= maxY) { _dy = -_dy; _changeColor(); } // Keep in bounds - _x = _x.clamp(0, size.width - logoWidth); - _y = _y.clamp(0, size.height - logoHeight); + _x = _x.clamp(0.0, maxX); + _y = _y.clamp(0.0, maxY); }); } @@ -137,6 +130,12 @@ class _SplashScreenState extends State with TickerProviderStateMix } Future _initializeApp() async { + // Run auth check and preloading in parallel for faster startup + print('[Splash] 🚀 Starting parallel initialization...'); + + // Start preloading data in background (fire and forget for non-critical data) + PreloadCache.preloadAll(); + // Check for saved auth credentials print('[Splash] 🔐 Checking for saved auth credentials...'); final credentials = await AuthStorage.loadAuth(); @@ -163,8 +162,6 @@ class _SplashScreenState extends State with TickerProviderStateMix // Start beacon scanning in background await _performBeaconScan(); - // No minimum display time - proceed as soon as scan completes - // Navigate based on results if (!mounted) return; _navigateToNextScreen(); @@ -184,7 +181,10 @@ class _SplashScreenState extends State with TickerProviderStateMix Future _performBeaconScan() async { print('[Splash] 📡 Starting beacon scan...'); - // Request permissions + // Check if permissions are already granted BEFORE requesting + final alreadyHadPermissions = await BeaconPermissions.checkPermissions(); + + // Request permissions (will be instant if already granted) final granted = await BeaconPermissions.requestPermissions(); if (!granted) { print('[Splash] ❌ Permissions denied'); @@ -192,6 +192,12 @@ class _SplashScreenState extends State with TickerProviderStateMix return; } + // If permissions were just granted (not already had), Bluetooth needs warmup + _permissionsWereFreshlyGranted = !alreadyHadPermissions; + if (_permissionsWereFreshlyGranted) { + print('[Splash] 🆕 Permissions freshly granted - will add warmup delay'); + } + // Check if Bluetooth is ON print('[Splash] 📶 Checking Bluetooth state...'); final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled(); @@ -202,18 +208,38 @@ class _SplashScreenState extends State with TickerProviderStateMix } print('[Splash] ✅ Bluetooth is ON'); - // Fetch beacon list from server - try { - _uuidToBeaconId = await Api.listAllBeacons(); - print('[Splash] Loaded ${_uuidToBeaconId.length} beacons from database'); - } catch (e) { - print('[Splash] Error loading beacons: $e'); - _scanComplete = true; - return; + // Step 1: Try to load beacon list from cache first, then fetch from server + print('[Splash] 📥 Loading beacon list...'); + Map knownBeacons = {}; + + // Try cache first + final cached = await BeaconCache.load(); + if (cached != null && cached.isNotEmpty) { + print('[Splash] ✅ Got ${cached.length} beacon UUIDs from cache'); + knownBeacons = cached; + // Refresh cache in background (fire and forget) + Api.listAllBeacons().then((fresh) { + BeaconCache.save(fresh); + print('[Splash] 🔄 Background refresh: saved ${fresh.length} beacons to cache'); + }).catchError((e) { + print('[Splash] ⚠️ Background refresh failed: $e'); + }); + } else { + // No cache - must fetch from server + try { + knownBeacons = await Api.listAllBeacons(); + print('[Splash] ✅ Got ${knownBeacons.length} beacon UUIDs from server'); + // Save to cache + await BeaconCache.save(knownBeacons); + } catch (e) { + print('[Splash] ❌ Failed to fetch beacons: $e'); + _scanComplete = true; + return; + } } - if (_uuidToBeaconId.isEmpty) { - print('[Splash] No beacons in database'); + if (knownBeacons.isEmpty) { + print('[Splash] ⚠️ No beacons configured'); _scanComplete = true; return; } @@ -221,49 +247,53 @@ class _SplashScreenState extends State with TickerProviderStateMix // Initialize beacon scanning try { await flutterBeacon.initializeScanning; - await Future.delayed(const Duration(milliseconds: 500)); + + // Only add delay if permissions were freshly granted (Bluetooth subsystem needs warmup) + if (_permissionsWereFreshlyGranted) { + print('[Splash] 🔄 Fresh permissions - adding Bluetooth warmup delay'); + await Future.delayed(const Duration(milliseconds: 1500)); + } // Create regions for all known UUIDs - final regions = _uuidToBeaconId.keys.map((uuid) { + final regions = knownBeacons.keys.map((uuid) { final formattedUUID = '${uuid.substring(0, 8)}-${uuid.substring(8, 12)}-${uuid.substring(12, 16)}-${uuid.substring(16, 20)}-${uuid.substring(20)}'; return Region(identifier: uuid, proximityUUID: formattedUUID); }).toList(); - // Perform scan cycles - for (int scanCycle = 1; scanCycle <= 5; scanCycle++) { - print('[Splash] ----- Scan cycle $scanCycle/5 -----'); + // Single scan - collect samples for 2 seconds + print('[Splash] 🔍 Scanning...'); + StreamSubscription? subscription; + subscription = flutterBeacon.ranging(regions).listen((result) { + for (var beacon in result.beacons) { + final uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', ''); + final rssi = beacon.rssi; - StreamSubscription? subscription; - subscription = flutterBeacon.ranging(regions).listen((result) { - for (var beacon in result.beacons) { - final uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', ''); - final rssi = beacon.rssi; - - if (_uuidToBeaconId.containsKey(uuid)) { - _beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi); - _beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1; - print('[Splash] ✅ Beacon ${_uuidToBeaconId[uuid]}, RSSI=$rssi'); - } - } - }); - - await Future.delayed(const Duration(seconds: 2)); - await subscription.cancel(); - - // Check for early exit after 3 cycles - if (scanCycle >= 3 && _beaconRssiSamples.isNotEmpty && _canExitEarly()) { - print('[Splash] ⚡ Early exit - stable readings'); - break; + _beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi); + _beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1; + print('[Splash] 📶 Found $uuid RSSI=$rssi'); } + }); - if (scanCycle < 5) { - await Future.delayed(const Duration(milliseconds: 200)); + await Future.delayed(const Duration(milliseconds: 2000)); + await subscription.cancel(); + + // Now lookup business info for found beacons + if (_beaconRssiSamples.isNotEmpty) { + print('[Splash] 🔍 Looking up ${_beaconRssiSamples.length} beacons...'); + final uuids = _beaconRssiSamples.keys.toList(); + + try { + final lookupResults = await Api.lookupBeacons(uuids); + print('[Splash] 📋 Server returned ${lookupResults.length} registered beacons'); + + // Find the best registered beacon based on RSSI + _bestBeacon = _findBestRegisteredBeacon(lookupResults); + } catch (e) { + print('[Splash] Error looking up beacons: $e'); } } - // Find best beacon - _bestBeacon = _findBestBeacon(); - print('[Splash] 🎯 Best beacon: ${_bestBeacon?.beaconId ?? "none"}'); + print('[Splash] 🎯 Best beacon: ${_bestBeacon?.beaconName ?? "none"}'); } catch (e) { print('[Splash] Scan error: $e'); @@ -272,347 +302,108 @@ class _SplashScreenState extends State with TickerProviderStateMix _scanComplete = true; } - bool _canExitEarly() { - if (_beaconRssiSamples.isEmpty) return false; + /// Find the best registered beacon from lookup results based on RSSI + BeaconLookupResult? _findBestRegisteredBeacon(List registeredBeacons) { + if (registeredBeacons.isEmpty) return null; - bool hasEnoughSamples = _beaconRssiSamples.values.any((s) => s.length >= 3); - if (!hasEnoughSamples) return false; - - for (final entry in _beaconRssiSamples.entries) { - final samples = entry.value; - if (samples.length < 3) continue; - - final avg = samples.reduce((a, b) => a + b) / samples.length; - final variance = samples.map((r) => (r - avg) * (r - avg)).reduce((a, b) => a + b) / samples.length; - - if (variance > 50) return false; - } - - if (_beaconRssiSamples.length > 1) { - final avgRssis = {}; - for (final entry in _beaconRssiSamples.entries) { - if (entry.value.isNotEmpty) { - avgRssis[entry.key] = entry.value.reduce((a, b) => a + b) / entry.value.length; - } - } - final sorted = avgRssis.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); - if (sorted.length >= 2 && (sorted[0].value - sorted[1].value) < 5) { - return false; - } - } - - return true; - } - - BeaconResult? _findBestBeacon() { - if (_beaconRssiSamples.isEmpty) return null; - - String? bestUuid; + BeaconLookupResult? best; double bestAvgRssi = -999; - for (final entry in _beaconRssiSamples.entries) { - final samples = entry.value; - final detections = _beaconDetectionCount[entry.key] ?? 0; + for (final beacon in registeredBeacons) { + final samples = _beaconRssiSamples[beacon.uuid]; + if (samples == null || samples.isEmpty) continue; - if (detections < 3) continue; + final detections = _beaconDetectionCount[beacon.uuid] ?? 0; + if (detections < 2) continue; // Need at least 2 detections final avgRssi = samples.reduce((a, b) => a + b) / samples.length; if (avgRssi > bestAvgRssi && avgRssi >= -85) { bestAvgRssi = avgRssi; - bestUuid = entry.key; + best = beacon; } } - if (bestUuid != null) { - return BeaconResult( - uuid: bestUuid, - beaconId: _uuidToBeaconId[bestUuid]!, - avgRssi: bestAvgRssi, - ); - } + // Fall back to strongest registered beacon if none meet threshold + if (best == null) { + for (final beacon in registeredBeacons) { + final samples = _beaconRssiSamples[beacon.uuid]; + if (samples == null || samples.isEmpty) continue; - // Fall back to strongest signal even if doesn't meet threshold - if (_beaconRssiSamples.isNotEmpty) { - for (final entry in _beaconRssiSamples.entries) { - final samples = entry.value; - if (samples.isEmpty) continue; final avgRssi = samples.reduce((a, b) => a + b) / samples.length; if (avgRssi > bestAvgRssi) { bestAvgRssi = avgRssi; - bestUuid = entry.key; + best = beacon; } } - if (bestUuid != null) { - return BeaconResult( - uuid: bestUuid, - beaconId: _uuidToBeaconId[bestUuid]!, - avgRssi: bestAvgRssi, - ); - } } - return null; + return best; } Future _navigateToNextScreen() async { - if (!mounted || _navigating) return; + if (!mounted) return; - setState(() { - _navigating = true; - }); - - final appState = context.read(); - - // Get beacon mapping if we found a beacon if (_bestBeacon != null) { - try { - _beaconMapping = await Api.getBusinessFromBeacon(beaconId: _bestBeacon!.beaconId); - print('[Splash] 📍 Beacon maps to: ${_beaconMapping!.businessName}'); - } catch (e) { - print('[Splash] Error mapping beacon to business: $e'); - _beaconMapping = null; - } - } + final beacon = _bestBeacon!; + print('[Splash] 📊 Best beacon: ${beacon.businessName}, hasChildren=${beacon.hasChildren}, parent=${beacon.parentBusinessName}'); - // Check for existing cart if user is logged in - final userId = appState.userId; - if (userId != null && userId > 0) { - try { - _existingCart = await Api.getActiveCart(userId: userId); - if (_existingCart != null && _existingCart!.hasItems) { - print('[Splash] 🛒 Found existing cart: ${_existingCart!.itemCount} items at ${_existingCart!.businessName}'); - } else { - _existingCart = null; + // Check if this business has child businesses (food court scenario) + if (beacon.hasChildren) { + print('[Splash] 🏢 Business has children - showing selector'); + // Need to fetch children and show selector + try { + final children = await Api.getChildBusinesses(businessId: beacon.businessId); + if (!mounted) return; + + if (children.isNotEmpty) { + Navigator.of(context).pushReplacementNamed( + AppRoutes.businessSelector, + arguments: { + "parentBusinessId": beacon.businessId, + "parentBusinessName": beacon.businessName, + "servicePointId": beacon.servicePointId, + "servicePointName": beacon.servicePointName, + "children": children, + }, + ); + return; + } + } catch (e) { + print('[Splash] Error fetching children: $e'); } - } catch (e) { - print('[Splash] Error checking for existing cart: $e'); - _existingCart = null; } - } - if (!mounted) return; - - // DECISION TREE: - // 1. Beacon found? - // - Yes: Is there an existing cart? - // - Yes: Same restaurant? - // - Yes: Continue order as dine-in, update service point - // - No: Start fresh with beacon's restaurant (dine-in) - // - No: Start fresh with beacon's restaurant (dine-in) - // - No: Is there an existing cart? - // - Yes: Show "Continue or Start Fresh?" popup - // - No: Go to restaurant select - - if (_beaconMapping != null) { - // BEACON FOUND - if (_existingCart != null && _existingCart!.businessId == _beaconMapping!.businessId) { - // Same restaurant - continue order, update to dine-in with new service point - print('[Splash] ✅ Continuing existing order at same restaurant (dine-in)'); - await _continueExistingOrderWithBeacon(); - } else { - // Different restaurant or no cart - start fresh with beacon - print('[Splash] 🆕 Starting fresh dine-in order at ${_beaconMapping!.businessName}'); - _startFreshWithBeacon(); - } - } else { - // NO BEACON - if (_existingCart != null) { - // Has existing cart - ask user what to do - print('[Splash] ❓ No beacon, but has existing cart - showing choice dialog'); - _showContinueOrStartFreshDialog(); - } else { - // No cart, no beacon - go to restaurant select - print('[Splash] 📋 No beacon, no cart - going to restaurant select'); - Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); - } - } - } - - /// Continue existing order and update to dine-in with beacon's service point - Future _continueExistingOrderWithBeacon() async { - if (!mounted || _existingCart == null || _beaconMapping == null) return; - - final appState = context.read(); - - // Update order type to dine-in and set service point - try { - await Api.setOrderType( - orderId: _existingCart!.orderId, - orderTypeId: 1, // dine-in + // Single business - go directly to menu + final appState = context.read(); + appState.setBusinessAndServicePoint( + beacon.businessId, + beacon.servicePointId, + businessName: beacon.businessName, + servicePointName: beacon.servicePointName, + parentBusinessId: beacon.hasParent ? beacon.parentBusinessId : null, + parentBusinessName: beacon.hasParent ? beacon.parentBusinessName : null, ); - } catch (e) { - print('[Splash] Error updating order type: $e'); - } - - // Set app state - appState.setBusinessAndServicePoint( - _beaconMapping!.businessId, - _beaconMapping!.servicePointId, - businessName: _beaconMapping!.businessName, - servicePointName: _beaconMapping!.servicePointName, - ); - appState.setOrderType(OrderType.dineIn); - appState.setCartOrder( - orderId: _existingCart!.orderId, - orderUuid: _existingCart!.orderUuid, - itemCount: _existingCart!.itemCount, - ); - Api.setBusinessId(_beaconMapping!.businessId); - - Navigator.of(context).pushReplacementNamed( - AppRoutes.menuBrowse, - arguments: { - 'businessId': _beaconMapping!.businessId, - 'servicePointId': _beaconMapping!.servicePointId, - }, - ); - } - - /// Start fresh dine-in order with beacon - void _startFreshWithBeacon() { - if (!mounted || _beaconMapping == null) return; - - final appState = context.read(); - - // Clear any existing cart reference - appState.clearCart(); - - appState.setBusinessAndServicePoint( - _beaconMapping!.businessId, - _beaconMapping!.servicePointId, - businessName: _beaconMapping!.businessName, - servicePointName: _beaconMapping!.servicePointName, - ); - appState.setOrderType(OrderType.dineIn); - Api.setBusinessId(_beaconMapping!.businessId); - - Navigator.of(context).pushReplacementNamed( - AppRoutes.menuBrowse, - arguments: { - 'businessId': _beaconMapping!.businessId, - 'servicePointId': _beaconMapping!.servicePointId, - }, - ); - } - - /// Show dialog asking user to continue existing order or start fresh - void _showContinueOrStartFreshDialog() { - if (!mounted || _existingCart == null) return; - - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => AlertDialog( - title: const Text("Existing Order Found"), - content: Text( - "You have an existing order at ${_existingCart!.businessName} " - "with ${_existingCart!.itemCount} item${_existingCart!.itemCount == 1 ? '' : 's'}.\n\n" - "Would you like to continue with this order or start fresh?", - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - _startFresh(); - }, - child: const Text("Start Fresh"), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - _continueExistingOrder(); - }, - child: const Text("Continue Order"), - ), - ], - ), - ); - } - - /// Continue with existing order (no beacon) - void _continueExistingOrder() { - if (!mounted || _existingCart == null) return; - - final appState = context.read(); - - // Only use service point if this is actually a dine-in order - // Otherwise clear it to avoid showing stale table info - final isDineIn = _existingCart!.isDineIn; - appState.setBusinessAndServicePoint( - _existingCart!.businessId, - isDineIn && _existingCart!.servicePointId > 0 ? _existingCart!.servicePointId : 0, - businessName: _existingCart!.businessName, - servicePointName: isDineIn ? _existingCart!.servicePointName : null, - ); - - // Set order type based on existing cart - if (isDineIn) { + // Beacon detected = dine-in at a table appState.setOrderType(OrderType.dineIn); - } else if (_existingCart!.isTakeaway) { - appState.setOrderType(OrderType.takeaway); - } else if (_existingCart!.isDelivery) { - appState.setOrderType(OrderType.delivery); - } else { - appState.setOrderType(null); // Undecided - will choose at checkout + Api.setBusinessId(beacon.businessId); + + print('[Splash] 🎉 Auto-selected: ${beacon.businessName}'); + + Navigator.of(context).pushReplacementNamed( + AppRoutes.menuBrowse, + arguments: { + 'businessId': beacon.businessId, + 'servicePointId': beacon.servicePointId, + }, + ); + return; } - appState.setCartOrder( - orderId: _existingCart!.orderId, - orderUuid: _existingCart!.orderUuid, - itemCount: _existingCart!.itemCount, - ); - Api.setBusinessId(_existingCart!.businessId); - - Navigator.of(context).pushReplacementNamed( - AppRoutes.menuBrowse, - arguments: { - 'businessId': _existingCart!.businessId, - 'servicePointId': _existingCart!.servicePointId, - }, - ); - } - - /// Start fresh - abandon existing order and go to restaurant select - Future _startFresh() async { - if (!mounted) return; - - final appState = context.read(); - - // Abandon the existing order on the server - if (_existingCart != null) { - print('[Splash] Abandoning order ${_existingCart!.orderId}...'); - try { - await Api.abandonOrder(orderId: _existingCart!.orderId); - print('[Splash] Order abandoned successfully'); - } catch (e) { - // Ignore errors - just proceed with clearing local state - print('[Splash] Failed to abandon order: $e'); - } - } else { - print('[Splash] No existing cart to abandon'); - } - - appState.clearCart(); - - if (!mounted) return; + // No beacon or error - go to restaurant select + print('[Splash] Going to restaurant select'); Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); } - /// Skip the beacon scan and proceed without dine-in detection - void _skipScan() { - if (_scanSkipped || _navigating) return; - - print('[Splash] ⏭️ User skipped beacon scan'); - setState(() { - _scanSkipped = true; - _scanComplete = true; - _bestBeacon = null; // No beacon since we skipped - }); - - // Proceed with navigation (will check for existing cart) - _navigateToNextScreen(); - } - @override void dispose() { _bounceController.dispose(); @@ -667,44 +458,8 @@ class _SplashScreenState extends State with TickerProviderStateMix ), ), ), - // Skip button at bottom - show until we start navigating away - if (!_navigating) - Positioned( - bottom: 50, - left: 0, - right: 0, - child: Center( - child: TextButton( - onPressed: _skipScan, - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), - ), - child: const Text( - "Skip Scan", - style: TextStyle( - color: Colors.white70, - fontSize: 16, - fontWeight: FontWeight.w500, - letterSpacing: 0.5, - ), - ), - ), - ), - ), ], ), ); } } - -class BeaconResult { - final String uuid; - final int beaconId; - final double avgRssi; - - const BeaconResult({ - required this.uuid, - required this.beaconId, - required this.avgRssi, - }); -} diff --git a/lib/services/api.dart b/lib/services/api.dart index b285704..b53fa13 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -8,6 +8,7 @@ import "../models/order_detail.dart"; import "../models/order_history.dart"; import "../models/restaurant.dart"; import "../models/service_point.dart"; +import "../models/task_type.dart"; import "../models/user_profile.dart"; import "auth_storage.dart"; @@ -23,6 +24,13 @@ class ApiRawResponse { }); } +class MenuItemsResult { + final List items; + final String? brandColor; + + const MenuItemsResult({required this.items, this.brandColor}); +} + class LoginResponse { final int userId; final String userFirstName; @@ -408,12 +416,15 @@ class Api { // Businesses (legacy model name: Restaurant) // ------------------------- - static Future listRestaurantsRaw() async { + static Future listRestaurantsRaw({double? lat, double? lng}) async { + if (lat != null && lng != null) { + return _postRaw("/businesses/list.cfm", {"lat": lat, "lng": lng}, businessIdOverride: _mvpBusinessId); + } return _getRaw("/businesses/list.cfm", businessIdOverride: _mvpBusinessId); } - static Future> listRestaurants() async { - final raw = await listRestaurantsRaw(); + static Future> listRestaurants({double? lat, double? lng}) async { + final raw = await listRestaurantsRaw(lat: lat, lng: lng); final j = _requireJson(raw, "Businesses"); if (!_ok(j)) { @@ -478,7 +489,7 @@ class Api { // Menu Items // ------------------------- - static Future> listMenuItems({required int businessId}) async { + static Future listMenuItems({required int businessId}) async { final raw = await _postRaw( "/menu/items.cfm", {"BusinessID": businessId}, @@ -506,7 +517,15 @@ class Api { out.add(MenuItem.fromJson(e.cast())); } } - return out; + + // Extract brand color if provided + String? brandColor; + final bc = j["BRANDCOLOR"] ?? j["BrandColor"] ?? j["brandColor"]; + if (bc is String && bc.isNotEmpty) { + brandColor = bc; + } + + return MenuItemsResult(items: out, brandColor: brandColor); } // ------------------------- @@ -698,6 +717,21 @@ class Api { // Tasks / Service Requests // ------------------------- + /// Get requestable task types for a business (for bell icon menu) + static Future> getTaskTypes({required int businessId}) async { + final raw = await _postRaw("/tasks/listTypes.cfm", {"BusinessID": businessId}); + final j = _requireJson(raw, "GetTaskTypes"); + + if (!_ok(j)) { + throw StateError( + "GetTaskTypes failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}", + ); + } + + final arr = j["TASK_TYPES"] as List? ?? []; + return arr.map((e) => TaskType.fromJson(e as Map)).toList(); + } + /// Call server to the table - creates a service request task static Future callServer({ required int businessId, @@ -705,6 +739,7 @@ class Api { int? orderId, int? userId, String? message, + int? taskTypeId, }) async { final body = { "BusinessID": businessId, @@ -713,6 +748,7 @@ class Api { if (orderId != null && orderId > 0) body["OrderID"] = orderId; if (userId != null && userId > 0) body["UserID"] = userId; if (message != null && message.isNotEmpty) body["Message"] = message; + if (taskTypeId != null && taskTypeId > 0) body["TaskTypeID"] = taskTypeId; final raw = await _postRaw("/tasks/callServer.cfm", body); final j = _requireJson(raw, "CallServer"); @@ -728,6 +764,73 @@ class Api { // Beacons // ------------------------- + /// Lookup beacons by UUID - sends found UUIDs to server to check if registered + static Future> lookupBeacons(List uuids) async { + if (uuids.isEmpty) return []; + + final raw = await _postRaw("/beacons/lookup.cfm", {"UUIDs": uuids}); + final j = _requireJson(raw, "LookupBeacons"); + + if (!_ok(j)) { + throw StateError( + "LookupBeacons API returned OK=false\nERROR: ${_err(j)}\nHTTP Status: ${raw.statusCode}", + ); + } + + final arr = j["BEACONS"] as List? ?? []; + final results = []; + + for (final e in arr) { + if (e is! Map) continue; + results.add(BeaconLookupResult( + uuid: (e["UUID"] as String?) ?? "", + beaconId: _parseInt(e["BeaconID"]) ?? 0, + beaconName: (e["BeaconName"] as String?) ?? "", + businessId: _parseInt(e["BusinessID"]) ?? 0, + businessName: (e["BusinessName"] as String?) ?? "", + servicePointId: _parseInt(e["ServicePointID"]) ?? 0, + servicePointName: (e["ServicePointName"] as String?) ?? "", + parentBusinessId: _parseInt(e["ParentBusinessID"]) ?? 0, + parentBusinessName: (e["ParentBusinessName"] as String?) ?? "", + hasChildren: e["HasChildren"] == true, + )); + } + + return results; + } + + /// Get beacons for a specific business (optimized for rescan) + static Future> listBeaconsByBusiness({required int businessId}) async { + final raw = await _postRaw("/beacons/list.cfm", {"BusinessID": businessId}); + final j = _requireJson(raw, "ListBeaconsByBusiness"); + + if (!_ok(j)) { + throw StateError( + "ListBeaconsByBusiness API returned OK=false\nERROR: ${_err(j)}\nHTTP Status: ${raw.statusCode}", + ); + } + + final arr = _pickArray(j, const ["BEACONS", "beacons"]); + if (arr == null) return {}; + + final Map uuidToBeaconId = {}; + + for (final e in arr) { + if (e is! Map) continue; + final item = e is Map ? e : e.cast(); + + final uuid = (item["UUID"] ?? item["uuid"] ?? "").toString().trim().toUpperCase(); + final beaconId = item["BeaconID"] ?? item["BEACONID"] ?? item["beaconId"]; + + if (uuid.isNotEmpty && beaconId is num) { + uuidToBeaconId[uuid] = beaconId.toInt(); + } + } + + return uuidToBeaconId; + } + + /// @deprecated Use lookupBeacons instead - this downloads ALL beacons which doesn't scale static Future> listAllBeacons() async { final raw = await _getRaw("/beacons/list_all.cfm"); final j = _requireJson(raw, "ListAllBeacons"); @@ -747,7 +850,7 @@ class Api { if (e is! Map) continue; final item = e is Map ? e : e.cast(); - final uuid = (item["BeaconUUID"] ?? item["BEACONUUID"] ?? "").toString().trim(); + final uuid = (item["BeaconUUID"] ?? item["BEACONUUID"] ?? "").toString().trim().toUpperCase(); final beaconId = item["BeaconID"] ?? item["BEACONID"]; if (uuid.isNotEmpty && beaconId is num) { @@ -777,6 +880,32 @@ class Api { final business = j["BUSINESS"] as Map? ?? {}; final servicePoint = j["SERVICEPOINT"] as Map? ?? {}; + // Parse child businesses if present + final List childBusinesses = []; + final businessesArr = j["BUSINESSES"] as List?; + if (businessesArr != null) { + for (final b in businessesArr) { + if (b is Map) { + childBusinesses.add(ChildBusiness( + businessId: _parseInt(b["BusinessID"]) ?? 0, + businessName: (b["BusinessName"] as String?) ?? "", + servicePointId: _parseInt(b["ServicePointID"]) ?? 0, + servicePointName: (b["ServicePointName"] as String?) ?? "", + )); + } + } + } + + // Parse parent if present + BeaconParent? parent; + final parentMap = j["PARENT"] as Map?; + if (parentMap != null) { + parent = BeaconParent( + businessId: _parseInt(parentMap["BusinessID"]) ?? 0, + businessName: (parentMap["BusinessName"] as String?) ?? "", + ); + } + return BeaconBusinessMapping( beaconId: _parseInt(beacon["BeaconID"]) ?? 0, beaconName: (beacon["BeaconName"] as String?) ?? "", @@ -784,9 +913,39 @@ class Api { businessName: (business["BusinessName"] as String?) ?? "", servicePointId: _parseInt(servicePoint["ServicePointID"]) ?? 0, servicePointName: (servicePoint["ServicePointName"] as String?) ?? "", + businesses: childBusinesses, + parent: parent, ); } + /// Get child businesses for a parent business + static Future> getChildBusinesses({ + required int businessId, + }) async { + final raw = await _getRaw("/businesses/getChildren.cfm?BusinessID=$businessId"); + final j = _requireJson(raw, "GetChildBusinesses"); + + if (!_ok(j)) { + return []; + } + + final List children = []; + final arr = _pickArray(j, const ["BUSINESSES", "businesses"]); + if (arr != null) { + for (final b in arr) { + if (b is Map) { + children.add(ChildBusiness( + businessId: _parseInt(b["BusinessID"]) ?? 0, + businessName: (b["BusinessName"] as String?) ?? "", + servicePointId: _parseInt(b["ServicePointID"]) ?? 0, + servicePointName: (b["ServicePointName"] as String?) ?? "", + )); + } + } + } + return children; + } + static int? _parseInt(dynamic value) { if (value == null) return null; if (value is int) return value; @@ -1058,16 +1217,18 @@ class Api { // Chat // ------------------------- - /// Check if there's an active chat for the service point + /// Check if there's an active chat for the user at a service point /// Returns the task ID if found, null otherwise static Future getActiveChat({ required int businessId, required int servicePointId, + int? userId, }) async { final body = { "BusinessID": businessId, "ServicePointID": servicePointId, }; + if (userId != null && userId > 0) body["UserID"] = userId; final raw = await _postRaw("/chat/getActiveChat.cfm", body); final j = _requireJson(raw, "GetActiveChat"); @@ -1201,6 +1362,20 @@ class OrderHistoryResponse { }); } +class ChildBusiness { + final int businessId; + final String businessName; + final int servicePointId; + final String servicePointName; + + const ChildBusiness({ + required this.businessId, + required this.businessName, + this.servicePointId = 0, + this.servicePointName = "", + }); +} + class BeaconBusinessMapping { final int beaconId; final String beaconName; @@ -1208,6 +1383,8 @@ class BeaconBusinessMapping { final String businessName; final int servicePointId; final String servicePointName; + final List businesses; + final BeaconParent? parent; const BeaconBusinessMapping({ required this.beaconId, @@ -1216,9 +1393,50 @@ class BeaconBusinessMapping { required this.businessName, required this.servicePointId, required this.servicePointName, + this.businesses = const [], + this.parent, }); } +class BeaconParent { + final int businessId; + final String businessName; + + const BeaconParent({ + required this.businessId, + required this.businessName, + }); +} + +/// Result from beacon UUID lookup - contains all info needed to navigate +class BeaconLookupResult { + final String uuid; + final int beaconId; + final String beaconName; + final int businessId; + final String businessName; + final int servicePointId; + final String servicePointName; + final int parentBusinessId; + final String parentBusinessName; + final bool hasChildren; + + const BeaconLookupResult({ + required this.uuid, + required this.beaconId, + required this.beaconName, + required this.businessId, + required this.businessName, + required this.servicePointId, + required this.servicePointName, + this.parentBusinessId = 0, + this.parentBusinessName = "", + this.hasChildren = false, + }); + + bool get hasParent => parentBusinessId > 0; +} + class PendingOrder { final int orderId; final String orderUuid; diff --git a/lib/services/beacon_cache.dart b/lib/services/beacon_cache.dart new file mode 100644 index 0000000..1076421 --- /dev/null +++ b/lib/services/beacon_cache.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; + +class BeaconCache { + static const _keyBeaconData = 'beacon_cache_data'; + static const _keyBeaconTimestamp = 'beacon_cache_timestamp'; + static const _cacheDuration = Duration(hours: 24); // Cache for 24 hours + + /// Save beacon list to cache + static Future save(Map beacons) async { + final prefs = await SharedPreferences.getInstance(); + final json = jsonEncode(beacons); + await prefs.setString(_keyBeaconData, json); + await prefs.setInt(_keyBeaconTimestamp, DateTime.now().millisecondsSinceEpoch); + } + + /// Load beacon list from cache (returns null if expired or not found) + static Future?> load() async { + final prefs = await SharedPreferences.getInstance(); + final timestamp = prefs.getInt(_keyBeaconTimestamp); + final data = prefs.getString(_keyBeaconData); + + if (timestamp == null || data == null) { + return null; + } + + // Check if cache is expired + final cachedTime = DateTime.fromMillisecondsSinceEpoch(timestamp); + if (DateTime.now().difference(cachedTime) > _cacheDuration) { + return null; + } + + try { + final decoded = jsonDecode(data) as Map; + return decoded.map((k, v) => MapEntry(k, v as int)); + } catch (e) { + return null; + } + } + + /// Clear the beacon cache + static Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keyBeaconData); + await prefs.remove(_keyBeaconTimestamp); + } +} diff --git a/lib/services/beacon_scanner_service.dart b/lib/services/beacon_scanner_service.dart new file mode 100644 index 0000000..550e90d --- /dev/null +++ b/lib/services/beacon_scanner_service.dart @@ -0,0 +1,204 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart'; + +import 'api.dart'; +import 'beacon_permissions.dart'; + +/// Result of a beacon scan +class BeaconScanResult { + final BeaconLookupResult? bestBeacon; + final int beaconsFound; + final String? error; + + const BeaconScanResult({ + this.bestBeacon, + this.beaconsFound = 0, + this.error, + }); + + bool get success => error == null; + bool get foundBeacon => bestBeacon != null; +} + +/// Global beacon scanner service for rescanning from anywhere in the app +class BeaconScannerService { + static final BeaconScannerService _instance = BeaconScannerService._internal(); + factory BeaconScannerService() => _instance; + BeaconScannerService._internal(); + + bool _isScanning = false; + bool get isScanning => _isScanning; + + // Callbacks for UI updates + final _scanStateController = StreamController.broadcast(); + Stream get scanStateStream => _scanStateController.stream; + + /// Perform a beacon scan + /// If [businessId] is provided, only scans for that business's beacons (optimized) + /// Otherwise, scans for all beacons and looks them up + Future scan({int? businessId}) async { + if (_isScanning) { + return const BeaconScanResult(error: "Scan already in progress"); + } + + _isScanning = true; + _scanStateController.add(true); + + try { + // Request permissions + final granted = await BeaconPermissions.requestPermissions(); + if (!granted) { + return const BeaconScanResult(error: "Permissions denied"); + } + + // Check Bluetooth + final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled(); + if (!bluetoothOn) { + return const BeaconScanResult(error: "Bluetooth is off"); + } + + // Get beacon UUIDs to scan for + Map knownBeacons = {}; + + if (businessId != null && businessId > 0) { + // Optimized: only get beacons for this business + debugPrint('[BeaconScanner] Scanning for business $businessId beacons only'); + try { + knownBeacons = await Api.listBeaconsByBusiness(businessId: businessId); + debugPrint('[BeaconScanner] Got ${knownBeacons.length} beacons for business'); + } catch (e) { + debugPrint('[BeaconScanner] Failed to get business beacons: $e'); + } + } + + // Fall back to all beacons if business-specific didn't work + if (knownBeacons.isEmpty) { + debugPrint('[BeaconScanner] Fetching all beacon UUIDs'); + try { + knownBeacons = await Api.listAllBeacons(); + debugPrint('[BeaconScanner] Got ${knownBeacons.length} total beacons'); + } catch (e) { + debugPrint('[BeaconScanner] Failed to fetch beacons: $e'); + return BeaconScanResult(error: "Failed to fetch beacons: $e"); + } + } + + if (knownBeacons.isEmpty) { + return const BeaconScanResult(error: "No beacons configured"); + } + + // Initialize scanning + await flutterBeacon.initializeScanning; + + // Create regions for all known UUIDs + final regions = knownBeacons.keys.map((uuid) { + final formattedUUID = '${uuid.substring(0, 8)}-${uuid.substring(8, 12)}-${uuid.substring(12, 16)}-${uuid.substring(16, 20)}-${uuid.substring(20)}'; + return Region(identifier: uuid, proximityUUID: formattedUUID); + }).toList(); + + // Collect RSSI samples + final Map> rssiSamples = {}; + final Map detectionCounts = {}; + + debugPrint('[BeaconScanner] Starting 2-second scan...'); + StreamSubscription? subscription; + subscription = flutterBeacon.ranging(regions).listen((result) { + for (var beacon in result.beacons) { + final uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', ''); + final rssi = beacon.rssi; + + rssiSamples.putIfAbsent(uuid, () => []).add(rssi); + detectionCounts[uuid] = (detectionCounts[uuid] ?? 0) + 1; + debugPrint('[BeaconScanner] Found $uuid RSSI=$rssi'); + } + }); + + await Future.delayed(const Duration(milliseconds: 2000)); + await subscription.cancel(); + + if (rssiSamples.isEmpty) { + debugPrint('[BeaconScanner] No beacons detected'); + return const BeaconScanResult(beaconsFound: 0); + } + + // Lookup found beacons + debugPrint('[BeaconScanner] Looking up ${rssiSamples.length} beacons...'); + final uuids = rssiSamples.keys.toList(); + List lookupResults = []; + + try { + lookupResults = await Api.lookupBeacons(uuids); + debugPrint('[BeaconScanner] Server returned ${lookupResults.length} registered beacons'); + } catch (e) { + debugPrint('[BeaconScanner] Lookup error: $e'); + return BeaconScanResult( + beaconsFound: rssiSamples.length, + error: "Failed to lookup beacons", + ); + } + + // Find the best registered beacon + final bestBeacon = _findBestBeacon(lookupResults, rssiSamples, detectionCounts); + + debugPrint('[BeaconScanner] Best beacon: ${bestBeacon?.beaconName ?? "none"}'); + + return BeaconScanResult( + bestBeacon: bestBeacon, + beaconsFound: rssiSamples.length, + ); + } catch (e) { + debugPrint('[BeaconScanner] Scan error: $e'); + return BeaconScanResult(error: e.toString()); + } finally { + _isScanning = false; + _scanStateController.add(false); + } + } + + /// Find the best registered beacon based on RSSI + BeaconLookupResult? _findBestBeacon( + List registeredBeacons, + Map> rssiSamples, + Map detectionCounts, + ) { + if (registeredBeacons.isEmpty) return null; + + BeaconLookupResult? best; + double bestAvgRssi = -999; + + for (final beacon in registeredBeacons) { + final samples = rssiSamples[beacon.uuid]; + if (samples == null || samples.isEmpty) continue; + + final detections = detectionCounts[beacon.uuid] ?? 0; + if (detections < 2) continue; // Need at least 2 detections + + final avgRssi = samples.reduce((a, b) => a + b) / samples.length; + if (avgRssi > bestAvgRssi && avgRssi >= -85) { + bestAvgRssi = avgRssi; + best = beacon; + } + } + + // Fall back to strongest if none meet threshold + if (best == null) { + for (final beacon in registeredBeacons) { + final samples = rssiSamples[beacon.uuid]; + if (samples == null || samples.isEmpty) continue; + + final avgRssi = samples.reduce((a, b) => a + b) / samples.length; + if (avgRssi > bestAvgRssi) { + bestAvgRssi = avgRssi; + best = beacon; + } + } + } + + return best; + } + + void dispose() { + _scanStateController.close(); + } +} diff --git a/lib/services/preload_cache.dart b/lib/services/preload_cache.dart new file mode 100644 index 0000000..fb178ba --- /dev/null +++ b/lib/services/preload_cache.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'api.dart'; +import '../models/task_type.dart'; + +/// Centralized preload cache for app startup optimization +class PreloadCache { + static const _keyTaskTypes = 'preload_task_types'; + static const _keyTaskTypesTimestamp = 'preload_task_types_ts'; + static const _cacheDuration = Duration(hours: 12); + + // In-memory cache for current session + static Map> _taskTypesCache = {}; + + /// Preload all cacheable data during splash + /// Note: Task types require a business ID, so they're loaded on-demand per business + static Future preloadAll() async { + debugPrint('[PreloadCache] Preload cache initialized'); + // Future: Add any global preloads here + } + + /// Get or fetch task types for a business + static Future> getTaskTypes(int businessId) async { + // Check in-memory cache first + if (_taskTypesCache.containsKey(businessId)) { + return _taskTypesCache[businessId]!; + } + + // Check disk cache + final cached = await _loadTaskTypesFromDisk(businessId); + if (cached != null) { + _taskTypesCache[businessId] = cached; + // Refresh in background + _refreshTaskTypesInBackground(businessId); + return cached; + } + + // Fetch from server + final types = await Api.getTaskTypes(businessId: businessId); + _taskTypesCache[businessId] = types; + await _saveTaskTypesToDisk(businessId, types); + return types; + } + + /// Preload task types for a specific business (call when entering a business) + static Future preloadTaskTypes(int businessId) async { + if (_taskTypesCache.containsKey(businessId)) return; + + try { + final types = await Api.getTaskTypes(businessId: businessId); + _taskTypesCache[businessId] = types; + await _saveTaskTypesToDisk(businessId, types); + debugPrint('[PreloadCache] Preloaded ${types.length} task types for business $businessId'); + } catch (e) { + debugPrint('[PreloadCache] Failed to preload task types: $e'); + } + } + + static void _refreshTaskTypesInBackground(int businessId) { + Api.getTaskTypes(businessId: businessId).then((types) { + _taskTypesCache[businessId] = types; + _saveTaskTypesToDisk(businessId, types); + }).catchError((e) { + debugPrint('[PreloadCache] Task types refresh failed: $e'); + }); + } + + static Future?> _loadTaskTypesFromDisk(int businessId) async { + final prefs = await SharedPreferences.getInstance(); + final key = '${_keyTaskTypes}_$businessId'; + final tsKey = '${_keyTaskTypesTimestamp}_$businessId'; + + final ts = prefs.getInt(tsKey); + final data = prefs.getString(key); + + if (ts == null || data == null) return null; + if (DateTime.now().difference(DateTime.fromMillisecondsSinceEpoch(ts)) > _cacheDuration) { + return null; + } + + try { + final list = jsonDecode(data) as List; + return list.map((j) => TaskType.fromJson(j as Map)).toList(); + } catch (e) { + return null; + } + } + + static Future _saveTaskTypesToDisk(int businessId, List types) async { + final prefs = await SharedPreferences.getInstance(); + final key = '${_keyTaskTypes}_$businessId'; + final tsKey = '${_keyTaskTypesTimestamp}_$businessId'; + + final data = types.map((t) => { + 'tasktypeid': t.taskTypeId, + 'tasktypename': t.taskTypeName, + 'tasktypeicon': t.taskTypeIcon, + }).toList(); + + await prefs.setString(key, jsonEncode(data)); + await prefs.setInt(tsKey, DateTime.now().millisecondsSinceEpoch); + } + + /// Clear all caches + static Future clearAll() async { + _taskTypesCache.clear(); + final prefs = await SharedPreferences.getInstance(); + final keys = prefs.getKeys().where((k) => k.startsWith('preload_')); + for (final key in keys) { + await prefs.remove(key); + } + } +} diff --git a/lib/services/stripe_service.dart b/lib/services/stripe_service.dart index 7aa246d..1ec2ee4 100644 --- a/lib/services/stripe_service.dart +++ b/lib/services/stripe_service.dart @@ -56,10 +56,12 @@ class StripeService { /// Initialize Stripe with publishable key static Future initialize(String publishableKey) async { - if (_isInitialized) return; - - Stripe.publishableKey = publishableKey; - await Stripe.instance.applySettings(); + // Always update the key if it's different (allows switching between test/live) + if (Stripe.publishableKey != publishableKey) { + print('[Stripe] Updating publishable key to: ${publishableKey.substring(0, 20)}...'); + Stripe.publishableKey = publishableKey; + await Stripe.instance.applySettings(); + } _isInitialized = true; } diff --git a/lib/widgets/rescan_button.dart b/lib/widgets/rescan_button.dart new file mode 100644 index 0000000..068e0cf --- /dev/null +++ b/lib/widgets/rescan_button.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../app/app_router.dart'; +import '../app/app_state.dart'; +import '../services/api.dart'; +import '../services/beacon_scanner_service.dart'; + +/// A button that triggers a beacon rescan +/// Can be used anywhere in the app - will use the current business if available +class RescanButton extends StatefulWidget { + final bool showLabel; + final Color? iconColor; + + const RescanButton({ + super.key, + this.showLabel = false, + this.iconColor, + }); + + @override + State createState() => _RescanButtonState(); +} + +class _RescanButtonState extends State { + final _scanner = BeaconScannerService(); + bool _isScanning = false; + + Future _performRescan() async { + if (_isScanning) return; + + setState(() => _isScanning = true); + + final appState = context.read(); + final currentBusinessId = appState.selectedBusinessId; + + // Show scanning indicator + final scaffold = ScaffoldMessenger.of(context); + scaffold.showSnackBar( + const SnackBar( + content: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + SizedBox(width: 12), + Text('Scanning for your table...'), + ], + ), + duration: Duration(seconds: 3), + ), + ); + + try { + // Use current business for optimized scan if available + final result = await _scanner.scan(businessId: currentBusinessId); + + if (!mounted) return; + + scaffold.hideCurrentSnackBar(); + + if (result.foundBeacon) { + final beacon = result.bestBeacon!; + + // Check if it's the same business/table or a new one + // Also check if the beacon's business matches our parent (food court scenario) + final isSameLocation = (beacon.businessId == currentBusinessId && + beacon.servicePointId == appState.selectedServicePointId); + final isSameParentLocation = (appState.hasParentBusiness && + beacon.businessId == appState.parentBusinessId && + beacon.servicePointId == appState.selectedServicePointId); + + if (isSameLocation || isSameParentLocation) { + // Same location - just confirm + scaffold.showSnackBar( + SnackBar( + content: Text('Still at ${appState.selectedServicePointName ?? beacon.servicePointName}'), + duration: const Duration(seconds: 2), + ), + ); + } else { + // Different location - ask to switch + _showSwitchDialog(beacon); + } + } else if (result.beaconsFound > 0) { + scaffold.showSnackBar( + SnackBar( + content: Text('Found ${result.beaconsFound} beacon(s) but none registered'), + duration: const Duration(seconds: 2), + ), + ); + } else { + scaffold.showSnackBar( + const SnackBar( + content: Text('No beacons detected nearby'), + duration: Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + scaffold.hideCurrentSnackBar(); + scaffold.showSnackBar( + SnackBar( + content: Text('Scan failed: $e'), + duration: const Duration(seconds: 2), + ), + ); + } + } finally { + if (mounted) { + setState(() => _isScanning = false); + } + } + } + + void _showSwitchDialog(BeaconLookupResult beacon) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('New Location Detected'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('You appear to be at:'), + const SizedBox(height: 8), + Text( + beacon.businessName, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + if (beacon.servicePointName.isNotEmpty) + Text( + beacon.servicePointName, + style: TextStyle(color: Colors.grey.shade600), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Stay Here'), + ), + FilledButton( + onPressed: () { + Navigator.pop(context); + _switchToBeacon(beacon); + }, + child: const Text('Switch'), + ), + ], + ), + ); + } + + Future _switchToBeacon(BeaconLookupResult beacon) async { + final appState = context.read(); + + // Handle parent business (food court scenario) + if (beacon.hasChildren) { + try { + final children = await Api.getChildBusinesses(businessId: beacon.businessId); + if (!mounted) return; + + if (children.isNotEmpty) { + Navigator.of(context).pushReplacementNamed( + AppRoutes.businessSelector, + arguments: { + 'parentBusinessId': beacon.businessId, + 'parentBusinessName': beacon.businessName, + 'servicePointId': beacon.servicePointId, + 'servicePointName': beacon.servicePointName, + 'children': children, + }, + ); + return; + } + } catch (e) { + debugPrint('[Rescan] Error fetching children: $e'); + } + } + + // Single business - update state and navigate + appState.setBusinessAndServicePoint( + beacon.businessId, + beacon.servicePointId, + businessName: beacon.businessName, + servicePointName: beacon.servicePointName, + parentBusinessId: beacon.hasParent ? beacon.parentBusinessId : null, + parentBusinessName: beacon.hasParent ? beacon.parentBusinessName : null, + ); + appState.setOrderType(OrderType.dineIn); + Api.setBusinessId(beacon.businessId); + + if (!mounted) return; + + Navigator.of(context).pushReplacementNamed( + AppRoutes.menuBrowse, + arguments: { + 'businessId': beacon.businessId, + 'servicePointId': beacon.servicePointId, + }, + ); + } + + @override + Widget build(BuildContext context) { + if (widget.showLabel) { + return TextButton.icon( + onPressed: _isScanning ? null : _performRescan, + icon: _isScanning + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(Icons.bluetooth_searching, color: widget.iconColor), + label: Text( + _isScanning ? 'Scanning...' : 'Find My Table', + style: TextStyle(color: widget.iconColor), + ), + ); + } + + return IconButton( + icon: _isScanning + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(Icons.bluetooth_searching, color: widget.iconColor), + tooltip: 'Find My Table', + onPressed: _isScanning ? null : _performRescan, + ); + } +} diff --git a/lib/widgets/sign_in_dialog.dart b/lib/widgets/sign_in_dialog.dart new file mode 100644 index 0000000..5c9b032 --- /dev/null +++ b/lib/widgets/sign_in_dialog.dart @@ -0,0 +1,404 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../app/app_state.dart'; +import '../services/api.dart'; +import '../services/auth_storage.dart'; + +/// A dialog that handles phone number + OTP sign-in inline. +/// Returns true if sign-in was successful, false if cancelled. +class SignInDialog extends StatefulWidget { + const SignInDialog({super.key}); + + /// Shows the sign-in dialog and returns true if authenticated successfully + static Future show(BuildContext context) async { + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const SignInDialog(), + ); + return result ?? false; + } + + @override + State createState() => _SignInDialogState(); +} + +enum _SignInStep { phone, otp } + +class _SignInDialogState extends State { + _SignInStep _currentStep = _SignInStep.phone; + + final _phoneController = TextEditingController(); + final _otpController = TextEditingController(); + final _phoneFocus = FocusNode(); + final _otpFocus = FocusNode(); + + String _uuid = ''; + String _phone = ''; + + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + // Auto-focus the phone field when dialog opens + WidgetsBinding.instance.addPostFrameCallback((_) { + _phoneFocus.requestFocus(); + }); + } + + @override + void dispose() { + _phoneController.dispose(); + _otpController.dispose(); + _phoneFocus.dispose(); + _otpFocus.dispose(); + super.dispose(); + } + + String _formatPhoneNumber(String input) { + final digits = input.replaceAll(RegExp(r'[^\d]'), ''); + if (digits.length == 11 && digits.startsWith('1')) { + return digits.substring(1); + } + return digits; + } + + String _formatPhoneDisplay(String phone) { + if (phone.length == 10) { + return '(${phone.substring(0, 3)}) ${phone.substring(3, 6)}-${phone.substring(6)}'; + } + return phone; + } + + Future _handleSendOtp() async { + final phone = _formatPhoneNumber(_phoneController.text); + + if (phone.length != 10) { + setState(() { + _errorMessage = 'Please enter a valid 10-digit phone number'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final response = await Api.sendLoginOtp(phone: phone); + + if (!mounted) return; + + if (response.uuid.isEmpty) { + setState(() { + _errorMessage = 'Server error - please try again'; + _isLoading = false; + }); + return; + } + + setState(() { + _uuid = response.uuid; + _phone = phone; + _currentStep = _SignInStep.otp; + _isLoading = false; + }); + + // Auto-focus the OTP field + WidgetsBinding.instance.addPostFrameCallback((_) { + _otpFocus.requestFocus(); + }); + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = e.toString().replaceFirst('StateError: ', ''); + _isLoading = false; + }); + } + } + + Future _handleVerifyOtp() async { + if (_uuid.isEmpty) { + setState(() { + _errorMessage = 'Session expired. Please go back and try again.'; + }); + return; + } + + final otp = _otpController.text.trim(); + + if (otp.length != 6) { + setState(() { + _errorMessage = 'Please enter the 6-digit code'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final response = await Api.verifyLoginOtp(uuid: _uuid, otp: otp); + + if (!mounted) return; + + // Save credentials for persistent login + await AuthStorage.saveAuth( + userId: response.userId, + token: response.token, + ); + + // Update app state + final appState = context.read(); + appState.setUserId(response.userId); + + // Close dialog with success + Navigator.of(context).pop(true); + + // Show welcome message + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Welcome${response.userFirstName.isNotEmpty ? ', ${response.userFirstName}' : ''}!', + style: const TextStyle(color: Colors.black), + ), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), + ), + ); + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = e.toString().replaceFirst('StateError: ', ''); + _isLoading = false; + }); + } + } + + Future _handleResendOtp() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final response = await Api.sendLoginOtp(phone: _phone); + + if (!mounted) return; + + setState(() { + _uuid = response.uuid; + _isLoading = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('New code sent!', style: TextStyle(color: Colors.black)), + backgroundColor: Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + ), + ); + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = e.toString().replaceFirst('StateError: ', ''); + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Row( + children: [ + Expanded( + child: Text( + _currentStep == _SignInStep.phone ? 'Sign In to Continue' : 'Enter Code', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: _isLoading ? null : () => Navigator.of(context).pop(false), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 8), + Text( + _currentStep == _SignInStep.phone + ? 'Enter your phone number to add items to your cart' + : 'We sent a code to ${_formatPhoneDisplay(_phone)}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 24), + + // Form content + if (_currentStep == _SignInStep.phone) _buildPhoneStep(), + if (_currentStep == _SignInStep.otp) _buildOtpStep(), + + // Error message + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: Colors.red.shade600, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: Colors.red.shade800, fontSize: 13), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildPhoneStep() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _phoneController, + focusNode: _phoneFocus, + decoration: InputDecoration( + labelText: 'Phone Number', + hintText: '(555) 123-4567', + hintStyle: TextStyle(color: Colors.grey.shade400), + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.phone), + prefixText: '+1 ', + ), + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + ], + enabled: !_isLoading, + onFieldSubmitted: (_) => _handleSendOtp(), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: _isLoading ? null : _handleSendOtp, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text('Send Code'), + ), + ], + ); + } + + Widget _buildOtpStep() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _otpController, + focusNode: _otpFocus, + decoration: InputDecoration( + labelText: 'Verification Code', + hintText: '123456', + hintStyle: TextStyle(color: Colors.grey.shade400), + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.lock), + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 20, + letterSpacing: 6, + fontWeight: FontWeight.bold, + ), + enabled: !_isLoading, + onFieldSubmitted: (_) => _handleVerifyOtp(), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: _isLoading ? null : _handleVerifyOtp, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text('Verify & Continue'), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + onPressed: _isLoading ? null : _handleResendOtp, + child: const Text('Resend Code'), + ), + const SizedBox(width: 8), + TextButton( + onPressed: _isLoading + ? null + : () { + setState(() { + _currentStep = _SignInStep.phone; + _otpController.clear(); + _errorMessage = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + _phoneFocus.requestFocus(); + }); + }, + child: const Text('Change Number'), + ), + ], + ), + ], + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 825c3be..5f19397 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,13 @@ import FlutterMacOS import Foundation import file_selector_macos +import geolocator_apple import package_info_plus import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 77346a9..4c33ff4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -208,6 +216,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.4" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 + url: "https://pub.dev" + source: hosted + version: "13.0.4" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" http: dependency: "direct main" description: @@ -693,6 +749,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e7bf140..4192df6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: permission_handler: ^11.3.1 shared_preferences: ^2.2.3 dchs_flutter_beacon: ^0.6.6 + geolocator: ^13.0.2 flutter_stripe: ^11.4.0 image_picker: ^1.0.7 intl: ^0.19.0