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 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-23 19:51:54 -08:00
parent 5033e751ab
commit c4792189dd
27 changed files with 3004 additions and 1007 deletions

View file

@ -5,6 +5,7 @@ import "../screens/about_screen.dart";
import "../screens/address_edit_screen.dart"; import "../screens/address_edit_screen.dart";
import "../screens/address_list_screen.dart"; import "../screens/address_list_screen.dart";
import "../screens/beacon_scan_screen.dart"; import "../screens/beacon_scan_screen.dart";
import "../screens/business_selector_screen.dart";
import "../screens/signup_screen.dart"; import "../screens/signup_screen.dart";
import "../screens/cart_view_screen.dart"; import "../screens/cart_view_screen.dart";
import "../screens/group_order_invite_screen.dart"; import "../screens/group_order_invite_screen.dart";
@ -21,6 +22,7 @@ class AppRoutes {
static const String splash = "/"; static const String splash = "/";
static const String login = "/login"; static const String login = "/login";
static const String beaconScan = "/beacon-scan"; static const String beaconScan = "/beacon-scan";
static const String businessSelector = "/business-selector";
static const String orderTypeSelect = "/order-type"; static const String orderTypeSelect = "/order-type";
static const String groupOrderInvite = "/group-invite"; static const String groupOrderInvite = "/group-invite";
static const String restaurantSelect = "/restaurants"; static const String restaurantSelect = "/restaurants";
@ -38,6 +40,7 @@ class AppRoutes {
splash: (_) => const SplashScreen(), splash: (_) => const SplashScreen(),
login: (_) => const LoginScreen(), login: (_) => const LoginScreen(),
beaconScan: (_) => const BeaconScanScreen(), beaconScan: (_) => const BeaconScanScreen(),
businessSelector: (_) => const BusinessSelectorScreen(),
orderTypeSelect: (_) => const OrderTypeSelectScreen(), orderTypeSelect: (_) => const OrderTypeSelectScreen(),
groupOrderInvite: (_) => const GroupOrderInviteScreen(), groupOrderInvite: (_) => const GroupOrderInviteScreen(),
restaurantSelect: (_) => const RestaurantSelectScreen(), restaurantSelect: (_) => const RestaurantSelectScreen(),

View file

@ -12,6 +12,10 @@ class AppState extends ChangeNotifier {
int? _selectedServicePointId; int? _selectedServicePointId;
String? _selectedServicePointName; String? _selectedServicePointName;
// Parent business info (for back navigation when selected from business selector)
int? _parentBusinessId;
String? _parentBusinessName;
int? _userId; int? _userId;
OrderType? _orderType; OrderType? _orderType;
@ -25,11 +29,17 @@ class AppState extends ChangeNotifier {
List<int> _groupOrderInvites = []; List<int> _groupOrderInvites = [];
String? _brandColor;
int? get selectedBusinessId => _selectedBusinessId; int? get selectedBusinessId => _selectedBusinessId;
String? get selectedBusinessName => _selectedBusinessName; String? get selectedBusinessName => _selectedBusinessName;
int? get selectedServicePointId => _selectedServicePointId; int? get selectedServicePointId => _selectedServicePointId;
String? get selectedServicePointName => _selectedServicePointName; String? get selectedServicePointName => _selectedServicePointName;
int? get parentBusinessId => _parentBusinessId;
String? get parentBusinessName => _parentBusinessName;
bool get hasParentBusiness => _parentBusinessId != null;
int? get userId => _userId; int? get userId => _userId;
bool get isLoggedIn => _userId != null && _userId! > 0; bool get isLoggedIn => _userId != null && _userId! > 0;
@ -49,6 +59,8 @@ class AppState extends ChangeNotifier {
List<int> get groupOrderInvites => _groupOrderInvites; List<int> get groupOrderInvites => _groupOrderInvites;
bool get isGroupOrder => _groupOrderInvites.isNotEmpty; bool get isGroupOrder => _groupOrderInvites.isNotEmpty;
String? get brandColor => _brandColor;
bool get hasLocationSelection => bool get hasLocationSelection =>
_selectedBusinessId != null && _selectedServicePointId != null; _selectedBusinessId != null && _selectedServicePointId != null;
@ -78,11 +90,15 @@ class AppState extends ChangeNotifier {
int servicePointId, { int servicePointId, {
String? businessName, String? businessName,
String? servicePointName, String? servicePointName,
int? parentBusinessId,
String? parentBusinessName,
}) { }) {
_selectedBusinessId = businessId; _selectedBusinessId = businessId;
_selectedBusinessName = businessName; _selectedBusinessName = businessName;
_selectedServicePointId = servicePointId; _selectedServicePointId = servicePointId;
_selectedServicePointName = servicePointName; _selectedServicePointName = servicePointName;
_parentBusinessId = parentBusinessId;
_parentBusinessName = parentBusinessName;
_cartOrderId = null; _cartOrderId = null;
_cartOrderUuid = null; _cartOrderUuid = null;
@ -152,6 +168,11 @@ class AppState extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void setBrandColor(String? color) {
_brandColor = color;
notifyListeners();
}
void clearAll() { void clearAll() {
_selectedBusinessId = null; _selectedBusinessId = null;
_selectedServicePointId = null; _selectedServicePointId = null;

View file

@ -12,9 +12,9 @@ final GlobalKey<ScaffoldMessengerState> rootScaffoldMessengerKey =
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Initialize Stripe with test publishable key // Initialize Stripe with live publishable key (must match backend mode)
// This will be updated dynamically when processing payments if needed // Backend is in live mode - see api/config/stripe.cfm
Stripe.publishableKey = 'pk_test_sPBNzSyJ9HcEPJGC7dSo8NqN'; Stripe.publishableKey = 'pk_live_Wqj4yGmtTghVJu7oufnWmU5H';
runApp(const PayfritApp()); runApp(const PayfritApp());
} }

View file

@ -31,19 +31,19 @@ class MenuItem {
factory MenuItem.fromJson(Map<String, dynamic> json) { factory MenuItem.fromJson(Map<String, dynamic> json) {
return MenuItem( return MenuItem(
itemId: (json["ItemID"] as num).toInt(), itemId: (json["ItemID"] ?? json["itemid"] as num?)?.toInt() ?? 0,
categoryId: (json["ItemCategoryID"] as num).toInt(), categoryId: (json["ItemCategoryID"] ?? json["itemcategoryid"] as num?)?.toInt() ?? 0,
categoryName: (json["ItemCategoryName"] as String?) ?? "", categoryName: (json["ItemCategoryName"] ?? json["itemcategoryname"] as String?) ?? "",
name: (json["ItemName"] as String?) ?? "", name: (json["ItemName"] ?? json["itemname"] as String?) ?? "",
description: (json["ItemDescription"] as String?) ?? "", description: (json["ItemDescription"] ?? json["itemdescription"] as String?) ?? "",
parentItemId: (json["ItemParentItemID"] as num?)?.toInt() ?? 0, parentItemId: (json["ItemParentItemID"] ?? json["itemparentitemid"] as num?)?.toInt() ?? 0,
price: (json["ItemPrice"] as num?)?.toDouble() ?? 0.0, price: (json["ItemPrice"] ?? json["itemprice"] as num?)?.toDouble() ?? 0.0,
isActive: _parseBool(json["ItemIsActive"]), isActive: _parseBool(json["ItemIsActive"] ?? json["itemisactive"]),
isCheckedByDefault: _parseBool(json["ItemIsCheckedByDefault"]), isCheckedByDefault: _parseBool(json["ItemIsCheckedByDefault"] ?? json["itemischeckedbydefault"]),
requiresChildSelection: _parseBool(json["ItemRequiresChildSelection"]), requiresChildSelection: _parseBool(json["ItemRequiresChildSelection"] ?? json["itemrequireschildselection"]),
maxNumSelectionReq: (json["ItemMaxNumSelectionReq"] as num?)?.toInt() ?? 0, maxNumSelectionReq: (json["ItemMaxNumSelectionReq"] ?? json["itemmaxnumselectionreq"] as num?)?.toInt() ?? 0,
isCollapsible: _parseBool(json["ItemIsCollapsible"]), isCollapsible: _parseBool(json["ItemIsCollapsible"] ?? json["itemiscollapsible"]),
sortOrder: (json["ItemSortOrder"] as num?)?.toInt() ?? 0, sortOrder: (json["ItemSortOrder"] ?? json["itemsortorder"] as num?)?.toInt() ?? 0,
); );
} }

View file

@ -1,16 +1,27 @@
class Restaurant { class Restaurant {
final int businessId; final int businessId;
final String name; final String name;
final String city;
final String address;
final double? distanceMiles;
const Restaurant({ const Restaurant({
required this.businessId, required this.businessId,
required this.name, required this.name,
this.city = "",
this.address = "",
this.distanceMiles,
}); });
factory Restaurant.fromJson(Map<String, dynamic> json) { factory Restaurant.fromJson(Map<String, dynamic> json) {
return Restaurant( return Restaurant(
businessId: (json["BusinessID"] as num).toInt(), businessId: (json["BusinessID"] as num).toInt(),
name: (json["BusinessName"] as String?) ?? "Unnamed", 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,
); );
} }
} }

239
lib/models/task_type.dart Normal file
View file

@ -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<String, dynamic> 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<String, IconData> _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<String, Color> _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<String> 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;
}

View file

@ -117,8 +117,8 @@ class _AboutScreenState extends State<AboutScreen> {
_buildSectionHeader(context, 'Contact'), _buildSectionHeader(context, 'Contact'),
const SizedBox(height: 12), const SizedBox(height: 12),
ListTile( ListTile(
leading: const Icon(Icons.email_outlined), leading: const Icon(Icons.help_outline),
title: const Text('support@payfrit.com'), title: const Text('help.payfrit.com'),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
ListTile( ListTile(

View file

@ -6,6 +6,7 @@ import '../app/app_state.dart';
import '../app/app_router.dart'; import '../app/app_router.dart';
import '../services/api.dart'; import '../services/api.dart';
import '../services/auth_storage.dart'; import '../services/auth_storage.dart';
import '../widgets/rescan_button.dart';
class AccountScreen extends StatefulWidget { class AccountScreen extends StatefulWidget {
const AccountScreen({super.key}); const AccountScreen({super.key});
@ -326,6 +327,9 @@ class _AccountScreenState extends State<AccountScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Account'), title: const Text('Account'),
actions: const [
RescanButton(),
],
), ),
body: Center( body: Center(
child: Padding( child: Padding(
@ -373,6 +377,9 @@ class _AccountScreenState extends State<AccountScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Account'), title: const Text('Account'),
actions: const [
RescanButton(),
],
), ),
body: ListView( body: ListView(
children: [ children: [

View file

@ -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<BusinessSelectorScreen> createState() => _BusinessSelectorScreenState();
}
class _BusinessSelectorScreenState extends State<BusinessSelectorScreen> {
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<void> _initializeData() async {
final args = ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>?;
final BeaconBusinessMapping? mapping = args?["mapping"] as BeaconBusinessMapping?;
final List<ChildBusiness>? children = args?["children"] as List<ChildBusiness>?;
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<AppState>();
// 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,
),
],
),
),
],
),
),
),
);
}
}

View file

@ -8,6 +8,7 @@ import '../models/menu_item.dart';
import '../services/api.dart'; import '../services/api.dart';
import '../services/order_polling_service.dart'; import '../services/order_polling_service.dart';
import '../services/stripe_service.dart'; import '../services/stripe_service.dart';
import '../widgets/rescan_button.dart';
/// Helper class to store modifier breadcrumb paths /// Helper class to store modifier breadcrumb paths
class ModifierPath { class ModifierPath {
@ -160,8 +161,8 @@ class _CartViewScreenState extends State<CartViewScreen> {
// Use cart's businessId if appState doesn't have one (e.g., delivery/takeaway flow) // Use cart's businessId if appState doesn't have one (e.g., delivery/takeaway flow)
final businessId = appState.selectedBusinessId ?? cart.businessId; final businessId = appState.selectedBusinessId ?? cart.businessId;
if (businessId > 0) { if (businessId > 0) {
final menuItems = await Api.listMenuItems(businessId: businessId); final result = await Api.listMenuItems(businessId: businessId);
_menuItemsById = {for (var item in menuItems) item.itemId: item}; _menuItemsById = {for (var item in result.items) item.itemId: item};
} }
setState(() { setState(() {
@ -540,6 +541,9 @@ class _CartViewScreenState extends State<CartViewScreen> {
title: const Text("Cart"), title: const Text("Cart"),
backgroundColor: Colors.black, backgroundColor: Colors.black,
foregroundColor: Colors.white, foregroundColor: Colors.white,
actions: const [
RescanButton(iconColor: Colors.white),
],
), ),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())

View file

@ -8,15 +8,25 @@ import '../services/chat_service.dart';
import '../services/auth_storage.dart'; import '../services/auth_storage.dart';
class ChatScreen extends StatefulWidget { 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 userType; // 'customer' or 'worker'
final String? otherPartyName; 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({ const ChatScreen({
super.key, super.key,
required this.taskId, this.taskId,
required this.userType, required this.userType,
this.otherPartyName, this.otherPartyName,
this.businessId,
this.servicePointId,
this.orderId,
this.userId,
}); });
@override @override
@ -28,6 +38,7 @@ class _ChatScreenState extends State<ChatScreen> {
final TextEditingController _messageController = TextEditingController(); final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final List<ChatMessage> _messages = []; final List<ChatMessage> _messages = [];
final List<String> _pendingMessages = []; // Messages queued before task created
bool _isLoading = true; bool _isLoading = true;
bool _isConnecting = false; bool _isConnecting = false;
@ -36,6 +47,8 @@ class _ChatScreenState extends State<ChatScreen> {
String? _otherUserName; String? _otherUserName;
String? _error; String? _error;
bool _chatEnded = false; bool _chatEnded = false;
bool _isCreatingTask = false; // True while creating task in background
int? _taskId; // Actual task ID (may be null initially)
StreamSubscription<ChatMessage>? _messageSubscription; StreamSubscription<ChatMessage>? _messageSubscription;
StreamSubscription<TypingEvent>? _typingSubscription; StreamSubscription<TypingEvent>? _typingSubscription;
@ -47,16 +60,70 @@ class _ChatScreenState extends State<ChatScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_otherUserName = widget.otherPartyName; _otherUserName = widget.otherPartyName;
_taskId = widget.taskId;
_initializeChat(); _initializeChat();
} }
Future<void> _initializeChat() async { Future<void> _initializeChat() async {
// Ensure auth is loaded first before any API calls // Ensure auth is loaded first before any API calls
await _ensureAuth(); await _ensureAuth();
// 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 // Then load messages and connect
await _loadMessages(); await _loadMessages();
_connectToChat(); _connectToChat();
} }
}
Future<void> _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<void> _sendPendingMessages() async {
if (_pendingMessages.isEmpty || _taskId == null) return;
final messages = List<String>.from(_pendingMessages);
_pendingMessages.clear();
for (final text in messages) {
await _sendMessageText(text);
}
}
Future<void> _ensureAuth() async { Future<void> _ensureAuth() async {
if (Api.authToken == null || Api.authToken!.isEmpty) { if (Api.authToken == null || Api.authToken!.isEmpty) {
@ -81,13 +148,15 @@ class _ChatScreenState extends State<ChatScreen> {
} }
Future<void> _loadMessages() async { Future<void> _loadMessages() async {
if (_taskId == null) return;
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_error = null; _error = null;
}); });
try { try {
final result = await Api.getChatMessages(taskId: widget.taskId); final result = await Api.getChatMessages(taskId: _taskId!);
if (mounted) { if (mounted) {
final wasClosed = result.chatClosed && !_chatEnded; final wasClosed = result.chatClosed && !_chatEnded;
setState(() { setState(() {
@ -138,7 +207,7 @@ class _ChatScreenState extends State<ChatScreen> {
// Mark as read if from the other party // Mark as read if from the other party
if (message.senderType != widget.userType) { if (message.senderType != widget.userType) {
Api.markChatMessagesRead( Api.markChatMessagesRead(
taskId: widget.taskId, taskId: _taskId!,
readerType: widget.userType, readerType: widget.userType,
); );
} }
@ -214,7 +283,7 @@ class _ChatScreenState extends State<ChatScreen> {
}); });
final connected = await _chatService.connect( final connected = await _chatService.connect(
taskId: widget.taskId, taskId: _taskId!,
userToken: token, userToken: token,
userType: widget.userType, userType: widget.userType,
); );
@ -237,15 +306,17 @@ class _ChatScreenState extends State<ChatScreen> {
} }
Future<void> _pollNewMessages() async { Future<void> _pollNewMessages() async {
if (_taskId == null) return;
try { try {
final lastMessageId = _messages.isNotEmpty ? _messages.last.messageId : 0; final lastMessageId = _messages.isNotEmpty ? _messages.last.messageId : 0;
final result = await Api.getChatMessages( final result = await Api.getChatMessages(
taskId: widget.taskId, taskId: _taskId!,
afterMessageId: lastMessageId, afterMessageId: lastMessageId,
); );
if (mounted) { 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) { if (result.chatClosed && !_chatEnded) {
setState(() { setState(() {
_chatEnded = true; _chatEnded = true;
@ -253,7 +324,7 @@ class _ChatScreenState extends State<ChatScreen> {
_pollTimer?.cancel(); _pollTimer?.cancel();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( 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), backgroundColor: const Color(0xFF90EE90),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
@ -307,18 +378,43 @@ class _ChatScreenState extends State<ChatScreen> {
final text = _messageController.text.trim(); final text = _messageController.text.trim();
if (text.isEmpty || _isSending || _chatEnded) return; if (text.isEmpty || _isSending || _chatEnded) return;
setState(() => _isSending = true); _messageController.clear();
_chatService.setTyping(false); _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<void> _sendMessageText(String text) async {
if (_taskId == null) return;
setState(() => _isSending = true);
try { try {
bool sentViaWebSocket = false; bool sentViaWebSocket = false;
if (_chatService.isConnected) { if (_chatService.isConnected) {
// Try to send via WebSocket // Try to send via WebSocket
sentViaWebSocket = _chatService.sendMessage(text); sentViaWebSocket = _chatService.sendMessage(text);
if (sentViaWebSocket) {
_messageController.clear();
}
} }
if (!sentViaWebSocket) { if (!sentViaWebSocket) {
@ -331,12 +427,11 @@ class _ChatScreenState extends State<ChatScreen> {
} }
await Api.sendChatMessage( await Api.sendChatMessage(
taskId: widget.taskId, taskId: _taskId!,
message: text, message: text,
userId: userId, userId: userId,
senderType: widget.userType, senderType: widget.userType,
); );
_messageController.clear();
// Refresh messages since we used HTTP // Refresh messages since we used HTTP
await _loadMessages(); await _loadMessages();
@ -344,6 +439,21 @@ class _ChatScreenState extends State<ChatScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
final message = e.toString().replaceAll('StateError: ', ''); final message = e.toString().replaceAll('StateError: ', '');
// 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( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to send: $message'), content: Text('Failed to send: $message'),
@ -351,6 +461,7 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
); );
} }
}
} finally { } finally {
if (mounted) { if (mounted) {
setState(() => _isSending = false); setState(() => _isSending = false);
@ -462,6 +573,33 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
body: Column( body: Column(
children: [ 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) if (_chatEnded)
Container( Container(
width: double.infinity, width: double.infinity,

View file

@ -180,10 +180,23 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () async {
Navigator.of(context).pop(); Navigator.of(context).pop();
// Start fresh - go to restaurant select // Abandon the old order and stay at the same business with a clean cart
Navigator.of(this.context).pushReplacementNamed(AppRoutes.restaurantSelect); try {
await Api.abandonOrder(orderId: cart.orderId);
} catch (e) {
// Ignore - proceed anyway
}
final appState = this.context.read<AppState>();
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"), child: const Text("Start Fresh"),
), ),

View file

@ -5,8 +5,12 @@ import "../app/app_router.dart";
import "../app/app_state.dart"; import "../app/app_state.dart";
import "../models/cart.dart"; import "../models/cart.dart";
import "../models/menu_item.dart"; import "../models/menu_item.dart";
import "../models/task_type.dart";
import "../services/api.dart"; import "../services/api.dart";
import "../services/auth_storage.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"; import "chat_screen.dart";
class MenuBrowseScreen extends StatefulWidget { class MenuBrowseScreen extends StatefulWidget {
@ -31,6 +35,12 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
// Track which category is currently expanded (null = none) // Track which category is currently expanded (null = none)
int? _expandedCategoryId; int? _expandedCategoryId;
// Task types for bell icon - fetched on load
List<TaskType> _taskTypes = [];
// Brand color for HUD gradients (decorative, from business settings)
Color _brandColor = const Color(0xFF1B4D3E); // Default forest green
int? _asIntNullable(dynamic v) { int? _asIntNullable(dynamic v) {
if (v == null) return null; if (v == null) return null;
if (v is int) return v; if (v is int) return v;
@ -60,9 +70,17 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
final u = appState.userId; final u = appState.userId;
final args = ModalRoute.of(context)?.settings.arguments; final args = ModalRoute.of(context)?.settings.arguments;
int? b;
int? sp;
if (args is Map) { if (args is Map) {
final b = _asIntNullable(args["BusinessID"]) ?? _asIntNullable(args["businessId"]); b = _asIntNullable(args["BusinessID"]) ?? _asIntNullable(args["businessId"]);
final sp = _asIntNullable(args["ServicePointID"]) ?? _asIntNullable(args["servicePointId"]); sp = _asIntNullable(args["ServicePointID"]) ?? _asIntNullable(args["servicePointId"]);
}
// Fall back to AppState if args don't have businessId
b ??= appState.selectedBusinessId;
sp ??= appState.selectedServicePointId;
if (_businessId != b || _servicePointId != sp || _userId != u || _future == null) { if (_businessId != b || _servicePointId != sp || _userId != u || _future == null) {
_businessId = b; _businessId = b;
@ -76,37 +94,114 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
} }
} }
} }
}
Future<List<MenuItem>> _loadMenu() async { Future<List<MenuItem>> _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<AppState>();
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<AppState>().setBrandColor(result.brandColor);
}
} catch (_) {
// Keep default color on parse error
}
}
setState(() { setState(() {
_allItems = items; _allItems = result.items;
_organizeItems(); _organizeItems();
}); });
return items; return result.items;
} }
bool _isCallingServer = false; 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<void> _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<void> _handleCallServer(AppState appState) async { Future<void> _handleCallServer(AppState appState) async {
if (_businessId == null) return; if (_businessId == null) return;
// For non-dine-in without a service point, use 0 as placeholder // For non-dine-in without a service point, use 0 as placeholder
final servicePointId = _servicePointId ?? 0; 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; int? activeTaskId;
List<TaskType> taskTypes = _taskTypes;
try { try {
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<int?>((v) => v)
.catchError((e) {
debugPrint('[MenuBrowse] getActiveChat error: $e');
return null;
}),
]);
taskTypes = results[0] as List<TaskType>;
activeTaskId = results[1] as int?;
} else {
// Task types preloaded, just check for active chat
activeTaskId = await Api.getActiveChat( activeTaskId = await Api.getActiveChat(
businessId: _businessId!, businessId: _businessId!,
servicePointId: servicePointId, servicePointId: servicePointId,
); userId: userId,
).catchError((e) {
debugPrint('[MenuBrowse] getActiveChat error: $e');
return null;
});
}
debugPrint('[MenuBrowse] Got ${taskTypes.length} task types, activeTaskId=$activeTaskId');
} catch (e) { } 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; if (!mounted) return;
@ -138,25 +233,39 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Only show "Request Server Visit" for dine-in orders // Build task type options dynamically
if (isDineIn && _servicePointId != null) ...[ ..._buildTaskTypeOptions(taskTypes, appState, isDineIn, activeTaskId),
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( ),
);
}
/// Build list tiles for each task type
List<Widget> _buildTaskTypeOptions(
List<TaskType> taskTypes,
AppState appState,
bool isDineIn,
int? activeTaskId,
) {
debugPrint('[MenuBrowse] _buildTaskTypeOptions called with ${taskTypes.length} task types, activeTaskId=$activeTaskId');
final widgets = <Widget>[];
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( leading: const CircleAvatar(
backgroundColor: Colors.green, backgroundColor: Colors.green,
child: Icon(Icons.chat_bubble, color: Colors.white), child: Icon(Icons.chat_bubble, color: Colors.white),
@ -164,28 +273,112 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
title: const Text('Rejoin Chat'), title: const Text('Rejoin Chat'),
subtitle: const Text('Continue your conversation'), subtitle: const Text('Continue your conversation'),
onTap: () { onTap: () {
debugPrint('[MenuBrowse] Rejoin tapped');
Navigator.pop(context); Navigator.pop(context);
_rejoinChat(activeTaskId!); _rejoinChat(activeTaskId);
}, },
) ));
else } else {
ListTile( debugPrint('[MenuBrowse] Showing START option');
leading: const CircleAvatar( widgets.add(ListTile(
backgroundColor: Colors.blue, leading: CircleAvatar(
child: Icon(Icons.chat, color: Colors.white), backgroundColor: taskType.iconColor,
child: Icon(taskType.icon, color: Colors.white),
), ),
title: const Text('Chat with Staff'), title: Text(taskType.taskTypeName),
subtitle: const Text('Send a message to our team'), subtitle: Text(taskType.taskTypeDescription.isNotEmpty
? taskType.taskTypeDescription
: 'Send a message to our team'),
onTap: () { onTap: () {
debugPrint('[MenuBrowse] Start chat tapped');
Navigator.pop(context); Navigator.pop(context);
_startChat(appState); _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<void> _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 /// Check if user is logged in, prompt login if not
@ -226,8 +419,13 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
/// Rejoin an existing active chat /// Rejoin an existing active chat
Future<void> _rejoinChat(int taskId) async { Future<void> _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; if (!mounted) return;
Navigator.push( Navigator.push(
context, context,
@ -240,115 +438,56 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
); );
} }
/// Send a server visit request (ping)
Future<void> _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 /// Start a new chat with staff
Future<void> _startChat(AppState appState) async { Future<void> _startChat(AppState appState) async {
if (_isCallingServer) return; if (_isCallingServer) return;
// Check we have required info - businessId is always required
if (_businessId == null) {
debugPrint('[MenuBrowse] Cannot start chat - missing businessId');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Unable to start chat - please try again'),
backgroundColor: Colors.orange,
),
);
}
return;
}
// Check login first // Check login first
if (!await _ensureLoggedIn()) return; if (!await _ensureLoggedIn()) {
debugPrint('[MenuBrowse] Login cancelled or failed');
return;
}
setState(() => _isCallingServer = true); // Get userId for chat screen
try {
// Reload auth to get userId
final auth = await AuthStorage.loadAuth(); final auth = await AuthStorage.loadAuth();
final userId = auth?.userId; final userId = auth?.userId;
// Create new chat // For non-dine-in users without a service point, use 0
final taskId = await Api.createChatTask( final servicePointId = _servicePointId ?? 0;
businessId: _businessId!,
servicePointId: _servicePointId!, debugPrint('[MenuBrowse] Starting chat immediately: businessId=$_businessId, servicePointId=$servicePointId, userId=$userId, isDineIn=${appState.isDineIn}');
orderId: appState.cartOrderId,
userId: userId,
);
if (!mounted) return; if (!mounted) return;
// Navigate to chat screen // Navigate to chat screen IMMEDIATELY - let it create the task in background
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => ChatScreen( builder: (context) => ChatScreen(
taskId: taskId, taskId: null, // Task will be created by ChatScreen
userType: 'customer', userType: 'customer',
businessId: _businessId,
servicePointId: servicePointId,
orderId: appState.cartOrderId,
userId: userId,
), ),
), ),
); );
} catch (e) { debugPrint('[MenuBrowse] Navigated to ChatScreen (task will be created in background)');
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 {
if (mounted) {
setState(() => _isCallingServer = false);
}
}
} }
void _organizeItems() { void _organizeItems() {
@ -411,45 +550,26 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Row( leading: IconButton(
children: [ icon: const Icon(Icons.arrow_back),
// Business logo onPressed: () {
if (_businessId != null) // If this business has a parent, go back to business selector
Padding( if (appState.hasParentBusiness) {
padding: const EdgeInsets.only(right: 12), Navigator.of(context).pushReplacementNamed(
child: ClipRRect( AppRoutes.businessSelector,
borderRadius: BorderRadius.circular(6), arguments: {
child: SizedBox( "parentBusinessId": appState.parentBusinessId,
width: 36, "parentBusinessName": appState.parentBusinessName,
height: 36, "servicePointId": appState.selectedServicePointId,
child: Image.network( "servicePointName": appState.selectedServicePointName,
"$_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,
),
);
}, },
); );
} else {
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
}
}, },
), ),
), title: Column(
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -468,14 +588,13 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
), ),
], ],
), ),
),
],
),
actions: [ 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( IconButton(
icon: Icon(appState.isDineIn ? Icons.room_service : Icons.chat_bubble_outline), icon: Icon(appState.isDineIn ? Icons.notifications_active : Icons.chat_bubble_outline),
tooltip: appState.isDineIn ? "Call Server" : "Chat", tooltip: appState.isDineIn ? "Call Server" : "Chat With Staff",
onPressed: () => _handleCallServer(appState), onPressed: () => _handleCallServer(appState),
), ),
IconButton( IconButton(
@ -561,35 +680,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
color: const Color(0xFFF0F0F0), color: const Color(0xFFF0F0F0),
child: Column( child: Column(
children: [ 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)), ...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,27 +710,27 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
return Container( return Container(
width: double.infinity, width: double.infinity,
height: 180, height: 180,
child: Stack( color: _brandColor, // Fill empty space with business brand color
fit: StackFit.expand, child: Center(
children: [ child: Image.network(
// Header background image
Image.network(
"$_imageBaseUrl/headers/$_businessId.png", "$_imageBaseUrl/headers/$_businessId.png",
fit: BoxFit.cover, fit: BoxFit.fitWidth,
width: double.infinity,
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
return Image.network( return Image.network(
"$_imageBaseUrl/headers/$_businessId.jpg", "$_imageBaseUrl/headers/$_businessId.jpg",
fit: BoxFit.cover, fit: BoxFit.fitWidth,
width: double.infinity,
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
// No header image - show gradient background // No header image - show gradient with brand color
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [
Theme.of(context).colorScheme.primary, _brandColor,
Theme.of(context).colorScheme.secondary, _brandColor.withAlpha(200),
], ],
), ),
), ),
@ -648,45 +739,6 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
); );
}, },
), ),
// 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<MenuBrowseScreen> {
children: [ children: [
// Category image background or styled text fallback // Category image background or styled text fallback
_buildCategoryBackground(categoryId, categoryName), _buildCategoryBackground(categoryId, categoryName),
// Top edge gradient (subtle forest green) // Top edge gradient (brand color)
Positioned( Positioned(
top: 0, top: 0,
left: 0, left: 0,
@ -812,14 +864,14 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
const Color(0xFF1B4D3E).withAlpha(120), _brandColor.withAlpha(150),
const Color(0xFF1B4D3E).withAlpha(0), _brandColor.withAlpha(0),
], ],
), ),
), ),
), ),
), ),
// Bottom edge gradient (subtle forest green) // Bottom edge gradient (brand color)
Positioned( Positioned(
bottom: 0, bottom: 0,
left: 0, left: 0,
@ -831,8 +883,46 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
const Color(0xFF1B4D3E).withAlpha(0), _brandColor.withAlpha(0),
const Color(0xFF1B4D3E).withAlpha(150), _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<MenuBrowseScreen> {
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
onTap: () { onTap: () => _showItemCustomization(item),
if (hasModifiers) {
_showItemCustomization(item);
} else {
_addToCart(item, {});
}
},
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Row( child: Row(
@ -964,31 +1048,24 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
} }
Future<void> _addToCart(MenuItem item, Set<int> selectedModifierIds, {int quantity = 1}) async { Future<void> _addToCart(MenuItem item, Set<int> 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) { if (_userId == null) {
final shouldLogin = await showDialog<bool>( final signedIn = await SignInDialog.show(context);
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"),
),
],
),
);
if (shouldLogin == true && mounted) { if (!mounted) return;
Navigator.of(context).pushNamed(AppRoutes.login);
} if (signedIn) {
// Refresh user ID from app state after successful sign-in
final appState = context.read<AppState>();
setState(() {
_userId = appState.userId;
});
// Continue with adding to cart below
} else {
// User cancelled sign-in
return; return;
} }
}
if (_businessId == null || _servicePointId == null) { if (_businessId == null || _servicePointId == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -1065,7 +1142,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
(li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted (li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted
).firstOrNull; ).firstOrNull;
final newQuantity = (existingItem?.quantity ?? 0) + 1; final newQuantity = (existingItem?.quantity ?? 0) + quantity;
cart = await Api.setLineItem( cart = await Api.setLineItem(
orderId: cart.orderId, orderId: cart.orderId,

View file

@ -3,6 +3,7 @@ import 'package:intl/intl.dart';
import '../models/order_detail.dart'; import '../models/order_detail.dart';
import '../services/api.dart'; import '../services/api.dart';
import '../widgets/rescan_button.dart';
class OrderDetailScreen extends StatefulWidget { class OrderDetailScreen extends StatefulWidget {
final int orderId; final int orderId;
@ -54,6 +55,9 @@ class _OrderDetailScreenState extends State<OrderDetailScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Order #${widget.orderId}'), title: Text('Order #${widget.orderId}'),
actions: const [
RescanButton(),
],
), ),
body: _buildBody(), body: _buildBody(),
); );

View file

@ -3,6 +3,7 @@ import 'package:intl/intl.dart';
import '../models/order_history.dart'; import '../models/order_history.dart';
import '../services/api.dart'; import '../services/api.dart';
import '../widgets/rescan_button.dart';
import 'order_detail_screen.dart'; import 'order_detail_screen.dart';
class OrderHistoryScreen extends StatefulWidget { class OrderHistoryScreen extends StatefulWidget {
@ -55,6 +56,9 @@ class _OrderHistoryScreenState extends State<OrderHistoryScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Order History'), title: const Text('Order History'),
actions: const [
RescanButton(),
],
), ),
body: _buildBody(), body: _buildBody(),
); );

View file

@ -2,12 +2,14 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:provider/provider.dart"; import "package:provider/provider.dart";
import "package:geolocator/geolocator.dart";
import "../app/app_state.dart"; import "../app/app_state.dart";
import "../models/menu_item.dart"; import "../models/menu_item.dart";
import "../models/restaurant.dart"; import "../models/restaurant.dart";
import "../models/service_point.dart"; import "../models/service_point.dart";
import "../services/api.dart"; import "../services/api.dart";
import "../widgets/rescan_button.dart";
class RestaurantSelectScreen extends StatefulWidget { class RestaurantSelectScreen extends StatefulWidget {
const RestaurantSelectScreen({super.key}); const RestaurantSelectScreen({super.key});
@ -34,7 +36,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_restaurantsFuture = _loadRestaurants(); _restaurantsFuture = _loadRestaurantsWithLocation();
// Clear order type when arriving at restaurant select (no beacon = not dine-in) // 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 // This ensures the table change icon doesn't appear for delivery/takeaway orders
@ -44,11 +46,32 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
}); });
} }
Future<List<Restaurant>> _loadRestaurants() async { Future<List<Restaurant>> _loadRestaurantsWithLocation() async {
final raw = await Api.listRestaurantsRaw(); 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; _debugLastRaw = raw.rawBody;
_debugLastStatus = raw.statusCode; _debugLastStatus = raw.statusCode;
return Api.listRestaurants(); return Api.listRestaurants(lat: lat, lng: lng);
} }
Future<void> _loadMenuForBusiness(int businessId) async { Future<void> _loadMenuForBusiness(int businessId) async {
@ -113,7 +136,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
Api.setBusinessId(restaurant.businessId); Api.setBusinessId(restaurant.businessId);
// Navigate to full menu browse screen // Navigate to full menu browse screen
Navigator.of(context).pushReplacementNamed( Navigator.of(context).pushNamed(
'/menu_browse', '/menu_browse',
arguments: { arguments: {
'businessId': restaurant.businessId, 'businessId': restaurant.businessId,
@ -122,15 +145,48 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
); );
} }
Future<bool> _onWillPop() async {
final shouldExit = await showDialog<bool>(
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( 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, backgroundColor: Colors.black,
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.black, backgroundColor: Colors.black,
foregroundColor: Colors.white, foregroundColor: Colors.white,
title: const Text("Nearby Restaurants"), title: const Text("Nearby Restaurants"),
elevation: 0, elevation: 0,
actions: const [
RescanButton(iconColor: Colors.white),
],
), ),
body: FutureBuilder<List<Restaurant>>( body: FutureBuilder<List<Restaurant>>(
future: _restaurantsFuture, future: _restaurantsFuture,
@ -147,7 +203,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
message: snapshot.error.toString(), message: snapshot.error.toString(),
statusCode: _debugLastStatus, statusCode: _debugLastStatus,
raw: _debugLastRaw, raw: _debugLastRaw,
onRetry: () => setState(() => _restaurantsFuture = _loadRestaurants()), onRetry: () => setState(() => _restaurantsFuture = _loadRestaurantsWithLocation()),
); );
} }
@ -158,7 +214,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
message: "No Payfrit restaurants nearby.", message: "No Payfrit restaurants nearby.",
statusCode: _debugLastStatus, statusCode: _debugLastStatus,
raw: _debugLastRaw, raw: _debugLastRaw,
onRetry: () => setState(() => _restaurantsFuture = _loadRestaurants()), onRetry: () => setState(() => _restaurantsFuture = _loadRestaurantsWithLocation()),
); );
} }
@ -182,6 +238,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
); );
}, },
), ),
),
); );
} }
} }
@ -205,186 +262,119 @@ class _RestaurantBar extends StatelessWidget {
required this.imageBaseUrl, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
// Restaurant header bar with logo // Restaurant card with header image (matches business selector style)
GestureDetector( GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
height: 80, height: 120,
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
color: isExpanded boxShadow: [
? Theme.of(context).colorScheme.primaryContainer BoxShadow(
: Colors.grey.shade900, color: Colors.black.withAlpha(100),
blurRadius: 8,
offset: const Offset(0, 4),
), ),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack( child: Stack(
children: [ children: [
// Background header image (subtle) - ignorePointer so taps go through // Header image background
IgnorePointer( Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Opacity(
opacity: 0.3,
child: SizedBox(
width: double.infinity,
height: 80,
child: Image.network( child: Image.network(
"$imageBaseUrl/headers/${restaurant.businessId}.png", "$imageBaseUrl/headers/${restaurant.businessId}.png",
fit: BoxFit.cover, fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
return Image.network( return Image.network(
"$imageBaseUrl/headers/${restaurant.businessId}.jpg", "$imageBaseUrl/headers/${restaurant.businessId}.jpg",
fit: BoxFit.cover, fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
return const SizedBox.shrink(); // Fallback to logo centered
}, return Container(
); color: Colors.grey.shade800,
}, child: Center(
),
),
),
),
),
// 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( child: Image.network(
"$imageBaseUrl/logos/${restaurant.businessId}.png", "$imageBaseUrl/logos/${restaurant.businessId}.png",
fit: BoxFit.cover, width: 60,
gaplessPlayback: true, height: 60,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { fit: BoxFit.contain,
// Show placeholder immediately, image loads on top
return Stack(
children: [
_buildLogoPlaceholder(context),
if (frame != null) child,
],
);
},
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
return Image.network( return Image.network(
"$imageBaseUrl/logos/${restaurant.businessId}.jpg", "$imageBaseUrl/logos/${restaurant.businessId}.jpg",
fit: BoxFit.cover, width: 60,
gaplessPlayback: true, height: 60,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { fit: BoxFit.contain,
return Stack(
children: [
_buildLogoPlaceholder(context),
if (frame != null) child,
],
);
},
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
return _buildLogoPlaceholder(context); return Text(
restaurant.name.isNotEmpty ? restaurant.name[0].toUpperCase() : "?",
style: const TextStyle(
color: Colors.white54,
fontSize: 36,
fontWeight: FontWeight.bold,
),
);
}, },
); );
}, },
), ),
), ),
);
},
);
},
), ),
const SizedBox(width: 16), ),
// Name // 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( Expanded(
child: Text( child: Text(
restaurant.name, restaurant.name,
style: TextStyle( style: const TextStyle(
color: isExpanded color: Colors.white,
? Theme.of(context).colorScheme.onPrimaryContainer fontSize: 20,
: Colors.white, fontWeight: FontWeight.bold,
fontSize: 18, shadows: [
fontWeight: FontWeight.w600, Shadow(
offset: Offset(0, 1),
blurRadius: 3,
color: Colors.black54,
),
],
), ),
), ),
), ),
const Icon(
Icons.arrow_forward_ios,
color: Colors.white70,
size: 20,
),
], ],
), ),
), ),
@ -392,6 +382,7 @@ class _RestaurantBar extends StatelessWidget {
), ),
), ),
), ),
),
// Expanded menu content // Expanded menu content
AnimatedCrossFade( AnimatedCrossFade(
@ -560,7 +551,7 @@ class _CategorySection extends StatelessWidget {
Api.setBusinessId(restaurant.businessId); Api.setBusinessId(restaurant.businessId);
// Navigate to full menu browse screen // Navigate to full menu browse screen
Navigator.of(context).pushReplacementNamed( Navigator.of(context).pushNamed(
'/menu_browse', '/menu_browse',
arguments: { arguments: {
'businessId': restaurant.businessId, 'businessId': restaurant.businessId,

View file

@ -6,10 +6,11 @@ import "package:dchs_flutter_beacon/dchs_flutter_beacon.dart";
import "../app/app_router.dart"; import "../app/app_router.dart";
import "../app/app_state.dart"; import "../app/app_state.dart";
import "../models/cart.dart";
import "../services/api.dart"; import "../services/api.dart";
import "../services/auth_storage.dart"; import "../services/auth_storage.dart";
import "../services/beacon_cache.dart";
import "../services/beacon_permissions.dart"; import "../services/beacon_permissions.dart";
import "../services/preload_cache.dart";
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@ -19,6 +20,9 @@ class SplashScreen extends StatefulWidget {
} }
class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMixin { class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMixin {
// Track if permissions were freshly granted (needs Bluetooth warmup delay)
bool _permissionsWereFreshlyGranted = false;
// Bouncing logo animation // Bouncing logo animation
late AnimationController _bounceController; late AnimationController _bounceController;
double _x = 100; double _x = 100;
@ -40,25 +44,11 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
"connecting...", "connecting...",
]; ];
// Beacon scanning state // Beacon scanning state - new approach: scan all, then lookup
Map<String, int> _uuidToBeaconId = {};
final Map<String, List<int>> _beaconRssiSamples = {}; final Map<String, List<int>> _beaconRssiSamples = {};
final Map<String, int> _beaconDetectionCount = {}; final Map<String, int> _beaconDetectionCount = {};
bool _scanComplete = false; bool _scanComplete = false;
BeaconResult? _bestBeacon; BeaconLookupResult? _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;
static const List<Color> _colors = [ static const List<Color> _colors = [
Colors.white, Colors.white,
@ -76,9 +66,6 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
super.initState(); super.initState();
print('[Splash] 🚀 Starting with bouncing logo + beacon scan'); print('[Splash] 🚀 Starting with bouncing logo + beacon scan');
// Record start time for minimum display duration
_splashStartTime = DateTime.now();
// Start bouncing animation // Start bouncing animation
_bounceController = AnimationController( _bounceController = AnimationController(
vsync: this, vsync: this,
@ -109,23 +96,29 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
const logoWidth = 180.0; const logoWidth = 180.0;
const logoHeight = 60.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(() { setState(() {
_x += _dx; _x += _dx;
_y += _dy; _y += _dy;
// Bounce off edges and change color // Bounce off edges and change color
if (_x <= 0 || _x >= size.width - logoWidth) { if (_x <= 0 || _x >= maxX) {
_dx = -_dx; _dx = -_dx;
_changeColor(); _changeColor();
} }
if (_y <= 0 || _y >= size.height - logoHeight) { if (_y <= 0 || _y >= maxY) {
_dy = -_dy; _dy = -_dy;
_changeColor(); _changeColor();
} }
// Keep in bounds // Keep in bounds
_x = _x.clamp(0, size.width - logoWidth); _x = _x.clamp(0.0, maxX);
_y = _y.clamp(0, size.height - logoHeight); _y = _y.clamp(0.0, maxY);
}); });
} }
@ -137,6 +130,12 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
} }
Future<void> _initializeApp() async { Future<void> _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 // Check for saved auth credentials
print('[Splash] 🔐 Checking for saved auth credentials...'); print('[Splash] 🔐 Checking for saved auth credentials...');
final credentials = await AuthStorage.loadAuth(); final credentials = await AuthStorage.loadAuth();
@ -163,8 +162,6 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
// Start beacon scanning in background // Start beacon scanning in background
await _performBeaconScan(); await _performBeaconScan();
// No minimum display time - proceed as soon as scan completes
// Navigate based on results // Navigate based on results
if (!mounted) return; if (!mounted) return;
_navigateToNextScreen(); _navigateToNextScreen();
@ -184,7 +181,10 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
Future<void> _performBeaconScan() async { Future<void> _performBeaconScan() async {
print('[Splash] 📡 Starting beacon scan...'); 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(); final granted = await BeaconPermissions.requestPermissions();
if (!granted) { if (!granted) {
print('[Splash] ❌ Permissions denied'); print('[Splash] ❌ Permissions denied');
@ -192,6 +192,12 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
return; 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 // Check if Bluetooth is ON
print('[Splash] 📶 Checking Bluetooth state...'); print('[Splash] 📶 Checking Bluetooth state...');
final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled(); final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled();
@ -202,18 +208,38 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
} }
print('[Splash] ✅ Bluetooth is ON'); print('[Splash] ✅ Bluetooth is ON');
// Fetch beacon list from server // Step 1: Try to load beacon list from cache first, then fetch from server
print('[Splash] 📥 Loading beacon list...');
Map<String, int> 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 { try {
_uuidToBeaconId = await Api.listAllBeacons(); knownBeacons = await Api.listAllBeacons();
print('[Splash] Loaded ${_uuidToBeaconId.length} beacons from database'); print('[Splash] ✅ Got ${knownBeacons.length} beacon UUIDs from server');
// Save to cache
await BeaconCache.save(knownBeacons);
} catch (e) { } catch (e) {
print('[Splash] Error loading beacons: $e'); print('[Splash] ❌ Failed to fetch beacons: $e');
_scanComplete = true; _scanComplete = true;
return; return;
} }
}
if (_uuidToBeaconId.isEmpty) { if (knownBeacons.isEmpty) {
print('[Splash] No beacons in database'); print('[Splash] ⚠️ No beacons configured');
_scanComplete = true; _scanComplete = true;
return; return;
} }
@ -221,49 +247,53 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
// Initialize beacon scanning // Initialize beacon scanning
try { try {
await flutterBeacon.initializeScanning; 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 // 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)}'; 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); return Region(identifier: uuid, proximityUUID: formattedUUID);
}).toList(); }).toList();
// Perform scan cycles // Single scan - collect samples for 2 seconds
for (int scanCycle = 1; scanCycle <= 5; scanCycle++) { print('[Splash] 🔍 Scanning...');
print('[Splash] ----- Scan cycle $scanCycle/5 -----');
StreamSubscription<RangingResult>? subscription; StreamSubscription<RangingResult>? subscription;
subscription = flutterBeacon.ranging(regions).listen((result) { subscription = flutterBeacon.ranging(regions).listen((result) {
for (var beacon in result.beacons) { for (var beacon in result.beacons) {
final uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', ''); final uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', '');
final rssi = beacon.rssi; final rssi = beacon.rssi;
if (_uuidToBeaconId.containsKey(uuid)) {
_beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi); _beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi);
_beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1; _beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1;
print('[Splash] ✅ Beacon ${_uuidToBeaconId[uuid]}, RSSI=$rssi'); print('[Splash] 📶 Found $uuid RSSI=$rssi');
}
} }
}); });
await Future.delayed(const Duration(seconds: 2)); await Future.delayed(const Duration(milliseconds: 2000));
await subscription.cancel(); await subscription.cancel();
// Check for early exit after 3 cycles // Now lookup business info for found beacons
if (scanCycle >= 3 && _beaconRssiSamples.isNotEmpty && _canExitEarly()) { if (_beaconRssiSamples.isNotEmpty) {
print('[Splash] ⚡ Early exit - stable readings'); print('[Splash] 🔍 Looking up ${_beaconRssiSamples.length} beacons...');
break; final uuids = _beaconRssiSamples.keys.toList();
}
if (scanCycle < 5) { try {
await Future.delayed(const Duration(milliseconds: 200)); 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 print('[Splash] 🎯 Best beacon: ${_bestBeacon?.beaconName ?? "none"}');
_bestBeacon = _findBestBeacon();
print('[Splash] 🎯 Best beacon: ${_bestBeacon?.beaconId ?? "none"}');
} catch (e) { } catch (e) {
print('[Splash] Scan error: $e'); print('[Splash] Scan error: $e');
@ -272,346 +302,107 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
_scanComplete = true; _scanComplete = true;
} }
bool _canExitEarly() { /// Find the best registered beacon from lookup results based on RSSI
if (_beaconRssiSamples.isEmpty) return false; BeaconLookupResult? _findBestRegisteredBeacon(List<BeaconLookupResult> registeredBeacons) {
if (registeredBeacons.isEmpty) return null;
bool hasEnoughSamples = _beaconRssiSamples.values.any((s) => s.length >= 3); BeaconLookupResult? best;
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 = <String, double>{};
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;
double bestAvgRssi = -999; double bestAvgRssi = -999;
for (final entry in _beaconRssiSamples.entries) { for (final beacon in registeredBeacons) {
final samples = entry.value; final samples = _beaconRssiSamples[beacon.uuid];
final detections = _beaconDetectionCount[entry.key] ?? 0; 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; final avgRssi = samples.reduce((a, b) => a + b) / samples.length;
if (avgRssi > bestAvgRssi && avgRssi >= -85) { if (avgRssi > bestAvgRssi && avgRssi >= -85) {
bestAvgRssi = avgRssi; bestAvgRssi = avgRssi;
bestUuid = entry.key; best = beacon;
} }
} }
if (bestUuid != null) { // Fall back to strongest registered beacon if none meet threshold
return BeaconResult( if (best == null) {
uuid: bestUuid, for (final beacon in registeredBeacons) {
beaconId: _uuidToBeaconId[bestUuid]!, final samples = _beaconRssiSamples[beacon.uuid];
avgRssi: bestAvgRssi, 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; final avgRssi = samples.reduce((a, b) => a + b) / samples.length;
if (avgRssi > bestAvgRssi) { if (avgRssi > bestAvgRssi) {
bestAvgRssi = avgRssi; bestAvgRssi = avgRssi;
bestUuid = entry.key; best = beacon;
} }
} }
if (bestUuid != null) {
return BeaconResult(
uuid: bestUuid,
beaconId: _uuidToBeaconId[bestUuid]!,
avgRssi: bestAvgRssi,
);
}
} }
return null; return best;
} }
Future<void> _navigateToNextScreen() async { Future<void> _navigateToNextScreen() async {
if (!mounted || _navigating) return; if (!mounted) return;
setState(() {
_navigating = true;
});
final appState = context.read<AppState>();
// Get beacon mapping if we found a beacon
if (_bestBeacon != null) { if (_bestBeacon != null) {
try { final beacon = _bestBeacon!;
_beaconMapping = await Api.getBusinessFromBeacon(beaconId: _bestBeacon!.beaconId); print('[Splash] 📊 Best beacon: ${beacon.businessName}, hasChildren=${beacon.hasChildren}, parent=${beacon.parentBusinessName}');
print('[Splash] 📍 Beacon maps to: ${_beaconMapping!.businessName}');
} catch (e) {
print('[Splash] Error mapping beacon to business: $e');
_beaconMapping = null;
}
}
// Check for existing cart if user is logged in // Check if this business has child businesses (food court scenario)
final userId = appState.userId; if (beacon.hasChildren) {
if (userId != null && userId > 0) { print('[Splash] 🏢 Business has children - showing selector');
// Need to fetch children and show selector
try { try {
_existingCart = await Api.getActiveCart(userId: userId); final children = await Api.getChildBusinesses(businessId: beacon.businessId);
if (_existingCart != null && _existingCart!.hasItems) {
print('[Splash] 🛒 Found existing cart: ${_existingCart!.itemCount} items at ${_existingCart!.businessName}');
} else {
_existingCart = null;
}
} catch (e) {
print('[Splash] Error checking for existing cart: $e');
_existingCart = null;
}
}
if (!mounted) return; if (!mounted) return;
// DECISION TREE: if (children.isNotEmpty) {
// 1. Beacon found? Navigator.of(context).pushReplacementNamed(
// - Yes: Is there an existing cart? AppRoutes.businessSelector,
// - Yes: Same restaurant? arguments: {
// - Yes: Continue order as dine-in, update service point "parentBusinessId": beacon.businessId,
// - No: Start fresh with beacon's restaurant (dine-in) "parentBusinessName": beacon.businessName,
// - No: Start fresh with beacon's restaurant (dine-in) "servicePointId": beacon.servicePointId,
// - No: Is there an existing cart? "servicePointName": beacon.servicePointName,
// - Yes: Show "Continue or Start Fresh?" popup "children": children,
// - No: Go to restaurant select },
);
if (_beaconMapping != null) { return;
// 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 { } catch (e) {
// NO BEACON print('[Splash] Error fetching children: $e');
if (_existingCart != null) { }
// Has existing cart - ask user what to do }
print('[Splash] ❓ No beacon, but has existing cart - showing choice dialog');
_showContinueOrStartFreshDialog(); // Single business - go directly to menu
} else { final appState = context.read<AppState>();
// No cart, no beacon - go to restaurant select appState.setBusinessAndServicePoint(
print('[Splash] 📋 No beacon, no cart - going to restaurant select'); beacon.businessId,
beacon.servicePointId,
businessName: beacon.businessName,
servicePointName: beacon.servicePointName,
parentBusinessId: beacon.hasParent ? beacon.parentBusinessId : null,
parentBusinessName: beacon.hasParent ? beacon.parentBusinessName : null,
);
// Beacon detected = dine-in at a table
appState.setOrderType(OrderType.dineIn);
Api.setBusinessId(beacon.businessId);
print('[Splash] 🎉 Auto-selected: ${beacon.businessName}');
Navigator.of(context).pushReplacementNamed(
AppRoutes.menuBrowse,
arguments: {
'businessId': beacon.businessId,
'servicePointId': beacon.servicePointId,
},
);
return;
}
// No beacon or error - go to restaurant select
print('[Splash] Going to restaurant select');
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
} }
}
}
/// Continue existing order and update to dine-in with beacon's service point
Future<void> _continueExistingOrderWithBeacon() async {
if (!mounted || _existingCart == null || _beaconMapping == null) return;
final appState = context.read<AppState>();
// Update order type to dine-in and set service point
try {
await Api.setOrderType(
orderId: _existingCart!.orderId,
orderTypeId: 1, // dine-in
);
} 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<AppState>();
// 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<AppState>();
// 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) {
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
}
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<void> _startFresh() async {
if (!mounted) return;
final appState = context.read<AppState>();
// 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;
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 @override
void dispose() { void dispose() {
@ -667,44 +458,8 @@ class _SplashScreenState extends State<SplashScreen> 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,
});
}

View file

@ -8,6 +8,7 @@ import "../models/order_detail.dart";
import "../models/order_history.dart"; import "../models/order_history.dart";
import "../models/restaurant.dart"; import "../models/restaurant.dart";
import "../models/service_point.dart"; import "../models/service_point.dart";
import "../models/task_type.dart";
import "../models/user_profile.dart"; import "../models/user_profile.dart";
import "auth_storage.dart"; import "auth_storage.dart";
@ -23,6 +24,13 @@ class ApiRawResponse {
}); });
} }
class MenuItemsResult {
final List<MenuItem> items;
final String? brandColor;
const MenuItemsResult({required this.items, this.brandColor});
}
class LoginResponse { class LoginResponse {
final int userId; final int userId;
final String userFirstName; final String userFirstName;
@ -408,12 +416,15 @@ class Api {
// Businesses (legacy model name: Restaurant) // Businesses (legacy model name: Restaurant)
// ------------------------- // -------------------------
static Future<ApiRawResponse> listRestaurantsRaw() async { static Future<ApiRawResponse> 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); return _getRaw("/businesses/list.cfm", businessIdOverride: _mvpBusinessId);
} }
static Future<List<Restaurant>> listRestaurants() async { static Future<List<Restaurant>> listRestaurants({double? lat, double? lng}) async {
final raw = await listRestaurantsRaw(); final raw = await listRestaurantsRaw(lat: lat, lng: lng);
final j = _requireJson(raw, "Businesses"); final j = _requireJson(raw, "Businesses");
if (!_ok(j)) { if (!_ok(j)) {
@ -478,7 +489,7 @@ class Api {
// Menu Items // Menu Items
// ------------------------- // -------------------------
static Future<List<MenuItem>> listMenuItems({required int businessId}) async { static Future<MenuItemsResult> listMenuItems({required int businessId}) async {
final raw = await _postRaw( final raw = await _postRaw(
"/menu/items.cfm", "/menu/items.cfm",
{"BusinessID": businessId}, {"BusinessID": businessId},
@ -506,7 +517,15 @@ class Api {
out.add(MenuItem.fromJson(e.cast<String, dynamic>())); out.add(MenuItem.fromJson(e.cast<String, dynamic>()));
} }
} }
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 // Tasks / Service Requests
// ------------------------- // -------------------------
/// Get requestable task types for a business (for bell icon menu)
static Future<List<TaskType>> 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<dynamic>? ?? [];
return arr.map((e) => TaskType.fromJson(e as Map<String, dynamic>)).toList();
}
/// Call server to the table - creates a service request task /// Call server to the table - creates a service request task
static Future<void> callServer({ static Future<void> callServer({
required int businessId, required int businessId,
@ -705,6 +739,7 @@ class Api {
int? orderId, int? orderId,
int? userId, int? userId,
String? message, String? message,
int? taskTypeId,
}) async { }) async {
final body = <String, dynamic>{ final body = <String, dynamic>{
"BusinessID": businessId, "BusinessID": businessId,
@ -713,6 +748,7 @@ class Api {
if (orderId != null && orderId > 0) body["OrderID"] = orderId; if (orderId != null && orderId > 0) body["OrderID"] = orderId;
if (userId != null && userId > 0) body["UserID"] = userId; if (userId != null && userId > 0) body["UserID"] = userId;
if (message != null && message.isNotEmpty) body["Message"] = message; 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 raw = await _postRaw("/tasks/callServer.cfm", body);
final j = _requireJson(raw, "CallServer"); final j = _requireJson(raw, "CallServer");
@ -728,6 +764,73 @@ class Api {
// Beacons // Beacons
// ------------------------- // -------------------------
/// Lookup beacons by UUID - sends found UUIDs to server to check if registered
static Future<List<BeaconLookupResult>> lookupBeacons(List<String> 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<dynamic>? ?? [];
final results = <BeaconLookupResult>[];
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<Map<String, int>> 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<String, int> uuidToBeaconId = {};
for (final e in arr) {
if (e is! Map) continue;
final item = e is Map<String, dynamic> ? e : e.cast<String, dynamic>();
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<Map<String, int>> listAllBeacons() async { static Future<Map<String, int>> listAllBeacons() async {
final raw = await _getRaw("/beacons/list_all.cfm"); final raw = await _getRaw("/beacons/list_all.cfm");
final j = _requireJson(raw, "ListAllBeacons"); final j = _requireJson(raw, "ListAllBeacons");
@ -747,7 +850,7 @@ class Api {
if (e is! Map) continue; if (e is! Map) continue;
final item = e is Map<String, dynamic> ? e : e.cast<String, dynamic>(); final item = e is Map<String, dynamic> ? e : e.cast<String, dynamic>();
final uuid = (item["BeaconUUID"] ?? item["BEACONUUID"] ?? "").toString().trim(); final uuid = (item["BeaconUUID"] ?? item["BEACONUUID"] ?? "").toString().trim().toUpperCase();
final beaconId = item["BeaconID"] ?? item["BEACONID"]; final beaconId = item["BeaconID"] ?? item["BEACONID"];
if (uuid.isNotEmpty && beaconId is num) { if (uuid.isNotEmpty && beaconId is num) {
@ -777,6 +880,32 @@ class Api {
final business = j["BUSINESS"] as Map<String, dynamic>? ?? {}; final business = j["BUSINESS"] as Map<String, dynamic>? ?? {};
final servicePoint = j["SERVICEPOINT"] as Map<String, dynamic>? ?? {}; final servicePoint = j["SERVICEPOINT"] as Map<String, dynamic>? ?? {};
// Parse child businesses if present
final List<ChildBusiness> childBusinesses = [];
final businessesArr = j["BUSINESSES"] as List<dynamic>?;
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<String, dynamic>?;
if (parentMap != null) {
parent = BeaconParent(
businessId: _parseInt(parentMap["BusinessID"]) ?? 0,
businessName: (parentMap["BusinessName"] as String?) ?? "",
);
}
return BeaconBusinessMapping( return BeaconBusinessMapping(
beaconId: _parseInt(beacon["BeaconID"]) ?? 0, beaconId: _parseInt(beacon["BeaconID"]) ?? 0,
beaconName: (beacon["BeaconName"] as String?) ?? "", beaconName: (beacon["BeaconName"] as String?) ?? "",
@ -784,9 +913,39 @@ class Api {
businessName: (business["BusinessName"] as String?) ?? "", businessName: (business["BusinessName"] as String?) ?? "",
servicePointId: _parseInt(servicePoint["ServicePointID"]) ?? 0, servicePointId: _parseInt(servicePoint["ServicePointID"]) ?? 0,
servicePointName: (servicePoint["ServicePointName"] as String?) ?? "", servicePointName: (servicePoint["ServicePointName"] as String?) ?? "",
businesses: childBusinesses,
parent: parent,
); );
} }
/// Get child businesses for a parent business
static Future<List<ChildBusiness>> 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<ChildBusiness> 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) { static int? _parseInt(dynamic value) {
if (value == null) return null; if (value == null) return null;
if (value is int) return value; if (value is int) return value;
@ -1058,16 +1217,18 @@ class Api {
// Chat // 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 /// Returns the task ID if found, null otherwise
static Future<int?> getActiveChat({ static Future<int?> getActiveChat({
required int businessId, required int businessId,
required int servicePointId, required int servicePointId,
int? userId,
}) async { }) async {
final body = <String, dynamic>{ final body = <String, dynamic>{
"BusinessID": businessId, "BusinessID": businessId,
"ServicePointID": servicePointId, "ServicePointID": servicePointId,
}; };
if (userId != null && userId > 0) body["UserID"] = userId;
final raw = await _postRaw("/chat/getActiveChat.cfm", body); final raw = await _postRaw("/chat/getActiveChat.cfm", body);
final j = _requireJson(raw, "GetActiveChat"); 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 { class BeaconBusinessMapping {
final int beaconId; final int beaconId;
final String beaconName; final String beaconName;
@ -1208,6 +1383,8 @@ class BeaconBusinessMapping {
final String businessName; final String businessName;
final int servicePointId; final int servicePointId;
final String servicePointName; final String servicePointName;
final List<ChildBusiness> businesses;
final BeaconParent? parent;
const BeaconBusinessMapping({ const BeaconBusinessMapping({
required this.beaconId, required this.beaconId,
@ -1216,9 +1393,50 @@ class BeaconBusinessMapping {
required this.businessName, required this.businessName,
required this.servicePointId, required this.servicePointId,
required this.servicePointName, 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 { class PendingOrder {
final int orderId; final int orderId;
final String orderUuid; final String orderUuid;

View file

@ -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<void> save(Map<String, int> 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<Map<String, int>?> 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<String, dynamic>;
return decoded.map((k, v) => MapEntry(k, v as int));
} catch (e) {
return null;
}
}
/// Clear the beacon cache
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyBeaconData);
await prefs.remove(_keyBeaconTimestamp);
}
}

View file

@ -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<bool>.broadcast();
Stream<bool> 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<BeaconScanResult> 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<String, int> 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<String, List<int>> rssiSamples = {};
final Map<String, int> detectionCounts = {};
debugPrint('[BeaconScanner] Starting 2-second scan...');
StreamSubscription<RangingResult>? 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<BeaconLookupResult> 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<BeaconLookupResult> registeredBeacons,
Map<String, List<int>> rssiSamples,
Map<String, int> 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();
}
}

View file

@ -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<int, List<TaskType>> _taskTypesCache = {};
/// Preload all cacheable data during splash
/// Note: Task types require a business ID, so they're loaded on-demand per business
static Future<void> preloadAll() async {
debugPrint('[PreloadCache] Preload cache initialized');
// Future: Add any global preloads here
}
/// Get or fetch task types for a business
static Future<List<TaskType>> 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<void> 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<List<TaskType>?> _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<String, dynamic>)).toList();
} catch (e) {
return null;
}
}
static Future<void> _saveTaskTypesToDisk(int businessId, List<TaskType> 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<void> 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);
}
}
}

View file

@ -56,10 +56,12 @@ class StripeService {
/// Initialize Stripe with publishable key /// Initialize Stripe with publishable key
static Future<void> initialize(String publishableKey) async { static Future<void> initialize(String publishableKey) async {
if (_isInitialized) return; // 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; Stripe.publishableKey = publishableKey;
await Stripe.instance.applySettings(); await Stripe.instance.applySettings();
}
_isInitialized = true; _isInitialized = true;
} }

View file

@ -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<RescanButton> createState() => _RescanButtonState();
}
class _RescanButtonState extends State<RescanButton> {
final _scanner = BeaconScannerService();
bool _isScanning = false;
Future<void> _performRescan() async {
if (_isScanning) return;
setState(() => _isScanning = true);
final appState = context.read<AppState>();
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<void> _switchToBeacon(BeaconLookupResult beacon) async {
final appState = context.read<AppState>();
// 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,
);
}
}

View file

@ -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<bool> show(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => const SignInDialog(),
);
return result ?? false;
}
@override
State<SignInDialog> createState() => _SignInDialogState();
}
enum _SignInStep { phone, otp }
class _SignInDialogState extends State<SignInDialog> {
_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<void> _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<void> _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>();
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<void> _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<Color>(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<Color>(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'),
),
],
),
],
);
}
}

View file

@ -6,11 +6,13 @@ import FlutterMacOS
import Foundation import Foundation
import file_selector_macos import file_selector_macos
import geolocator_apple
import package_info_plus import package_info_plus
import shared_preferences_foundation import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
} }

View file

@ -153,6 +153,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.3+5" version: "0.9.3+5"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -208,6 +216,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.4" 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: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -693,6 +749,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
url: "https://pub.dev"
source: hosted
version: "4.5.2"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:

View file

@ -15,6 +15,7 @@ dependencies:
permission_handler: ^11.3.1 permission_handler: ^11.3.1
shared_preferences: ^2.2.3 shared_preferences: ^2.2.3
dchs_flutter_beacon: ^0.6.6 dchs_flutter_beacon: ^0.6.6
geolocator: ^13.0.2
flutter_stripe: ^11.4.0 flutter_stripe: ^11.4.0
image_picker: ^1.0.7 image_picker: ^1.0.7
intl: ^0.19.0 intl: ^0.19.0