Compare commits

..

No commits in common. "821eae417008fb253f3687ddb24b5bcae40e9fe4" and "9a489f20bb7878747ea7274a8d21a6a55028dfda" have entirely different histories.

33 changed files with 1084 additions and 3095 deletions

View file

@ -1,14 +1,5 @@
package com.payfrit.app
import android.os.Bundle
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity() {
// Fix crash when returning from CashApp/external payment authorization
// Stripe SDK fragments don't properly support state restoration
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
// Remove fragment state to prevent Stripe PaymentSheetFragment restoration crash
outState.remove("android:support:fragments")
}
}
class MainActivity : FlutterFragmentActivity()

View file

@ -496,7 +496,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.app;
PRODUCT_BUNDLE_IDENTIFIER = com.example.payfritApp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@ -513,7 +513,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.app.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.example.payfritApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -531,7 +531,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.app.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.example.payfritApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -547,7 +547,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.app.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.example.payfritApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -681,7 +681,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.app;
PRODUCT_BUNDLE_IDENTIFIER = com.example.payfritApp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -704,7 +704,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.app;
PRODUCT_BUNDLE_IDENTIFIER = com.example.payfritApp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;

View file

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

View file

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

View file

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

View file

@ -61,7 +61,7 @@ class Cart {
return Cart(
orderId: _parseInt(order["OrderID"]) ?? 0,
orderUuid: order["OrderUUID"]?.toString() ?? "",
orderUuid: (order["OrderUUID"] as String?) ?? "",
userId: _parseInt(order["OrderUserID"]) ?? 0,
businessId: _parseInt(order["OrderBusinessID"]) ?? 0,
businessDeliveryMultiplier: _parseDouble(order["OrderBusinessDeliveryMultiplier"]) ?? 0.0,
@ -72,7 +72,7 @@ class Cart {
statusId: _parseInt(order["OrderStatusID"]) ?? 0,
addressId: _parseInt(order["OrderAddressID"]),
paymentId: _parseInt(order["OrderPaymentID"]),
remarks: order["OrderRemarks"]?.toString(),
remarks: order["OrderRemarks"] as String?,
addedOn: _parseDateTime(order["OrderAddedOn"]),
lastEditedOn: _parseDateTime(order["OrderLastEditedOn"]),
submittedOn: _parseDateTime(order["OrderSubmittedOn"]),
@ -211,12 +211,12 @@ class OrderLineItem {
statusId: _parseInt(json["OrderLineItemStatusID"]) ?? 0,
price: _parseDouble(json["OrderLineItemPrice"]) ?? 0.0,
quantity: _parseInt(json["OrderLineItemQuantity"]) ?? 0,
remark: json["OrderLineItemRemark"]?.toString(),
remark: json["OrderLineItemRemark"] as String?,
isDeleted: _parseBool(json["OrderLineItemIsDeleted"]),
addedOn: _parseDateTime(json["OrderLineItemAddedOn"]),
itemName: json["ItemName"]?.toString(),
itemName: json["ItemName"] as String?,
itemParentItemId: _parseInt(json["ItemParentItemID"]),
itemParentName: json["ItemParentName"]?.toString(),
itemParentName: json["ItemParentName"] as String?,
isCheckedByDefault: _parseBool(json["ItemIsCheckedByDefault"]),
);
}

View file

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

View file

@ -43,23 +43,22 @@ class OrderDetail {
});
factory OrderDetail.fromJson(Map<String, dynamic> json) {
String safeStr(dynamic v) => v?.toString() ?? '';
final lineItemsJson = json['LineItems'] as List<dynamic>? ?? [];
final staffJson = json['Staff'] as List<dynamic>? ?? [];
return OrderDetail(
orderId: _parseInt(json['OrderID']) ?? 0,
businessId: _parseInt(json['BusinessID']) ?? 0,
businessName: safeStr(json['BusinessName']),
businessName: (json['BusinessName'] as String?) ?? '',
status: _parseInt(json['Status']) ?? 0,
statusText: safeStr(json['StatusText']),
statusText: (json['StatusText'] as String?) ?? '',
orderTypeId: _parseInt(json['OrderTypeID']) ?? 0,
orderTypeName: safeStr(json['OrderTypeName']),
orderTypeName: (json['OrderTypeName'] as String?) ?? '',
subtotal: _parseDouble(json['Subtotal']) ?? 0.0,
tax: _parseDouble(json['Tax']) ?? 0.0,
tip: _parseDouble(json['Tip']) ?? 0.0,
total: _parseDouble(json['Total']) ?? 0.0,
notes: safeStr(json['Notes']),
notes: (json['Notes'] as String?) ?? '',
createdOn: _parseDateTime(json['CreatedOn']),
submittedOn: _parseDateTimeNullable(json['SubmittedOn']),
updatedOn: _parseDateTimeNullable(json['UpdatedOn']),
@ -136,17 +135,14 @@ class OrderCustomer {
factory OrderCustomer.fromJson(Map<String, dynamic> json) {
return OrderCustomer(
userId: _safeInt(json['UserID']),
firstName: _safeStr(json['FirstName']),
lastName: _safeStr(json['LastName']),
phone: _safeStr(json['Phone']),
email: _safeStr(json['Email']),
userId: (json['UserID'] as num?)?.toInt() ?? 0,
firstName: (json['FirstName'] as String?) ?? '',
lastName: (json['LastName'] as String?) ?? '',
phone: (json['Phone'] as String?) ?? '',
email: (json['Email'] as String?) ?? '',
);
}
static int _safeInt(dynamic v) => v is int ? v : int.tryParse(v?.toString() ?? '') ?? 0;
static String _safeStr(dynamic v) => v?.toString() ?? '';
String get fullName {
final parts = [firstName, lastName].where((s) => s.isNotEmpty);
return parts.isEmpty ? 'Guest' : parts.join(' ');
@ -165,12 +161,10 @@ class OrderServicePoint {
});
factory OrderServicePoint.fromJson(Map<String, dynamic> json) {
int safeInt(dynamic v) => v is int ? v : int.tryParse(v?.toString() ?? '') ?? 0;
String safeStr(dynamic v) => v?.toString() ?? '';
return OrderServicePoint(
servicePointId: safeInt(json['ServicePointID']),
name: safeStr(json['Name']),
typeId: safeInt(json['TypeID']),
servicePointId: (json['ServicePointID'] as num?)?.toInt() ?? 0,
name: (json['Name'] as String?) ?? '',
typeId: (json['TypeID'] as num?)?.toInt() ?? 0,
);
}
}
@ -187,12 +181,10 @@ class OrderStaff {
});
factory OrderStaff.fromJson(Map<String, dynamic> json) {
int safeInt(dynamic v) => v is int ? v : int.tryParse(v?.toString() ?? '') ?? 0;
String safeStr(dynamic v) => v?.toString() ?? '';
return OrderStaff(
userId: safeInt(json['UserID']),
firstName: safeStr(json['FirstName']),
avatarUrl: safeStr(json['AvatarUrl']),
userId: (json['UserID'] as num?)?.toInt() ?? 0,
firstName: (json['FirstName'] as String?) ?? '',
avatarUrl: (json['AvatarUrl'] as String?) ?? '',
);
}
}
@ -221,19 +213,16 @@ class OrderLineItemDetail {
});
factory OrderLineItemDetail.fromJson(Map<String, dynamic> json) {
int safeInt(dynamic v) => v is int ? v : int.tryParse(v?.toString() ?? '') ?? 0;
double safeDouble(dynamic v) => v is num ? v.toDouble() : double.tryParse(v?.toString() ?? '') ?? 0.0;
String safeStr(dynamic v) => v?.toString() ?? '';
final modifiersJson = json['Modifiers'] as List<dynamic>? ?? [];
return OrderLineItemDetail(
lineItemId: safeInt(json['LineItemID']),
itemId: safeInt(json['ItemID']),
parentLineItemId: safeInt(json['ParentLineItemID']),
itemName: safeStr(json['ItemName']),
quantity: safeInt(json['Quantity']),
unitPrice: safeDouble(json['UnitPrice']),
remarks: safeStr(json['Remarks']),
lineItemId: (json['LineItemID'] as num?)?.toInt() ?? 0,
itemId: (json['ItemID'] as num?)?.toInt() ?? 0,
parentLineItemId: (json['ParentLineItemID'] as num?)?.toInt() ?? 0,
itemName: (json['ItemName'] as String?) ?? '',
quantity: (json['Quantity'] as num?)?.toInt() ?? 1,
unitPrice: (json['UnitPrice'] as num?)?.toDouble() ?? 0.0,
remarks: (json['Remarks'] as String?) ?? '',
isDefault: json['IsDefault'] == true,
modifiers: modifiersJson
.map((e) => OrderLineItemDetail.fromJson(e as Map<String, dynamic>))

View file

@ -28,24 +28,20 @@ class OrderHistoryItem {
});
factory OrderHistoryItem.fromJson(Map<String, dynamic> json) {
int parseId(dynamic val) => val is int ? val : int.tryParse(val.toString()) ?? 0;
double parseDouble(dynamic val) => val is num ? val.toDouble() : double.tryParse(val.toString()) ?? 0.0;
String parseStr(dynamic val) => val?.toString() ?? "";
return OrderHistoryItem(
orderId: parseId(json["OrderID"]),
orderUuid: parseStr(json["OrderUUID"]),
businessId: parseId(json["BusinessID"]),
businessName: parseStr(json["BusinessName"]).isEmpty ? "Unknown" : parseStr(json["BusinessName"]),
total: parseDouble(json["OrderTotal"]),
statusId: parseId(json["OrderStatusID"]),
statusName: parseStr(json["StatusName"]).isEmpty ? "Unknown" : parseStr(json["StatusName"]),
orderTypeId: parseId(json["OrderTypeID"]),
typeName: parseStr(json["TypeName"]).isEmpty ? "Unknown" : parseStr(json["TypeName"]),
itemCount: parseId(json["ItemCount"]),
createdAt: DateTime.tryParse(parseStr(json["CreatedAt"])) ?? DateTime.now(),
completedAt: parseStr(json["CompletedAt"]).isNotEmpty
? DateTime.tryParse(parseStr(json["CompletedAt"]))
orderId: (json["OrderID"] as num).toInt(),
orderUuid: json["OrderUUID"] as String? ?? "",
businessId: (json["BusinessID"] as num).toInt(),
businessName: json["BusinessName"] as String? ?? "Unknown",
total: (json["OrderTotal"] as num?)?.toDouble() ?? 0.0,
statusId: (json["OrderStatusID"] as num).toInt(),
statusName: json["StatusName"] as String? ?? "Unknown",
orderTypeId: (json["OrderTypeID"] as num?)?.toInt() ?? 0,
typeName: json["TypeName"] as String? ?? "Unknown",
itemCount: (json["ItemCount"] as num?)?.toInt() ?? 0,
createdAt: DateTime.tryParse(json["CreatedAt"] as String? ?? "") ?? DateTime.now(),
completedAt: json["CompletedAt"] != null && (json["CompletedAt"] as String).isNotEmpty
? DateTime.tryParse(json["CompletedAt"] as String)
: null,
);
}

View file

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

View file

@ -1,239 +0,0 @@
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'),
const SizedBox(height: 12),
ListTile(
leading: const Icon(Icons.help_outline),
title: const Text('help.payfrit.com'),
leading: const Icon(Icons.email_outlined),
title: const Text('support@payfrit.com'),
contentPadding: EdgeInsets.zero,
),
ListTile(

View file

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

View file

@ -183,8 +183,6 @@ class _AddressListScreenState extends State<AddressListScreen> {
_error!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 24),
FilledButton.icon(

View file

@ -1,435 +0,0 @@
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,7 +8,6 @@ import '../models/menu_item.dart';
import '../services/api.dart';
import '../services/order_polling_service.dart';
import '../services/stripe_service.dart';
import '../widgets/rescan_button.dart';
/// Helper class to store modifier breadcrumb paths
class ModifierPath {
@ -161,8 +160,8 @@ class _CartViewScreenState extends State<CartViewScreen> {
// Use cart's businessId if appState doesn't have one (e.g., delivery/takeaway flow)
final businessId = appState.selectedBusinessId ?? cart.businessId;
if (businessId > 0) {
final result = await Api.listMenuItems(businessId: businessId);
_menuItemsById = {for (var item in result.items) item.itemId: item};
final menuItems = await Api.listMenuItems(businessId: businessId);
_menuItemsById = {for (var item in menuItems) item.itemId: item};
}
setState(() {
@ -541,9 +540,6 @@ class _CartViewScreenState extends State<CartViewScreen> {
title: const Text("Cart"),
backgroundColor: Colors.black,
foregroundColor: Colors.white,
actions: const [
RescanButton(iconColor: Colors.white),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
@ -561,7 +557,7 @@ class _CartViewScreenState extends State<CartViewScreen> {
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(_error!, textAlign: TextAlign.center, maxLines: 3, overflow: TextOverflow.ellipsis),
Text(_error!, textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadCart,

View file

@ -8,25 +8,15 @@ import '../services/chat_service.dart';
import '../services/auth_storage.dart';
class ChatScreen extends StatefulWidget {
final int? taskId; // null if task needs to be created
final int taskId;
final String userType; // 'customer' or 'worker'
final String? otherPartyName;
// Required for creating task when taskId is null
final int? businessId;
final int? servicePointId;
final int? orderId;
final int? userId;
const ChatScreen({
super.key,
this.taskId,
required this.taskId,
required this.userType,
this.otherPartyName,
this.businessId,
this.servicePointId,
this.orderId,
this.userId,
});
@override
@ -38,7 +28,6 @@ class _ChatScreenState extends State<ChatScreen> {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final List<ChatMessage> _messages = [];
final List<String> _pendingMessages = []; // Messages queued before task created
bool _isLoading = true;
bool _isConnecting = false;
@ -47,8 +36,6 @@ class _ChatScreenState extends State<ChatScreen> {
String? _otherUserName;
String? _error;
bool _chatEnded = false;
bool _isCreatingTask = false; // True while creating task in background
int? _taskId; // Actual task ID (may be null initially)
StreamSubscription<ChatMessage>? _messageSubscription;
StreamSubscription<TypingEvent>? _typingSubscription;
@ -60,69 +47,15 @@ class _ChatScreenState extends State<ChatScreen> {
void initState() {
super.initState();
_otherUserName = widget.otherPartyName;
_taskId = widget.taskId;
_initializeChat();
}
Future<void> _initializeChat() async {
// Ensure auth is loaded first before any API calls
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
await _loadMessages();
_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);
}
// Then load messages and connect
await _loadMessages();
_connectToChat();
}
Future<void> _ensureAuth() async {
@ -148,15 +81,13 @@ class _ChatScreenState extends State<ChatScreen> {
}
Future<void> _loadMessages() async {
if (_taskId == null) return;
setState(() {
_isLoading = true;
_error = null;
});
try {
final result = await Api.getChatMessages(taskId: _taskId!);
final result = await Api.getChatMessages(taskId: widget.taskId);
if (mounted) {
final wasClosed = result.chatClosed && !_chatEnded;
setState(() {
@ -207,7 +138,7 @@ class _ChatScreenState extends State<ChatScreen> {
// Mark as read if from the other party
if (message.senderType != widget.userType) {
Api.markChatMessagesRead(
taskId: _taskId!,
taskId: widget.taskId,
readerType: widget.userType,
);
}
@ -283,7 +214,7 @@ class _ChatScreenState extends State<ChatScreen> {
});
final connected = await _chatService.connect(
taskId: _taskId!,
taskId: widget.taskId,
userToken: token,
userType: widget.userType,
);
@ -306,17 +237,15 @@ class _ChatScreenState extends State<ChatScreen> {
}
Future<void> _pollNewMessages() async {
if (_taskId == null) return;
try {
final lastMessageId = _messages.isNotEmpty ? _messages.last.messageId : 0;
final result = await Api.getChatMessages(
taskId: _taskId!,
taskId: widget.taskId,
afterMessageId: lastMessageId,
);
if (mounted) {
// Check if chat has been closed (by worker or system auto-close)
// Check if chat has been closed by worker
if (result.chatClosed && !_chatEnded) {
setState(() {
_chatEnded = true;
@ -324,7 +253,7 @@ class _ChatScreenState extends State<ChatScreen> {
_pollTimer?.cancel();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('This chat has ended', style: TextStyle(color: Colors.black)),
content: const Text('This chat has been closed by staff', style: TextStyle(color: Colors.black)),
backgroundColor: const Color(0xFF90EE90),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
@ -378,36 +307,8 @@ class _ChatScreenState extends State<ChatScreen> {
final text = _messageController.text.trim();
if (text.isEmpty || _isSending || _chatEnded) return;
_messageController.clear();
_chatService.setTyping(false);
// If task not created yet, queue the message
if (_taskId == null) {
setState(() {
_pendingMessages.add(text);
// Add optimistic message to UI
_messages.add(ChatMessage(
messageId: -(_pendingMessages.length), // Negative ID for pending
taskId: 0,
senderUserId: widget.userId ?? 0,
senderType: widget.userType,
senderName: 'Me',
text: text,
createdOn: DateTime.now(),
isRead: false,
));
});
_scrollToBottom();
return;
}
await _sendMessageText(text);
}
Future<void> _sendMessageText(String text) async {
if (_taskId == null) return;
setState(() => _isSending = true);
_chatService.setTyping(false);
try {
bool sentViaWebSocket = false;
@ -415,6 +316,9 @@ class _ChatScreenState extends State<ChatScreen> {
if (_chatService.isConnected) {
// Try to send via WebSocket
sentViaWebSocket = _chatService.sendMessage(text);
if (sentViaWebSocket) {
_messageController.clear();
}
}
if (!sentViaWebSocket) {
@ -427,11 +331,12 @@ class _ChatScreenState extends State<ChatScreen> {
}
await Api.sendChatMessage(
taskId: _taskId!,
taskId: widget.taskId,
message: text,
userId: userId,
senderType: widget.userType,
);
_messageController.clear();
// Refresh messages since we used HTTP
await _loadMessages();
@ -439,28 +344,12 @@ class _ChatScreenState extends State<ChatScreen> {
} catch (e) {
if (mounted) {
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(
SnackBar(
content: Text('Failed to send: $message'),
backgroundColor: Colors.red,
),
);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to send: $message'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
@ -573,33 +462,6 @@ class _ChatScreenState extends State<ChatScreen> {
),
body: Column(
children: [
if (_isCreatingTask)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: Colors.blue.shade50,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.blue.shade700,
),
),
const SizedBox(width: 12),
Text(
'Finding available staff...',
style: TextStyle(
color: Colors.blue.shade700,
fontWeight: FontWeight.w500,
),
),
],
),
),
if (_chatEnded)
Container(
width: double.infinity,

View file

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

View file

@ -5,12 +5,8 @@ import "../app/app_router.dart";
import "../app/app_state.dart";
import "../models/cart.dart";
import "../models/menu_item.dart";
import "../models/task_type.dart";
import "../services/api.dart";
import "../services/auth_storage.dart";
import "../services/preload_cache.dart";
import "../widgets/rescan_button.dart";
import "../widgets/sign_in_dialog.dart";
import "chat_screen.dart";
class MenuBrowseScreen extends StatefulWidget {
@ -35,12 +31,6 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
// Track which category is currently expanded (null = none)
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) {
if (v == null) return null;
if (v is int) return v;
@ -70,138 +60,53 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
final u = appState.userId;
final args = ModalRoute.of(context)?.settings.arguments;
int? b;
int? sp;
if (args is Map) {
b = _asIntNullable(args["BusinessID"]) ?? _asIntNullable(args["businessId"]);
sp = _asIntNullable(args["ServicePointID"]) ?? _asIntNullable(args["servicePointId"]);
}
final b = _asIntNullable(args["BusinessID"]) ?? _asIntNullable(args["businessId"]);
final 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) {
_businessId = b;
_servicePointId = sp;
_userId = u;
if (_businessId != b || _servicePointId != sp || _userId != u || _future == null) {
_businessId = b;
_servicePointId = sp;
_userId = u;
if (_businessId != null && _businessId! > 0) {
_future = _loadMenu();
} else {
_future = Future.value(<MenuItem>[]);
if (_businessId != null && _businessId! > 0) {
_future = _loadMenu();
} else {
_future = Future.value(<MenuItem>[]);
}
}
}
}
Future<List<MenuItem>> _loadMenu() async {
// 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
}
}
final items = await Api.listMenuItems(businessId: _businessId!);
setState(() {
_allItems = result.items;
_allItems = items;
_organizeItems();
});
return result.items;
return items;
}
bool _isCallingServer = false;
/// 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
/// Show bottom sheet with choice: Server Visit or Chat (dine-in) or just Chat (non-dine-in)
Future<void> _handleCallServer(AppState appState) async {
if (_businessId == null) return;
// For non-dine-in without a service point, use 0 as placeholder
final servicePointId = _servicePointId ?? 0;
final userId = appState.userId;
// Use preloaded task types if available, otherwise fetch from cache
// Check for active chat first
int? activeTaskId;
List<TaskType> taskTypes = _taskTypes;
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(
businessId: _businessId!,
servicePointId: servicePointId,
userId: userId,
).catchError((e) {
debugPrint('[MenuBrowse] getActiveChat error: $e');
return null;
});
}
debugPrint('[MenuBrowse] Got ${taskTypes.length} task types, activeTaskId=$activeTaskId');
activeTaskId = await Api.getActiveChat(
businessId: _businessId!,
servicePointId: servicePointId,
);
} catch (e) {
// Fall back to showing default options if API fails
debugPrint('[MenuBrowse] Failed to fetch task types: $e');
// Continue without active chat
}
if (!mounted) return;
@ -233,8 +138,49 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// Build task type options dynamically
..._buildTaskTypeOptions(taskTypes, appState, isDineIn, activeTaskId),
// Only show "Request Server Visit" for dine-in orders
if (isDineIn && _servicePointId != null) ...[
ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.orange,
child: Icon(Icons.room_service, color: Colors.white),
),
title: const Text('Request Server Visit'),
subtitle: const Text('Staff will come to your table'),
onTap: () {
Navigator.pop(context);
_sendServerRequest(appState);
},
),
const Divider(),
],
// Show either "Rejoin Chat" OR "Chat with Staff" - never both
if (activeTaskId != null)
ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.green,
child: Icon(Icons.chat_bubble, color: Colors.white),
),
title: const Text('Rejoin Chat'),
subtitle: const Text('Continue your conversation'),
onTap: () {
Navigator.pop(context);
_rejoinChat(activeTaskId!);
},
)
else
ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.blue,
child: Icon(Icons.chat, color: Colors.white),
),
title: const Text('Chat with Staff'),
subtitle: const Text('Send a message to our team'),
onTap: () {
Navigator.pop(context);
_startChat(appState);
},
),
],
),
),
@ -242,145 +188,6 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
);
}
/// 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(
backgroundColor: Colors.green,
child: Icon(Icons.chat_bubble, color: Colors.white),
),
title: const Text('Rejoin Chat'),
subtitle: const Text('Continue your conversation'),
onTap: () {
debugPrint('[MenuBrowse] Rejoin tapped');
Navigator.pop(context);
_rejoinChat(activeTaskId);
},
));
} else {
debugPrint('[MenuBrowse] Showing START option');
widgets.add(ListTile(
leading: CircleAvatar(
backgroundColor: taskType.iconColor,
child: Icon(taskType.icon, color: Colors.white),
),
title: Text(taskType.taskTypeName),
subtitle: Text(taskType.taskTypeDescription.isNotEmpty
? taskType.taskTypeDescription
: 'Send a message to our team'),
onTap: () {
debugPrint('[MenuBrowse] Start chat tapped');
Navigator.pop(context);
_startChat(appState);
},
));
}
widgets.add(const Divider());
}
// All other task types - only show for dine-in with service point
else if (isDineIn && _servicePointId != null) {
widgets.add(ListTile(
leading: CircleAvatar(
backgroundColor: taskType.iconColor,
child: Icon(taskType.icon, color: Colors.white),
),
title: Text(taskType.taskTypeName),
subtitle: Text(taskType.taskTypeDescription.isNotEmpty
? taskType.taskTypeDescription
: 'Request this service'),
onTap: () {
Navigator.pop(context);
_sendTaskRequest(appState, taskType);
},
));
widgets.add(const Divider());
}
}
// Remove trailing divider if present
if (widgets.isNotEmpty && widgets.last is Divider) {
widgets.removeLast();
}
return widgets;
}
/// Send a task request (server call, pay cash, custom, etc.)
Future<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
/// Returns true if logged in, false if user needs to log in
Future<bool> _ensureLoggedIn() async {
@ -419,13 +226,8 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
/// Rejoin an existing active chat
Future<void> _rejoinChat(int taskId) async {
debugPrint('[MenuBrowse] Rejoining chat: taskId=$taskId');
if (!await _ensureLoggedIn()) {
debugPrint('[MenuBrowse] Login required for rejoin - user cancelled or failed');
return;
}
if (!await _ensureLoggedIn()) return;
debugPrint('[MenuBrowse] Navigating to chat screen');
if (!mounted) return;
Navigator.push(
context,
@ -438,56 +240,115 @@ 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
Future<void> _startChat(AppState appState) async {
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
if (!await _ensureLoggedIn()) {
debugPrint('[MenuBrowse] Login cancelled or failed');
return;
}
if (!await _ensureLoggedIn()) return;
// Get userId for chat screen
final auth = await AuthStorage.loadAuth();
final userId = auth?.userId;
setState(() => _isCallingServer = true);
// For non-dine-in users without a service point, use 0
final servicePointId = _servicePointId ?? 0;
try {
// Reload auth to get userId
final auth = await AuthStorage.loadAuth();
final userId = auth?.userId;
debugPrint('[MenuBrowse] Starting chat immediately: businessId=$_businessId, servicePointId=$servicePointId, userId=$userId, isDineIn=${appState.isDineIn}');
// Create new chat
final taskId = await Api.createChatTask(
businessId: _businessId!,
servicePointId: _servicePointId!,
orderId: appState.cartOrderId,
userId: userId,
);
if (!mounted) return;
if (!mounted) return;
// Navigate to chat screen IMMEDIATELY - let it create the task in background
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChatScreen(
taskId: null, // Task will be created by ChatScreen
userType: 'customer',
businessId: _businessId,
servicePointId: servicePointId,
orderId: appState.cartOrderId,
userId: userId,
// Navigate to chat screen
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChatScreen(
taskId: taskId,
userType: 'customer',
),
),
),
);
debugPrint('[MenuBrowse] Navigated to ChatScreen (task will be created in background)');
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error, color: Colors.black),
const SizedBox(width: 8),
Expanded(child: Text("Failed to start chat: $e", style: const TextStyle(color: Colors.black))),
],
),
backgroundColor: Colors.red.shade100,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
),
);
} finally {
if (mounted) {
setState(() => _isCallingServer = false);
}
}
}
void _organizeItems() {
@ -550,51 +411,71 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
// If this business has a parent, go back to business selector
if (appState.hasParentBusiness) {
Navigator.of(context).pushReplacementNamed(
AppRoutes.businessSelector,
arguments: {
"parentBusinessId": appState.parentBusinessId,
"parentBusinessName": appState.parentBusinessName,
"servicePointId": appState.selectedServicePointId,
"servicePointName": appState.selectedServicePointName,
},
);
} else {
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
}
},
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
title: Row(
children: [
Text(
businessName,
style: const TextStyle(fontSize: 18),
),
// Only show table name for dine-in orders (beacon detected)
if (appState.isDineIn && appState.selectedServicePointName != null)
Text(
appState.selectedServicePointName!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
// Business logo
if (_businessId != null)
Padding(
padding: const EdgeInsets.only(right: 12),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 36,
height: 36,
child: Image.network(
"$_imageBaseUrl/logos/$_businessId.png",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.network(
"$_imageBaseUrl/logos/$_businessId.jpg",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.store,
size: 20,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
);
},
);
},
),
),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
businessName,
style: const TextStyle(fontSize: 18),
),
// Only show table name for dine-in orders (beacon detected)
if (appState.isDineIn && appState.selectedServicePointName != null)
Text(
appState.selectedServicePointName!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
),
),
],
),
),
],
),
actions: [
// Rescan for table button
const RescanButton(),
// Service bell for dine-in, chat bubble for non-dine-in
// Call Server (dine-in) or Chat (non-dine-in) button
IconButton(
icon: Icon(appState.isDineIn ? Icons.notifications_active : Icons.chat_bubble_outline),
tooltip: appState.isDineIn ? "Call Server" : "Chat With Staff",
icon: Icon(appState.isDineIn ? Icons.room_service : Icons.chat_bubble_outline),
tooltip: appState.isDineIn ? "Call Server" : "Chat",
onPressed: () => _handleCallServer(appState),
),
IconButton(
@ -680,7 +561,35 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
color: const Color(0xFFF0F0F0),
child: Column(
children: [
// Top gradient transition from category bar
Container(
height: 12,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFF1B4D3E).withAlpha(60),
const Color(0xFFF0F0F0),
],
),
),
),
...items.map((item) => _buildMenuItem(item)),
// Bottom fade-out gradient to show end of expanded section
Container(
height: 24,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFFF0F0F0),
const Color(0xFF1B4D3E).withAlpha(60),
],
),
),
),
],
),
),
@ -710,35 +619,74 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
return Container(
width: double.infinity,
height: 180,
color: _brandColor, // Fill empty space with business brand color
child: Center(
child: Image.network(
"$_imageBaseUrl/headers/$_businessId.png",
fit: BoxFit.fitWidth,
width: double.infinity,
errorBuilder: (context, error, stackTrace) {
return Image.network(
"$_imageBaseUrl/headers/$_businessId.jpg",
fit: BoxFit.fitWidth,
width: double.infinity,
errorBuilder: (context, error, stackTrace) {
// No header image - show gradient with brand color
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
_brandColor,
_brandColor.withAlpha(200),
],
child: Stack(
fit: StackFit.expand,
children: [
// Header background image
Image.network(
"$_imageBaseUrl/headers/$_businessId.png",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.network(
"$_imageBaseUrl/headers/$_businessId.jpg",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
// No header image - show gradient background
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.secondary,
],
),
),
),
);
},
);
},
),
);
},
);
},
),
// 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),
],
),
),
),
),
],
),
);
}
@ -852,7 +800,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
children: [
// Category image background or styled text fallback
_buildCategoryBackground(categoryId, categoryName),
// Top edge gradient (brand color)
// Top edge gradient (subtle forest green)
Positioned(
top: 0,
left: 0,
@ -864,14 +812,14 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_brandColor.withAlpha(150),
_brandColor.withAlpha(0),
const Color(0xFF1B4D3E).withAlpha(120),
const Color(0xFF1B4D3E).withAlpha(0),
],
),
),
),
),
// Bottom edge gradient (brand color)
// Bottom edge gradient (subtle forest green)
Positioned(
bottom: 0,
left: 0,
@ -883,46 +831,8 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_brandColor.withAlpha(0),
_brandColor.withAlpha(150),
],
),
),
),
),
// Left edge gradient (brand color)
Positioned(
top: 0,
bottom: 0,
left: 0,
width: 16,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
_brandColor.withAlpha(150),
_brandColor.withAlpha(0),
],
),
),
),
),
// Right edge gradient (brand color)
Positioned(
top: 0,
bottom: 0,
right: 0,
width: 16,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
_brandColor.withAlpha(0),
_brandColor.withAlpha(150),
const Color(0xFF1B4D3E).withAlpha(0),
const Color(0xFF1B4D3E).withAlpha(150),
],
),
),
@ -960,7 +870,13 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => _showItemCustomization(item),
onTap: () {
if (hasModifiers) {
_showItemCustomization(item);
} else {
_addToCart(item, {});
}
},
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
@ -1048,23 +964,30 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
}
Future<void> _addToCart(MenuItem item, Set<int> selectedModifierIds, {int quantity = 1}) async {
// Check if user is logged in - if not, show inline sign-in dialog
// Check if user is logged in - if not, navigate to login
if (_userId == null) {
final signedIn = await SignInDialog.show(context);
final shouldLogin = await showDialog<bool>(
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 (!mounted) return;
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;
if (shouldLogin == true && mounted) {
Navigator.of(context).pushNamed(AppRoutes.login);
}
return;
}
if (_businessId == null || _servicePointId == null) {
@ -1142,7 +1065,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
(li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted
).firstOrNull;
final newQuantity = (existingItem?.quantity ?? 0) + quantity;
final newQuantity = (existingItem?.quantity ?? 0) + 1;
cart = await Api.setLineItem(
orderId: cart.orderId,
@ -1242,7 +1165,6 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
final realChildItemId = _decodeVirtualId(child.itemId);
if (isSelected) {
// This item is directly selected - add it
final cart = await Api.setLineItem(
orderId: orderId,
parentOrderLineItemId: parentOrderLineItemId,
@ -1264,13 +1186,21 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
);
}
} else if (hasSelectedDescendants) {
// This item is NOT selected, but has selected descendants
// DON'T add this intermediate group - just recurse to add the actual selections
// The selections will be added directly under the current parent
// This matches how attachDefaultChildren works on the server
final cart = await Api.setLineItem(
orderId: orderId,
parentOrderLineItemId: parentOrderLineItemId,
itemId: child.itemId,
isSelected: true,
);
final childLineItem = cart.lineItems.lastWhere(
(li) => li.itemId == realChildItemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted,
orElse: () => throw StateError('Failed to add item'),
);
await _addModifiersRecursively(
orderId,
parentOrderLineItemId, // Keep the same parent - don't create intermediate node
childLineItem.orderLineItemId,
child.itemId,
selectedItemIds,
);
@ -1736,13 +1666,17 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> {
for (final child in children) {
final hasGrandchildren = widget.itemsByParent.containsKey(child.itemId);
// Auto-expand any item with children (regardless of isCollapsible flag)
if (hasGrandchildren) {
if (hasGrandchildren && child.isCollapsible) {
// Collapsible section with ExpansionTile
widgets.add(_buildExpansionTile(child, parent, depth));
} else {
// Regular checkbox/radio item (leaf node)
// Regular checkbox/radio item
widgets.add(_buildSelectableItem(child, parent, depth));
// Recursively add grandchildren
if (hasGrandchildren && _selectedItemIds.contains(child.itemId)) {
widgets.addAll(_buildModifierTree(child.itemId, depth + 1));
}
}
}

View file

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

View file

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

View file

@ -2,14 +2,12 @@
import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "package:geolocator/geolocator.dart";
import "../app/app_state.dart";
import "../models/menu_item.dart";
import "../models/restaurant.dart";
import "../models/service_point.dart";
import "../services/api.dart";
import "../widgets/rescan_button.dart";
class RestaurantSelectScreen extends StatefulWidget {
const RestaurantSelectScreen({super.key});
@ -36,7 +34,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
@override
void initState() {
super.initState();
_restaurantsFuture = _loadRestaurantsWithLocation();
_restaurantsFuture = _loadRestaurants();
// 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
@ -46,32 +44,11 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
});
}
Future<List<Restaurant>> _loadRestaurantsWithLocation() async {
double? lat;
double? lng;
// Try to get user location for distance-based sorting
try {
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.always || permission == LocationPermission.whileInUse) {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.low,
timeLimit: Duration(seconds: 5),
),
);
lat = position.latitude;
lng = position.longitude;
debugPrint('[RestaurantSelect] Got location: $lat, $lng');
}
} catch (e) {
debugPrint('[RestaurantSelect] Location error (continuing without): $e');
}
final raw = await Api.listRestaurantsRaw(lat: lat, lng: lng);
Future<List<Restaurant>> _loadRestaurants() async {
final raw = await Api.listRestaurantsRaw();
_debugLastRaw = raw.rawBody;
_debugLastStatus = raw.statusCode;
return Api.listRestaurants(lat: lat, lng: lng);
return Api.listRestaurants();
}
Future<void> _loadMenuForBusiness(int businessId) async {
@ -136,7 +113,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
Api.setBusinessId(restaurant.businessId);
// Navigate to full menu browse screen
Navigator.of(context).pushNamed(
Navigator.of(context).pushReplacementNamed(
'/menu_browse',
arguments: {
'businessId': restaurant.businessId,
@ -145,48 +122,15 @@ 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
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldExit = await _onWillPop();
if (shouldExit && context.mounted) {
Navigator.of(context).pop();
}
},
child: Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: const Text("Nearby Restaurants"),
elevation: 0,
actions: const [
RescanButton(iconColor: Colors.white),
],
),
body: FutureBuilder<List<Restaurant>>(
future: _restaurantsFuture,
@ -203,7 +147,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
message: snapshot.error.toString(),
statusCode: _debugLastStatus,
raw: _debugLastRaw,
onRetry: () => setState(() => _restaurantsFuture = _loadRestaurantsWithLocation()),
onRetry: () => setState(() => _restaurantsFuture = _loadRestaurants()),
);
}
@ -214,7 +158,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
message: "No Payfrit restaurants nearby.",
statusCode: _debugLastStatus,
raw: _debugLastRaw,
onRetry: () => setState(() => _restaurantsFuture = _loadRestaurantsWithLocation()),
onRetry: () => setState(() => _restaurantsFuture = _loadRestaurants()),
);
}
@ -238,7 +182,6 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
);
},
),
),
);
}
}
@ -262,124 +205,190 @@ class _RestaurantBar extends StatelessWidget {
required this.imageBaseUrl,
});
Widget _buildLogoPlaceholder(BuildContext context) {
return Container(
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.tertiary,
],
),
),
alignment: Alignment.center,
child: Text(
restaurant.name.isNotEmpty ? restaurant.name[0].toUpperCase() : "?",
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Restaurant card with header image (matches business selector style)
// Restaurant header bar with logo
GestureDetector(
onTap: onTap,
child: Container(
height: 120,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
height: 80,
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(100),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
borderRadius: BorderRadius.circular(12),
color: isExpanded
? Theme.of(context).colorScheme.primaryContainer
: Colors.grey.shade900,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
children: [
// Header image background
Positioned.fill(
child: Image.network(
"$imageBaseUrl/headers/${restaurant.businessId}.png",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.network(
"$imageBaseUrl/headers/${restaurant.businessId}.jpg",
child: Stack(
children: [
// Background header image (subtle) - ignorePointer so taps go through
IgnorePointer(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Opacity(
opacity: 0.3,
child: SizedBox(
width: double.infinity,
height: 80,
child: Image.network(
"$imageBaseUrl/headers/${restaurant.businessId}.png",
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (context, error, stackTrace) {
// Fallback to logo centered
return Container(
color: Colors.grey.shade800,
child: Center(
child: Image.network(
"$imageBaseUrl/logos/${restaurant.businessId}.png",
width: 60,
height: 60,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Image.network(
"$imageBaseUrl/logos/${restaurant.businessId}.jpg",
width: 60,
height: 60,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Text(
restaurant.name.isNotEmpty ? restaurant.name[0].toUpperCase() : "?",
style: const TextStyle(
color: Colors.white54,
fontSize: 36,
fontWeight: FontWeight.bold,
),
);
},
);
},
),
),
return Image.network(
"$imageBaseUrl/headers/${restaurant.businessId}.jpg",
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (context, error, stackTrace) {
return const SizedBox.shrink();
},
);
},
);
},
),
),
// Gradient overlay
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withAlpha(180),
],
),
),
),
),
// Business name and arrow at bottom
Positioned(
bottom: 12,
left: 16,
right: 16,
child: Row(
children: [
Expanded(
child: Text(
restaurant.name,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
offset: Offset(0, 1),
blurRadius: 3,
color: Colors.black54,
),
],
),
),
),
const Icon(
Icons.arrow_forward_ios,
color: Colors.white70,
size: 20,
),
],
),
// Sharp gradient edges
Positioned(
left: 0,
top: 0,
bottom: 0,
width: 20,
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
bottomLeft: Radius.circular(12),
),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
isExpanded
? Colors.transparent
: Colors.grey.shade900,
Colors.transparent,
],
),
),
),
],
),
),
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 20,
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topRight: Radius.circular(12),
bottomRight: Radius.circular(12),
),
gradient: LinearGradient(
begin: Alignment.centerRight,
end: Alignment.centerLeft,
colors: [
isExpanded
? Colors.transparent
: Colors.grey.shade900,
Colors.transparent,
],
),
),
),
),
// Content
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
// Logo (56x56 recommended, or 112x112 for 2x)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 56,
height: 56,
child: Image.network(
"$imageBaseUrl/logos/${restaurant.businessId}.png",
fit: BoxFit.cover,
gaplessPlayback: true,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
// Show placeholder immediately, image loads on top
return Stack(
children: [
_buildLogoPlaceholder(context),
if (frame != null) child,
],
);
},
errorBuilder: (context, error, stackTrace) {
return Image.network(
"$imageBaseUrl/logos/${restaurant.businessId}.jpg",
fit: BoxFit.cover,
gaplessPlayback: true,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
return Stack(
children: [
_buildLogoPlaceholder(context),
if (frame != null) child,
],
);
},
errorBuilder: (context, error, stackTrace) {
return _buildLogoPlaceholder(context);
},
);
},
),
),
),
const SizedBox(width: 16),
// Name
Expanded(
child: Text(
restaurant.name,
style: TextStyle(
color: isExpanded
? Theme.of(context).colorScheme.onPrimaryContainer
: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
),
),
),
@ -551,7 +560,7 @@ class _CategorySection extends StatelessWidget {
Api.setBusinessId(restaurant.businessId);
// Navigate to full menu browse screen
Navigator.of(context).pushNamed(
Navigator.of(context).pushReplacementNamed(
'/menu_browse',
arguments: {
'businessId': restaurant.businessId,

View file

@ -6,11 +6,10 @@ import "package:dchs_flutter_beacon/dchs_flutter_beacon.dart";
import "../app/app_router.dart";
import "../app/app_state.dart";
import "../models/cart.dart";
import "../services/api.dart";
import "../services/auth_storage.dart";
import "../services/beacon_cache.dart";
import "../services/beacon_permissions.dart";
import "../services/preload_cache.dart";
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@ -20,9 +19,6 @@ class SplashScreen extends StatefulWidget {
}
class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMixin {
// Track if permissions were freshly granted (needs Bluetooth warmup delay)
bool _permissionsWereFreshlyGranted = false;
// Bouncing logo animation
late AnimationController _bounceController;
double _x = 100;
@ -44,11 +40,25 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
"connecting...",
];
// Beacon scanning state - new approach: scan all, then lookup
// Beacon scanning state
Map<String, int> _uuidToBeaconId = {};
final Map<String, List<int>> _beaconRssiSamples = {};
final Map<String, int> _beaconDetectionCount = {};
bool _scanComplete = false;
BeaconLookupResult? _bestBeacon;
BeaconResult? _bestBeacon;
// Existing cart state
ActiveCartInfo? _existingCart;
BeaconBusinessMapping? _beaconMapping;
// Skip scan state
bool _scanSkipped = false;
// Navigation state - true once we start navigating away
bool _navigating = false;
// Minimum display time for splash screen
late DateTime _splashStartTime;
static const List<Color> _colors = [
Colors.white,
@ -66,6 +76,9 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
super.initState();
print('[Splash] 🚀 Starting with bouncing logo + beacon scan');
// Record start time for minimum display duration
_splashStartTime = DateTime.now();
// Start bouncing animation
_bounceController = AnimationController(
vsync: this,
@ -96,29 +109,23 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
const logoWidth = 180.0;
const logoHeight = 60.0;
// Skip if screen size not yet available
if (size.width <= logoWidth || size.height <= logoHeight) return;
final maxX = size.width - logoWidth;
final maxY = size.height - logoHeight;
setState(() {
_x += _dx;
_y += _dy;
// Bounce off edges and change color
if (_x <= 0 || _x >= maxX) {
if (_x <= 0 || _x >= size.width - logoWidth) {
_dx = -_dx;
_changeColor();
}
if (_y <= 0 || _y >= maxY) {
if (_y <= 0 || _y >= size.height - logoHeight) {
_dy = -_dy;
_changeColor();
}
// Keep in bounds
_x = _x.clamp(0.0, maxX);
_y = _y.clamp(0.0, maxY);
_x = _x.clamp(0, size.width - logoWidth);
_y = _y.clamp(0, size.height - logoHeight);
});
}
@ -130,12 +137,6 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
}
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
print('[Splash] 🔐 Checking for saved auth credentials...');
final credentials = await AuthStorage.loadAuth();
@ -162,6 +163,13 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
// Start beacon scanning in background
await _performBeaconScan();
// Ensure minimum 3 seconds display time so user can see/use skip button
if (!mounted) return;
final elapsed = DateTime.now().difference(_splashStartTime);
if (elapsed < const Duration(seconds: 3)) {
await Future.delayed(const Duration(seconds: 3) - elapsed);
}
// Navigate based on results
if (!mounted) return;
_navigateToNextScreen();
@ -181,10 +189,7 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
Future<void> _performBeaconScan() async {
print('[Splash] 📡 Starting beacon scan...');
// Check if permissions are already granted BEFORE requesting
final alreadyHadPermissions = await BeaconPermissions.checkPermissions();
// Request permissions (will be instant if already granted)
// Request permissions
final granted = await BeaconPermissions.requestPermissions();
if (!granted) {
print('[Splash] ❌ Permissions denied');
@ -192,12 +197,6 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
return;
}
// If permissions were just granted (not already had), Bluetooth needs warmup
_permissionsWereFreshlyGranted = !alreadyHadPermissions;
if (_permissionsWereFreshlyGranted) {
print('[Splash] 🆕 Permissions freshly granted - will add warmup delay');
}
// Check if Bluetooth is ON
print('[Splash] 📶 Checking Bluetooth state...');
final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled();
@ -208,38 +207,18 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
}
print('[Splash] ✅ Bluetooth is ON');
// 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 {
knownBeacons = await Api.listAllBeacons();
print('[Splash] ✅ Got ${knownBeacons.length} beacon UUIDs from server');
// Save to cache
await BeaconCache.save(knownBeacons);
} catch (e) {
print('[Splash] ❌ Failed to fetch beacons: $e');
_scanComplete = true;
return;
}
// Fetch beacon list from server
try {
_uuidToBeaconId = await Api.listAllBeacons();
print('[Splash] Loaded ${_uuidToBeaconId.length} beacons from database');
} catch (e) {
print('[Splash] Error loading beacons: $e');
_scanComplete = true;
return;
}
if (knownBeacons.isEmpty) {
print('[Splash] ⚠️ No beacons configured');
if (_uuidToBeaconId.isEmpty) {
print('[Splash] No beacons in database');
_scanComplete = true;
return;
}
@ -247,53 +226,49 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
// Initialize beacon scanning
try {
await flutterBeacon.initializeScanning;
// 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));
}
await Future.delayed(const Duration(milliseconds: 500));
// Create regions for all known UUIDs
final regions = knownBeacons.keys.map((uuid) {
final regions = _uuidToBeaconId.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();
// Single scan - collect samples for 2 seconds
print('[Splash] 🔍 Scanning...');
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;
// Perform scan cycles
for (int scanCycle = 1; scanCycle <= 5; scanCycle++) {
print('[Splash] ----- Scan cycle $scanCycle/5 -----');
_beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi);
_beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1;
print('[Splash] 📶 Found $uuid RSSI=$rssi');
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;
if (_uuidToBeaconId.containsKey(uuid)) {
_beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi);
_beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1;
print('[Splash] ✅ Beacon ${_uuidToBeaconId[uuid]}, RSSI=$rssi');
}
}
});
await Future.delayed(const Duration(seconds: 2));
await subscription.cancel();
// Check for early exit after 3 cycles
if (scanCycle >= 3 && _beaconRssiSamples.isNotEmpty && _canExitEarly()) {
print('[Splash] ⚡ Early exit - stable readings');
break;
}
});
await Future.delayed(const Duration(milliseconds: 2000));
await subscription.cancel();
// Now lookup business info for found beacons
if (_beaconRssiSamples.isNotEmpty) {
print('[Splash] 🔍 Looking up ${_beaconRssiSamples.length} beacons...');
final uuids = _beaconRssiSamples.keys.toList();
try {
final lookupResults = await Api.lookupBeacons(uuids);
print('[Splash] 📋 Server returned ${lookupResults.length} registered beacons');
// Find the best registered beacon based on RSSI
_bestBeacon = _findBestRegisteredBeacon(lookupResults);
} catch (e) {
print('[Splash] Error looking up beacons: $e');
if (scanCycle < 5) {
await Future.delayed(const Duration(milliseconds: 200));
}
}
print('[Splash] 🎯 Best beacon: ${_bestBeacon?.beaconName ?? "none"}');
// Find best beacon
_bestBeacon = _findBestBeacon();
print('[Splash] 🎯 Best beacon: ${_bestBeacon?.beaconId ?? "none"}');
} catch (e) {
print('[Splash] Scan error: $e');
@ -302,108 +277,347 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
_scanComplete = true;
}
/// Find the best registered beacon from lookup results based on RSSI
BeaconLookupResult? _findBestRegisteredBeacon(List<BeaconLookupResult> registeredBeacons) {
if (registeredBeacons.isEmpty) return null;
bool _canExitEarly() {
if (_beaconRssiSamples.isEmpty) return false;
BeaconLookupResult? best;
bool hasEnoughSamples = _beaconRssiSamples.values.any((s) => s.length >= 3);
if (!hasEnoughSamples) return false;
for (final entry in _beaconRssiSamples.entries) {
final samples = entry.value;
if (samples.length < 3) continue;
final avg = samples.reduce((a, b) => a + b) / samples.length;
final variance = samples.map((r) => (r - avg) * (r - avg)).reduce((a, b) => a + b) / samples.length;
if (variance > 50) return false;
}
if (_beaconRssiSamples.length > 1) {
final avgRssis = <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;
for (final beacon in registeredBeacons) {
final samples = _beaconRssiSamples[beacon.uuid];
if (samples == null || samples.isEmpty) continue;
for (final entry in _beaconRssiSamples.entries) {
final samples = entry.value;
final detections = _beaconDetectionCount[entry.key] ?? 0;
final detections = _beaconDetectionCount[beacon.uuid] ?? 0;
if (detections < 2) continue; // Need at least 2 detections
if (detections < 3) continue;
final avgRssi = samples.reduce((a, b) => a + b) / samples.length;
if (avgRssi > bestAvgRssi && avgRssi >= -85) {
bestAvgRssi = avgRssi;
best = beacon;
bestUuid = entry.key;
}
}
// Fall back to strongest registered beacon if none meet threshold
if (best == null) {
for (final beacon in registeredBeacons) {
final samples = _beaconRssiSamples[beacon.uuid];
if (samples == null || samples.isEmpty) continue;
if (bestUuid != null) {
return BeaconResult(
uuid: bestUuid,
beaconId: _uuidToBeaconId[bestUuid]!,
avgRssi: bestAvgRssi,
);
}
// Fall back to strongest signal even if doesn't meet threshold
if (_beaconRssiSamples.isNotEmpty) {
for (final entry in _beaconRssiSamples.entries) {
final samples = entry.value;
if (samples.isEmpty) continue;
final avgRssi = samples.reduce((a, b) => a + b) / samples.length;
if (avgRssi > bestAvgRssi) {
bestAvgRssi = avgRssi;
best = beacon;
bestUuid = entry.key;
}
}
if (bestUuid != null) {
return BeaconResult(
uuid: bestUuid,
beaconId: _uuidToBeaconId[bestUuid]!,
avgRssi: bestAvgRssi,
);
}
}
return best;
return null;
}
Future<void> _navigateToNextScreen() async {
if (!mounted) return;
if (!mounted || _navigating) return;
setState(() {
_navigating = true;
});
final appState = context.read<AppState>();
// Get beacon mapping if we found a beacon
if (_bestBeacon != null) {
final beacon = _bestBeacon!;
print('[Splash] 📊 Best beacon: ${beacon.businessName}, hasChildren=${beacon.hasChildren}, parent=${beacon.parentBusinessName}');
// Check if this business has child businesses (food court scenario)
if (beacon.hasChildren) {
print('[Splash] 🏢 Business has children - showing selector');
// Need to fetch children and show selector
try {
final children = await Api.getChildBusinesses(businessId: beacon.businessId);
if (!mounted) return;
if (children.isNotEmpty) {
Navigator.of(context).pushReplacementNamed(
AppRoutes.businessSelector,
arguments: {
"parentBusinessId": beacon.businessId,
"parentBusinessName": beacon.businessName,
"servicePointId": beacon.servicePointId,
"servicePointName": beacon.servicePointName,
"children": children,
},
);
return;
}
} catch (e) {
print('[Splash] Error fetching children: $e');
}
try {
_beaconMapping = await Api.getBusinessFromBeacon(beaconId: _bestBeacon!.beaconId);
print('[Splash] 📍 Beacon maps to: ${_beaconMapping!.businessName}');
} catch (e) {
print('[Splash] Error mapping beacon to business: $e');
_beaconMapping = null;
}
// Single business - go directly to menu
final appState = context.read<AppState>();
appState.setBusinessAndServicePoint(
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');
// Check for existing cart if user is logged in
final userId = appState.userId;
if (userId != null && userId > 0) {
try {
_existingCart = await Api.getActiveCart(userId: userId);
if (_existingCart != null && _existingCart!.hasItems) {
print('[Splash] 🛒 Found existing cart: ${_existingCart!.itemCount} items at ${_existingCart!.businessName}');
} else {
_existingCart = null;
}
} catch (e) {
print('[Splash] Error checking for existing cart: $e');
_existingCart = null;
}
}
if (!mounted) return;
// DECISION TREE:
// 1. Beacon found?
// - Yes: Is there an existing cart?
// - Yes: Same restaurant?
// - Yes: Continue order as dine-in, update service point
// - No: Start fresh with beacon's restaurant (dine-in)
// - No: Start fresh with beacon's restaurant (dine-in)
// - No: Is there an existing cart?
// - Yes: Show "Continue or Start Fresh?" popup
// - No: Go to restaurant select
if (_beaconMapping != null) {
// BEACON FOUND
if (_existingCart != null && _existingCart!.businessId == _beaconMapping!.businessId) {
// Same restaurant - continue order, update to dine-in with new service point
print('[Splash] ✅ Continuing existing order at same restaurant (dine-in)');
await _continueExistingOrderWithBeacon();
} else {
// Different restaurant or no cart - start fresh with beacon
print('[Splash] 🆕 Starting fresh dine-in order at ${_beaconMapping!.businessName}');
_startFreshWithBeacon();
}
} else {
// NO BEACON
if (_existingCart != null) {
// Has existing cart - ask user what to do
print('[Splash] ❓ No beacon, but has existing cart - showing choice dialog');
_showContinueOrStartFreshDialog();
} else {
// No cart, no beacon - go to restaurant select
print('[Splash] 📋 No beacon, no cart - going to restaurant select');
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
}
}
}
/// Continue existing order and update to dine-in with beacon's service point
Future<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
void dispose() {
_bounceController.dispose();
@ -458,8 +672,44 @@ 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,7 +8,6 @@ import "../models/order_detail.dart";
import "../models/order_history.dart";
import "../models/restaurant.dart";
import "../models/service_point.dart";
import "../models/task_type.dart";
import "../models/user_profile.dart";
import "auth_storage.dart";
@ -24,13 +23,6 @@ class ApiRawResponse {
});
}
class MenuItemsResult {
final List<MenuItem> items;
final String? brandColor;
const MenuItemsResult({required this.items, this.brandColor});
}
class LoginResponse {
final int userId;
final String userFirstName;
@ -416,15 +408,12 @@ class Api {
// Businesses (legacy model name: Restaurant)
// -------------------------
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);
}
static Future<ApiRawResponse> listRestaurantsRaw() async {
return _getRaw("/businesses/list.cfm", businessIdOverride: _mvpBusinessId);
}
static Future<List<Restaurant>> listRestaurants({double? lat, double? lng}) async {
final raw = await listRestaurantsRaw(lat: lat, lng: lng);
static Future<List<Restaurant>> listRestaurants() async {
final raw = await listRestaurantsRaw();
final j = _requireJson(raw, "Businesses");
if (!_ok(j)) {
@ -489,7 +478,7 @@ class Api {
// Menu Items
// -------------------------
static Future<MenuItemsResult> listMenuItems({required int businessId}) async {
static Future<List<MenuItem>> listMenuItems({required int businessId}) async {
final raw = await _postRaw(
"/menu/items.cfm",
{"BusinessID": businessId},
@ -517,15 +506,7 @@ class Api {
out.add(MenuItem.fromJson(e.cast<String, dynamic>()));
}
}
// 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);
return out;
}
// -------------------------
@ -717,21 +698,6 @@ class Api {
// 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
static Future<void> callServer({
required int businessId,
@ -739,7 +705,6 @@ class Api {
int? orderId,
int? userId,
String? message,
int? taskTypeId,
}) async {
final body = <String, dynamic>{
"BusinessID": businessId,
@ -748,7 +713,6 @@ class Api {
if (orderId != null && orderId > 0) body["OrderID"] = orderId;
if (userId != null && userId > 0) body["UserID"] = userId;
if (message != null && message.isNotEmpty) body["Message"] = message;
if (taskTypeId != null && taskTypeId > 0) body["TaskTypeID"] = taskTypeId;
final raw = await _postRaw("/tasks/callServer.cfm", body);
final j = _requireJson(raw, "CallServer");
@ -764,73 +728,6 @@ class Api {
// 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 {
final raw = await _getRaw("/beacons/list_all.cfm");
final j = _requireJson(raw, "ListAllBeacons");
@ -850,7 +747,7 @@ class Api {
if (e is! Map) continue;
final item = e is Map<String, dynamic> ? e : e.cast<String, dynamic>();
final uuid = (item["BeaconUUID"] ?? item["BEACONUUID"] ?? "").toString().trim().toUpperCase();
final uuid = (item["BeaconUUID"] ?? item["BEACONUUID"] ?? "").toString().trim();
final beaconId = item["BeaconID"] ?? item["BEACONID"];
if (uuid.isNotEmpty && beaconId is num) {
@ -880,32 +777,6 @@ class Api {
final business = j["BUSINESS"] 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(
beaconId: _parseInt(beacon["BeaconID"]) ?? 0,
beaconName: (beacon["BeaconName"] as String?) ?? "",
@ -913,39 +784,9 @@ class Api {
businessName: (business["BusinessName"] as String?) ?? "",
servicePointId: _parseInt(servicePoint["ServicePointID"]) ?? 0,
servicePointName: (servicePoint["ServicePointName"] as String?) ?? "",
businesses: childBusinesses,
parent: parent,
);
}
/// Get child businesses for a parent business
static Future<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) {
if (value == null) return null;
if (value is int) return value;
@ -1124,9 +965,7 @@ class Api {
final j = _requireJson(raw, "GetOrderHistory");
if (!_ok(j)) {
final detail = j["DETAIL"] ?? j["detail"] ?? "";
final debugLine = j["DEBUG_LINE"] ?? j["debug_line"] ?? "";
throw StateError("GetOrderHistory failed: ${_err(j)} - $detail (line: $debugLine)");
throw StateError("GetOrderHistory failed: ${_err(j)}");
}
final ordersJson = j["ORDERS"] as List<dynamic>? ?? [];
@ -1217,18 +1056,16 @@ class Api {
// Chat
// -------------------------
/// Check if there's an active chat for the user at a service point
/// Check if there's an active chat for the service point
/// Returns the task ID if found, null otherwise
static Future<int?> getActiveChat({
required int businessId,
required int servicePointId,
int? userId,
}) async {
final body = <String, dynamic>{
"BusinessID": businessId,
"ServicePointID": servicePointId,
};
if (userId != null && userId > 0) body["UserID"] = userId;
final raw = await _postRaw("/chat/getActiveChat.cfm", body);
final j = _requireJson(raw, "GetActiveChat");
@ -1362,20 +1199,6 @@ class OrderHistoryResponse {
});
}
class ChildBusiness {
final int businessId;
final String businessName;
final int servicePointId;
final String servicePointName;
const ChildBusiness({
required this.businessId,
required this.businessName,
this.servicePointId = 0,
this.servicePointName = "",
});
}
class BeaconBusinessMapping {
final int beaconId;
final String beaconName;
@ -1383,8 +1206,6 @@ class BeaconBusinessMapping {
final String businessName;
final int servicePointId;
final String servicePointName;
final List<ChildBusiness> businesses;
final BeaconParent? parent;
const BeaconBusinessMapping({
required this.beaconId,
@ -1393,50 +1214,9 @@ class BeaconBusinessMapping {
required this.businessName,
required this.servicePointId,
required this.servicePointName,
this.businesses = const [],
this.parent,
});
}
class BeaconParent {
final int businessId;
final String businessName;
const BeaconParent({
required this.businessId,
required this.businessName,
});
}
/// Result from beacon UUID lookup - contains all info needed to navigate
class BeaconLookupResult {
final String uuid;
final int beaconId;
final String beaconName;
final int businessId;
final String businessName;
final int servicePointId;
final String servicePointName;
final int parentBusinessId;
final String parentBusinessName;
final bool hasChildren;
const BeaconLookupResult({
required this.uuid,
required this.beaconId,
required this.beaconName,
required this.businessId,
required this.businessName,
required this.servicePointId,
required this.servicePointName,
this.parentBusinessId = 0,
this.parentBusinessName = "",
this.hasChildren = false,
});
bool get hasParent => parentBusinessId > 0;
}
class PendingOrder {
final int orderId;
final String orderUuid;
@ -1513,20 +1293,18 @@ class DeliveryAddress {
});
factory DeliveryAddress.fromJson(Map<String, dynamic> json) {
final rawId = json["AddressID"] ?? json["ADDRESSID"] ?? 0;
final rawStateId = json["StateID"] ?? json["STATEID"] ?? 0;
return DeliveryAddress(
addressId: rawId is int ? rawId : int.tryParse(rawId.toString()) ?? 0,
label: (json["Label"] ?? json["LABEL"] ?? "Address").toString(),
addressId: (json["AddressID"] ?? json["ADDRESSID"] ?? 0) as int,
label: (json["Label"] ?? json["LABEL"] ?? "Address") as String,
isDefault: (json["IsDefault"] ?? json["ISDEFAULT"] ?? false) == true,
line1: (json["Line1"] ?? json["LINE1"] ?? "").toString(),
line2: (json["Line2"] ?? json["LINE2"] ?? "").toString(),
city: (json["City"] ?? json["CITY"] ?? "").toString(),
stateId: rawStateId is int ? rawStateId : int.tryParse(rawStateId.toString()) ?? 0,
stateAbbr: (json["StateAbbr"] ?? json["STATEABBR"] ?? "").toString(),
stateName: (json["StateName"] ?? json["STATENAME"] ?? "").toString(),
zipCode: (json["ZIPCode"] ?? json["ZIPCODE"] ?? "").toString(),
displayText: (json["DisplayText"] ?? json["DISPLAYTEXT"] ?? "").toString(),
line1: (json["Line1"] ?? json["LINE1"] ?? "") as String,
line2: (json["Line2"] ?? json["LINE2"] ?? "") as String,
city: (json["City"] ?? json["CITY"] ?? "") as String,
stateId: (json["StateID"] ?? json["STATEID"] ?? 0) as int,
stateAbbr: (json["StateAbbr"] ?? json["STATEABBR"] ?? "") as String,
stateName: (json["StateName"] ?? json["STATENAME"] ?? "") as String,
zipCode: (json["ZIPCode"] ?? json["ZIPCODE"] ?? "") as String,
displayText: (json["DisplayText"] ?? json["DISPLAYTEXT"] ?? "") as String,
);
}
}

View file

@ -1,47 +0,0 @@
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

@ -1,204 +0,0 @@
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

@ -1,114 +0,0 @@
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,12 +56,10 @@ class StripeService {
/// Initialize Stripe with publishable key
static Future<void> initialize(String publishableKey) async {
// Always update the key if it's different (allows switching between test/live)
if (Stripe.publishableKey != publishableKey) {
print('[Stripe] Updating publishable key to: ${publishableKey.substring(0, 20)}...');
Stripe.publishableKey = publishableKey;
await Stripe.instance.applySettings();
}
if (_isInitialized) return;
Stripe.publishableKey = publishableKey;
await Stripe.instance.applySettings();
_isInitialized = true;
}

View file

@ -1,239 +0,0 @@
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

@ -1,404 +0,0 @@
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,13 +6,11 @@ import FlutterMacOS
import Foundation
import file_selector_macos
import geolocator_apple
import package_info_plus
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}

View file

@ -153,14 +153,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@ -216,54 +208,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.4"
geolocator:
dependency: "direct main"
description:
name: geolocator
sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2
url: "https://pub.dev"
source: hosted
version: "13.0.4"
geolocator_android:
dependency: transitive
description:
name: geolocator_android
sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
url: "https://pub.dev"
source: hosted
version: "4.6.2"
geolocator_apple:
dependency: transitive
description:
name: geolocator_apple
sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22
url: "https://pub.dev"
source: hosted
version: "2.3.13"
geolocator_platform_interface:
dependency: transitive
description:
name: geolocator_platform_interface
sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67"
url: "https://pub.dev"
source: hosted
version: "4.2.6"
geolocator_web:
dependency: transitive
description:
name: geolocator_web
sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172
url: "https://pub.dev"
source: hosted
version: "4.1.3"
geolocator_windows:
dependency: transitive
description:
name: geolocator_windows
sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6"
url: "https://pub.dev"
source: hosted
version: "0.2.5"
http:
dependency: "direct main"
description:
@ -749,14 +693,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
url: "https://pub.dev"
source: hosted
version: "4.5.2"
vector_math:
dependency: transitive
description:

View file

@ -1,7 +1,7 @@
name: payfrit_app
description: Payfrit MVP Flutter app scaffold
publish_to: "none"
version: 4.0.0+13
version: 3.0.1+10
environment:
sdk: ">=3.4.0 <4.0.0"
@ -15,7 +15,6 @@ dependencies:
permission_handler: ^11.3.1
shared_preferences: ^2.2.3
dchs_flutter_beacon: ^0.6.6
geolocator: ^13.0.2
flutter_stripe: ^11.4.0
image_picker: ^1.0.7
intl: ^0.19.0