Compare commits

...

10 commits

Author SHA1 Message Date
John Mizerek
821eae4170 Fix cart model string casting to use ?.toString()
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:17:43 -08:00
John Mizerek
b47c68b63a Fix type casting in order history and order detail models
Handle string/int/null values safely in JSON parsing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:08:39 -08:00
John Mizerek
28e41a445e Fix DeliveryAddress parser to handle string IDs from API
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:05:56 -08:00
John Mizerek
ce8cc74e96 Bump version to 4.0.0+13 for App Store release
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:54:20 -08:00
John Mizerek
c4792189dd App Store Version 2: Beacon scanning, preload caching, business selector
Features:
- Beacon scanner service for detecting nearby beacons
- Beacon cache for offline-first beacon resolution
- Preload cache for instant menu display
- Business selector screen for multi-location support
- Rescan button widget for quick beacon refresh
- Sign-in dialog for guest checkout flow
- Task type model for server tasks

Improvements:
- Enhanced menu browsing with category filtering
- Improved cart view with better modifier display
- Order history with detailed order tracking
- Chat screen improvements
- Better error handling in API service

Fixes:
- CashApp payment return crash fix
- Modifier nesting issues resolved
- Auto-expand modifier groups

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:51:54 -08:00
John Mizerek
5033e751ab Bump version to 3.0.1+12
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 17:31:30 -08:00
John Mizerek
213bc5e401 Fix CashApp return crash by preventing fragment state restoration
Prevent Stripe PaymentSheetFragment restoration crash when returning from
CashApp authorization by removing fragment state from saved instance bundle.

This fixes the rare crash:
"Unable to instantiate fragment com.reactnativestripesdk.PaymentSheetFragment:
could not find Fragment constructor"

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 00:29:33 -08:00
John Mizerek
e8c99844f8 Improve order history error debugging
Add detail and line number to error messages for easier troubleshooting.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:28:43 -08:00
John Mizerek
d111833aab Auto-expand modifier groups with children (ignore isCollapsible flag) 2026-01-14 13:36:25 -08:00
John Mizerek
f6428a14ff Fix modifier nesting - don't add intermediate groups
When user customizes an item, add selections directly under the root
item instead of creating an intermediate group node. This matches how
attachDefaultChildren works on the server and prevents duplicate
modifiers showing in KDS.

Before: Double Double → Customize Lettuce → Light, Extra
After:  Double Double → Light, Extra

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:51:59 -08:00
33 changed files with 3103 additions and 1092 deletions

View file

@ -1,5 +1,14 @@
package com.payfrit.app package com.payfrit.app
import android.os.Bundle
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : 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")
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

@ -0,0 +1,239 @@
import 'package:flutter/material.dart';
/// Represents a requestable task type (for bell icon menu)
class TaskType {
final int taskTypeId;
final String taskTypeName;
final String taskTypeDescription;
final String taskTypeIcon;
final String taskTypeColor;
const TaskType({
required this.taskTypeId,
required this.taskTypeName,
required this.taskTypeDescription,
required this.taskTypeIcon,
required this.taskTypeColor,
});
factory TaskType.fromJson(Map<String, dynamic> json) {
return TaskType(
taskTypeId: (json['tasktypeid'] as num?)?.toInt() ?? (json['TaskTypeID'] as num?)?.toInt() ?? 0,
taskTypeName: json['tasktypename'] as String? ?? json['TaskTypeName'] as String? ?? '',
taskTypeDescription: json['tasktypedescription'] as String? ?? json['TaskTypeDescription'] as String? ?? '',
taskTypeIcon: json['tasktypeicon'] as String? ?? json['TaskTypeIcon'] as String? ?? 'notifications',
taskTypeColor: json['tasktypecolor'] as String? ?? json['TaskTypeColor'] as String? ?? '#9C27B0',
);
}
/// Get the Flutter icon for this task type
IconData get icon => _iconMap[taskTypeIcon] ?? Icons.notifications;
/// Get the color for this task type (from the configured color)
Color get color {
try {
final hex = taskTypeColor.replaceFirst('#', '');
return Color(int.parse('FF$hex', radix: 16));
} catch (_) {
return Colors.purple;
}
}
/// Get the icon color for this task type (uses the configured color)
Color get iconColor => color;
/// Available icons for task types
static const Map<String, IconData> _iconMap = {
// Service & Staff
'room_service': Icons.room_service,
'support_agent': Icons.support_agent,
'person': Icons.person,
'groups': Icons.groups,
// Payment & Money
'attach_money': Icons.attach_money,
'payments': Icons.payments,
'receipt': Icons.receipt,
'credit_card': Icons.credit_card,
// Communication
'chat': Icons.chat,
'message': Icons.message,
'call': Icons.call,
'notifications': Icons.notifications,
// Food & Drink
'restaurant': Icons.restaurant,
'local_bar': Icons.local_bar,
'coffee': Icons.coffee,
'icecream': Icons.icecream,
'cake': Icons.cake,
'local_pizza': Icons.local_pizza,
'lunch_dining': Icons.lunch_dining,
'fastfood': Icons.fastfood,
'ramen_dining': Icons.ramen_dining,
'bakery_dining': Icons.bakery_dining,
// Drinks & Refills
'water_drop': Icons.water_drop,
'local_drink': Icons.local_drink,
'wine_bar': Icons.wine_bar,
'sports_bar': Icons.sports_bar,
'liquor': Icons.liquor,
// Hookah & Fire
'local_fire_department': Icons.local_fire_department,
'whatshot': Icons.whatshot,
'smoke_free': Icons.smoke_free,
// Cleaning & Maintenance
'cleaning_services': Icons.cleaning_services,
'delete_sweep': Icons.delete_sweep,
'auto_fix_high': Icons.auto_fix_high,
// Supplies & Items
'inventory': Icons.inventory,
'shopping_basket': Icons.shopping_basket,
'add_box': Icons.add_box,
'note_add': Icons.note_add,
// Entertainment
'music_note': Icons.music_note,
'tv': Icons.tv,
'sports_esports': Icons.sports_esports,
'celebration': Icons.celebration,
// Comfort & Amenities
'ac_unit': Icons.ac_unit,
'wb_sunny': Icons.wb_sunny,
'light_mode': Icons.light_mode,
'volume_up': Icons.volume_up,
'volume_down': Icons.volume_down,
// Health & Safety
'medical_services': Icons.medical_services,
'health_and_safety': Icons.health_and_safety,
'child_care': Icons.child_care,
'accessible': Icons.accessible,
// Location & Navigation
'directions': Icons.directions,
'meeting_room': Icons.meeting_room,
'wc': Icons.wc,
'local_parking': Icons.local_parking,
// General
'help': Icons.help,
'info': Icons.info,
'star': Icons.star,
'favorite': Icons.favorite,
'thumb_up': Icons.thumb_up,
'check_circle': Icons.check_circle,
'warning': Icons.warning,
'error': Icons.error,
'schedule': Icons.schedule,
'event': Icons.event,
};
/// Icon colors
static const Map<String, Color> _iconColorMap = {
// Service & Staff - Orange tones
'room_service': Colors.orange,
'support_agent': Colors.orange,
'person': Colors.orange,
'groups': Colors.orange,
// Payment & Money - Green tones
'attach_money': Colors.green,
'payments': Colors.green,
'receipt': Colors.green,
'credit_card': Colors.green,
// Communication - Blue tones
'chat': Colors.blue,
'message': Colors.blue,
'call': Colors.blue,
'notifications': Colors.purple,
// Food & Drink - Brown/Amber tones
'restaurant': Colors.brown,
'local_bar': Colors.purple,
'coffee': Colors.brown,
'icecream': Colors.pink,
'cake': Colors.pink,
'local_pizza': Colors.orange,
'lunch_dining': Colors.brown,
'fastfood': Colors.amber,
'ramen_dining': Colors.orange,
'bakery_dining': Colors.brown,
// Drinks & Refills - Blue tones
'water_drop': Colors.lightBlue,
'local_drink': Colors.lightBlue,
'wine_bar': Colors.red,
'sports_bar': Colors.amber,
'liquor': Colors.amber,
// Hookah & Fire - Red/Orange tones
'local_fire_department': Colors.red,
'whatshot': Colors.deepOrange,
'smoke_free': Colors.grey,
// Cleaning & Maintenance - Teal tones
'cleaning_services': Colors.teal,
'delete_sweep': Colors.teal,
'auto_fix_high': Colors.teal,
// Supplies & Items - Indigo tones
'inventory': Colors.indigo,
'shopping_basket': Colors.indigo,
'add_box': Colors.indigo,
'note_add': Colors.indigo,
// Entertainment - Purple tones
'music_note': Colors.purple,
'tv': Colors.purple,
'sports_esports': Colors.purple,
'celebration': Colors.pink,
// Comfort & Amenities - Cyan tones
'ac_unit': Colors.cyan,
'wb_sunny': Colors.amber,
'light_mode': Colors.amber,
'volume_up': Colors.blueGrey,
'volume_down': Colors.blueGrey,
// Health & Safety - Red tones
'medical_services': Colors.red,
'health_and_safety': Colors.green,
'child_care': Colors.pink,
'accessible': Colors.blue,
// Location & Navigation - Grey/Blue tones
'directions': Colors.blue,
'meeting_room': Colors.blueGrey,
'wc': Colors.blueGrey,
'local_parking': Colors.blue,
// General
'help': Colors.grey,
'info': Colors.blue,
'star': Colors.amber,
'favorite': Colors.red,
'thumb_up': Colors.green,
'check_circle': Colors.green,
'warning': Colors.orange,
'error': Colors.red,
'schedule': Colors.blueGrey,
'event': Colors.blue,
};
/// Get list of available icon names for UI
static List<String> get availableIcons => _iconMap.keys.toList();
/// Get icon by name
static IconData getIconByName(String name) => _iconMap[name] ?? Icons.notifications;
/// Get color by icon name
static Color getColorByName(String name) => _iconColorMap[name] ?? Colors.purple;
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,435 @@
import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "../app/app_router.dart";
import "../app/app_state.dart";
import "../services/api.dart";
import "../widgets/rescan_button.dart";
class BusinessSelectorScreen extends StatefulWidget {
const BusinessSelectorScreen({super.key});
@override
State<BusinessSelectorScreen> createState() => _BusinessSelectorScreenState();
}
class _BusinessSelectorScreenState extends State<BusinessSelectorScreen> {
static const String _imageBaseUrl = "https://biz.payfrit.com/uploads";
String? _parentName;
int? _parentBusinessId;
int? _servicePointId;
String? _servicePointName;
List<_BusinessItem>? _businesses;
bool _loading = false;
bool _initialized = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialized) {
_initialized = true;
_initializeData();
}
}
Future<void> _initializeData() async {
final args = ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>?;
final BeaconBusinessMapping? mapping = args?["mapping"] as BeaconBusinessMapping?;
final List<ChildBusiness>? children = args?["children"] as List<ChildBusiness>?;
if (mapping != null && mapping.businesses.isNotEmpty) {
// From beacon mapping
setState(() {
_parentName = mapping.parent?.businessName ?? mapping.businessName;
_parentBusinessId = mapping.parent?.businessId ?? mapping.businessId;
_servicePointId = mapping.servicePointId;
_servicePointName = mapping.servicePointName;
_businesses = mapping.businesses.map((b) => _BusinessItem(
businessId: b.businessId,
businessName: b.businessName,
servicePointId: b.servicePointId,
servicePointName: b.servicePointName,
)).toList();
});
} else if (children != null && children.isNotEmpty) {
// From menu_browse_screen with children list
setState(() {
_parentName = (args?["parentBusinessName"] as String?) ?? "this location";
_parentBusinessId = args?["parentBusinessId"] as int?;
_servicePointId = args?["servicePointId"] as int?;
_servicePointName = args?["servicePointName"] as String?;
_businesses = children.map((c) => _BusinessItem(
businessId: c.businessId,
businessName: c.businessName,
servicePointId: _servicePointId ?? 0,
servicePointName: _servicePointName ?? "",
)).toList();
});
} else if (args?["parentBusinessId"] != null) {
// From menu back button - need to fetch children
_parentBusinessId = args?["parentBusinessId"] as int?;
_parentName = args?["parentBusinessName"] as String? ?? "this location";
_servicePointId = args?["servicePointId"] as int?;
_servicePointName = args?["servicePointName"] as String?;
setState(() => _loading = true);
try {
final fetchedChildren = await Api.getChildBusinesses(businessId: _parentBusinessId!);
if (mounted) {
setState(() {
_businesses = fetchedChildren.map((c) => _BusinessItem(
businessId: c.businessId,
businessName: c.businessName,
servicePointId: _servicePointId ?? 0,
servicePointName: _servicePointName ?? "",
)).toList();
_loading = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _loading = false);
}
}
}
}
@override
Widget build(BuildContext context) {
if (_loading) {
return Scaffold(
backgroundColor: Colors.black,
body: const Center(
child: CircularProgressIndicator(color: Colors.white),
),
);
}
if (_businesses == null || _businesses!.isEmpty) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"No businesses available",
style: TextStyle(color: Colors.white, fontSize: 18),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect),
child: const Text("Browse Restaurants"),
),
],
),
),
);
}
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Column(
children: [
// Large header banner with back button overlay
_buildHeaderBanner(context, _parentBusinessId, _parentName ?? ""),
// Message
const Padding(
padding: EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
"Please select one of the businesses below:",
style: TextStyle(
color: Colors.white70,
fontSize: 16,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
// Business list with header images
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _businesses!.length,
itemBuilder: (context, index) {
final business = _businesses![index];
return _BusinessCardWithHeader(
businessId: business.businessId,
businessName: business.businessName,
imageBaseUrl: _imageBaseUrl,
onTap: () => _selectBusiness(context, business, _parentBusinessId, _parentName),
);
},
),
),
],
),
),
);
}
Widget _buildHeaderBanner(BuildContext context, int? parentBusinessId, String parentName) {
const imageBaseUrl = _imageBaseUrl;
return SizedBox(
height: 200,
width: double.infinity,
child: Stack(
children: [
// Background image
if (parentBusinessId != null)
Positioned.fill(
child: Image.network(
"$_imageBaseUrl/headers/$parentBusinessId.png",
fit: BoxFit.fitWidth,
errorBuilder: (context, error, stackTrace) {
return Image.network(
"$_imageBaseUrl/headers/$parentBusinessId.jpg",
fit: BoxFit.fitWidth,
errorBuilder: (context, error, stackTrace) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.grey.shade800,
Colors.grey.shade900,
],
),
),
);
},
);
},
),
),
// Subtle gradient overlay at bottom for readability of text below
Positioned(
bottom: 0,
left: 0,
right: 0,
height: 40,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withAlpha(150),
],
),
),
),
),
// Back button
Positioned(
top: 8,
left: 8,
child: IconButton(
onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect),
icon: const Icon(Icons.arrow_back),
color: Colors.white,
style: IconButton.styleFrom(
backgroundColor: Colors.black54,
),
),
),
// Rescan button
Positioned(
top: 8,
right: 8,
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: const RescanButton(iconColor: Colors.white),
),
),
],
),
);
}
void _selectBusiness(BuildContext context, _BusinessItem business, int? parentBusinessId, String? parentBusinessName) {
final appState = context.read<AppState>();
// Clear any existing cart
appState.clearCart();
// Set the selected business and service point (with parent info for back navigation)
appState.setBusinessAndServicePoint(
business.businessId,
business.servicePointId,
businessName: business.businessName,
servicePointName: business.servicePointName,
parentBusinessId: parentBusinessId,
parentBusinessName: parentBusinessName,
);
appState.setOrderType(OrderType.dineIn);
Api.setBusinessId(business.businessId);
// Navigate to menu
Navigator.of(context).pushReplacementNamed(
AppRoutes.menuBrowse,
arguments: {
"businessId": business.businessId,
"servicePointId": business.servicePointId,
},
);
}
}
/// Internal business item for unified handling
class _BusinessItem {
final int businessId;
final String businessName;
final int servicePointId;
final String servicePointName;
const _BusinessItem({
required this.businessId,
required this.businessName,
required this.servicePointId,
required this.servicePointName,
});
}
/// Business card with header image background
class _BusinessCardWithHeader extends StatelessWidget {
final int businessId;
final String businessName;
final String imageBaseUrl;
final VoidCallback onTap;
const _BusinessCardWithHeader({
required this.businessId,
required this.businessName,
required this.imageBaseUrl,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
height: 120,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(100),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
children: [
// Header image background
Positioned.fill(
child: Image.network(
"$imageBaseUrl/headers/$businessId.png",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.network(
"$imageBaseUrl/headers/$businessId.jpg",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
// Fallback to logo centered
return Container(
color: Colors.grey.shade800,
child: Center(
child: Image.network(
"$imageBaseUrl/logos/$businessId.png",
width: 60,
height: 60,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Image.network(
"$imageBaseUrl/logos/$businessId.jpg",
width: 60,
height: 60,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Text(
businessName.isNotEmpty ? businessName[0].toUpperCase() : "?",
style: const TextStyle(
color: Colors.white54,
fontSize: 36,
fontWeight: FontWeight.bold,
),
);
},
);
},
),
),
);
},
);
},
),
),
// Gradient overlay
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withAlpha(180),
],
),
),
),
),
// Business name and arrow at bottom
Positioned(
bottom: 12,
left: 16,
right: 16,
child: Row(
children: [
Expanded(
child: Text(
businessName,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
offset: Offset(0, 1),
blurRadius: 3,
color: Colors.black54,
),
],
),
),
),
const Icon(
Icons.arrow_forward_ios,
color: Colors.white70,
size: 20,
),
],
),
),
],
),
),
),
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,10 +6,11 @@ import "package:dchs_flutter_beacon/dchs_flutter_beacon.dart";
import "../app/app_router.dart"; import "../app/app_router.dart";
import "../app/app_state.dart"; import "../app/app_state.dart";
import "../models/cart.dart";
import "../services/api.dart"; import "../services/api.dart";
import "../services/auth_storage.dart"; import "../services/auth_storage.dart";
import "../services/beacon_cache.dart";
import "../services/beacon_permissions.dart"; import "../services/beacon_permissions.dart";
import "../services/preload_cache.dart";
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@ -19,6 +20,9 @@ class SplashScreen extends StatefulWidget {
} }
class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMixin { class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMixin {
// Track if permissions were freshly granted (needs Bluetooth warmup delay)
bool _permissionsWereFreshlyGranted = false;
// Bouncing logo animation // Bouncing logo animation
late AnimationController _bounceController; late AnimationController _bounceController;
double _x = 100; double _x = 100;
@ -40,25 +44,11 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
"connecting...", "connecting...",
]; ];
// Beacon scanning state // Beacon scanning state - new approach: scan all, then lookup
Map<String, int> _uuidToBeaconId = {};
final Map<String, List<int>> _beaconRssiSamples = {}; final Map<String, List<int>> _beaconRssiSamples = {};
final Map<String, int> _beaconDetectionCount = {}; final Map<String, int> _beaconDetectionCount = {};
bool _scanComplete = false; bool _scanComplete = false;
BeaconResult? _bestBeacon; BeaconLookupResult? _bestBeacon;
// Existing cart state
ActiveCartInfo? _existingCart;
BeaconBusinessMapping? _beaconMapping;
// Skip scan state
bool _scanSkipped = false;
// Navigation state - true once we start navigating away
bool _navigating = false;
// Minimum display time for splash screen
late DateTime _splashStartTime;
static const List<Color> _colors = [ static const List<Color> _colors = [
Colors.white, Colors.white,
@ -76,9 +66,6 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
super.initState(); super.initState();
print('[Splash] 🚀 Starting with bouncing logo + beacon scan'); print('[Splash] 🚀 Starting with bouncing logo + beacon scan');
// Record start time for minimum display duration
_splashStartTime = DateTime.now();
// Start bouncing animation // Start bouncing animation
_bounceController = AnimationController( _bounceController = AnimationController(
vsync: this, vsync: this,
@ -109,23 +96,29 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
const logoWidth = 180.0; const logoWidth = 180.0;
const logoHeight = 60.0; const logoHeight = 60.0;
// Skip if screen size not yet available
if (size.width <= logoWidth || size.height <= logoHeight) return;
final maxX = size.width - logoWidth;
final maxY = size.height - logoHeight;
setState(() { setState(() {
_x += _dx; _x += _dx;
_y += _dy; _y += _dy;
// Bounce off edges and change color // Bounce off edges and change color
if (_x <= 0 || _x >= size.width - logoWidth) { if (_x <= 0 || _x >= maxX) {
_dx = -_dx; _dx = -_dx;
_changeColor(); _changeColor();
} }
if (_y <= 0 || _y >= size.height - logoHeight) { if (_y <= 0 || _y >= maxY) {
_dy = -_dy; _dy = -_dy;
_changeColor(); _changeColor();
} }
// Keep in bounds // Keep in bounds
_x = _x.clamp(0, size.width - logoWidth); _x = _x.clamp(0.0, maxX);
_y = _y.clamp(0, size.height - logoHeight); _y = _y.clamp(0.0, maxY);
}); });
} }
@ -137,6 +130,12 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
} }
Future<void> _initializeApp() async { Future<void> _initializeApp() async {
// Run auth check and preloading in parallel for faster startup
print('[Splash] 🚀 Starting parallel initialization...');
// Start preloading data in background (fire and forget for non-critical data)
PreloadCache.preloadAll();
// Check for saved auth credentials // Check for saved auth credentials
print('[Splash] 🔐 Checking for saved auth credentials...'); print('[Splash] 🔐 Checking for saved auth credentials...');
final credentials = await AuthStorage.loadAuth(); final credentials = await AuthStorage.loadAuth();
@ -163,13 +162,6 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
// Start beacon scanning in background // Start beacon scanning in background
await _performBeaconScan(); 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 // Navigate based on results
if (!mounted) return; if (!mounted) return;
_navigateToNextScreen(); _navigateToNextScreen();
@ -189,7 +181,10 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
Future<void> _performBeaconScan() async { Future<void> _performBeaconScan() async {
print('[Splash] 📡 Starting beacon scan...'); print('[Splash] 📡 Starting beacon scan...');
// Request permissions // Check if permissions are already granted BEFORE requesting
final alreadyHadPermissions = await BeaconPermissions.checkPermissions();
// Request permissions (will be instant if already granted)
final granted = await BeaconPermissions.requestPermissions(); final granted = await BeaconPermissions.requestPermissions();
if (!granted) { if (!granted) {
print('[Splash] ❌ Permissions denied'); print('[Splash] ❌ Permissions denied');
@ -197,6 +192,12 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
return; return;
} }
// If permissions were just granted (not already had), Bluetooth needs warmup
_permissionsWereFreshlyGranted = !alreadyHadPermissions;
if (_permissionsWereFreshlyGranted) {
print('[Splash] 🆕 Permissions freshly granted - will add warmup delay');
}
// Check if Bluetooth is ON // Check if Bluetooth is ON
print('[Splash] 📶 Checking Bluetooth state...'); print('[Splash] 📶 Checking Bluetooth state...');
final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled(); final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled();
@ -207,18 +208,38 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
} }
print('[Splash] ✅ Bluetooth is ON'); print('[Splash] ✅ Bluetooth is ON');
// Fetch beacon list from server // Step 1: Try to load beacon list from cache first, then fetch from server
try { print('[Splash] 📥 Loading beacon list...');
_uuidToBeaconId = await Api.listAllBeacons(); Map<String, int> knownBeacons = {};
print('[Splash] Loaded ${_uuidToBeaconId.length} beacons from database');
} catch (e) { // Try cache first
print('[Splash] Error loading beacons: $e'); final cached = await BeaconCache.load();
_scanComplete = true; if (cached != null && cached.isNotEmpty) {
return; print('[Splash] ✅ Got ${cached.length} beacon UUIDs from cache');
knownBeacons = cached;
// Refresh cache in background (fire and forget)
Api.listAllBeacons().then((fresh) {
BeaconCache.save(fresh);
print('[Splash] 🔄 Background refresh: saved ${fresh.length} beacons to cache');
}).catchError((e) {
print('[Splash] ⚠️ Background refresh failed: $e');
});
} else {
// No cache - must fetch from server
try {
knownBeacons = await Api.listAllBeacons();
print('[Splash] ✅ Got ${knownBeacons.length} beacon UUIDs from server');
// Save to cache
await BeaconCache.save(knownBeacons);
} catch (e) {
print('[Splash] ❌ Failed to fetch beacons: $e');
_scanComplete = true;
return;
}
} }
if (_uuidToBeaconId.isEmpty) { if (knownBeacons.isEmpty) {
print('[Splash] No beacons in database'); print('[Splash] ⚠️ No beacons configured');
_scanComplete = true; _scanComplete = true;
return; return;
} }
@ -226,49 +247,53 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
// Initialize beacon scanning // Initialize beacon scanning
try { try {
await flutterBeacon.initializeScanning; await flutterBeacon.initializeScanning;
await Future.delayed(const Duration(milliseconds: 500));
// Only add delay if permissions were freshly granted (Bluetooth subsystem needs warmup)
if (_permissionsWereFreshlyGranted) {
print('[Splash] 🔄 Fresh permissions - adding Bluetooth warmup delay');
await Future.delayed(const Duration(milliseconds: 1500));
}
// Create regions for all known UUIDs // Create regions for all known UUIDs
final regions = _uuidToBeaconId.keys.map((uuid) { final regions = knownBeacons.keys.map((uuid) {
final formattedUUID = '${uuid.substring(0, 8)}-${uuid.substring(8, 12)}-${uuid.substring(12, 16)}-${uuid.substring(16, 20)}-${uuid.substring(20)}'; final formattedUUID = '${uuid.substring(0, 8)}-${uuid.substring(8, 12)}-${uuid.substring(12, 16)}-${uuid.substring(16, 20)}-${uuid.substring(20)}';
return Region(identifier: uuid, proximityUUID: formattedUUID); return Region(identifier: uuid, proximityUUID: formattedUUID);
}).toList(); }).toList();
// Perform scan cycles // Single scan - collect samples for 2 seconds
for (int scanCycle = 1; scanCycle <= 5; scanCycle++) { print('[Splash] 🔍 Scanning...');
print('[Splash] ----- Scan cycle $scanCycle/5 -----'); StreamSubscription<RangingResult>? subscription;
subscription = flutterBeacon.ranging(regions).listen((result) {
for (var beacon in result.beacons) {
final uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', '');
final rssi = beacon.rssi;
StreamSubscription<RangingResult>? subscription; _beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi);
subscription = flutterBeacon.ranging(regions).listen((result) { _beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1;
for (var beacon in result.beacons) { print('[Splash] 📶 Found $uuid RSSI=$rssi');
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;
} }
});
if (scanCycle < 5) { await Future.delayed(const Duration(milliseconds: 2000));
await Future.delayed(const Duration(milliseconds: 200)); await subscription.cancel();
// Now lookup business info for found beacons
if (_beaconRssiSamples.isNotEmpty) {
print('[Splash] 🔍 Looking up ${_beaconRssiSamples.length} beacons...');
final uuids = _beaconRssiSamples.keys.toList();
try {
final lookupResults = await Api.lookupBeacons(uuids);
print('[Splash] 📋 Server returned ${lookupResults.length} registered beacons');
// Find the best registered beacon based on RSSI
_bestBeacon = _findBestRegisteredBeacon(lookupResults);
} catch (e) {
print('[Splash] Error looking up beacons: $e');
} }
} }
// Find best beacon print('[Splash] 🎯 Best beacon: ${_bestBeacon?.beaconName ?? "none"}');
_bestBeacon = _findBestBeacon();
print('[Splash] 🎯 Best beacon: ${_bestBeacon?.beaconId ?? "none"}');
} catch (e) { } catch (e) {
print('[Splash] Scan error: $e'); print('[Splash] Scan error: $e');
@ -277,347 +302,108 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
_scanComplete = true; _scanComplete = true;
} }
bool _canExitEarly() { /// Find the best registered beacon from lookup results based on RSSI
if (_beaconRssiSamples.isEmpty) return false; BeaconLookupResult? _findBestRegisteredBeacon(List<BeaconLookupResult> registeredBeacons) {
if (registeredBeacons.isEmpty) return null;
bool hasEnoughSamples = _beaconRssiSamples.values.any((s) => s.length >= 3); BeaconLookupResult? best;
if (!hasEnoughSamples) return false;
for (final entry in _beaconRssiSamples.entries) {
final samples = entry.value;
if (samples.length < 3) continue;
final avg = samples.reduce((a, b) => a + b) / samples.length;
final variance = samples.map((r) => (r - avg) * (r - avg)).reduce((a, b) => a + b) / samples.length;
if (variance > 50) return false;
}
if (_beaconRssiSamples.length > 1) {
final avgRssis = <String, double>{};
for (final entry in _beaconRssiSamples.entries) {
if (entry.value.isNotEmpty) {
avgRssis[entry.key] = entry.value.reduce((a, b) => a + b) / entry.value.length;
}
}
final sorted = avgRssis.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
if (sorted.length >= 2 && (sorted[0].value - sorted[1].value) < 5) {
return false;
}
}
return true;
}
BeaconResult? _findBestBeacon() {
if (_beaconRssiSamples.isEmpty) return null;
String? bestUuid;
double bestAvgRssi = -999; double bestAvgRssi = -999;
for (final entry in _beaconRssiSamples.entries) { for (final beacon in registeredBeacons) {
final samples = entry.value; final samples = _beaconRssiSamples[beacon.uuid];
final detections = _beaconDetectionCount[entry.key] ?? 0; if (samples == null || samples.isEmpty) continue;
if (detections < 3) continue; final detections = _beaconDetectionCount[beacon.uuid] ?? 0;
if (detections < 2) continue; // Need at least 2 detections
final avgRssi = samples.reduce((a, b) => a + b) / samples.length; final avgRssi = samples.reduce((a, b) => a + b) / samples.length;
if (avgRssi > bestAvgRssi && avgRssi >= -85) { if (avgRssi > bestAvgRssi && avgRssi >= -85) {
bestAvgRssi = avgRssi; bestAvgRssi = avgRssi;
bestUuid = entry.key; best = beacon;
} }
} }
if (bestUuid != null) { // Fall back to strongest registered beacon if none meet threshold
return BeaconResult( if (best == null) {
uuid: bestUuid, for (final beacon in registeredBeacons) {
beaconId: _uuidToBeaconId[bestUuid]!, final samples = _beaconRssiSamples[beacon.uuid];
avgRssi: bestAvgRssi, if (samples == null || samples.isEmpty) continue;
);
}
// Fall back to strongest signal even if doesn't meet threshold
if (_beaconRssiSamples.isNotEmpty) {
for (final entry in _beaconRssiSamples.entries) {
final samples = entry.value;
if (samples.isEmpty) continue;
final avgRssi = samples.reduce((a, b) => a + b) / samples.length; final avgRssi = samples.reduce((a, b) => a + b) / samples.length;
if (avgRssi > bestAvgRssi) { if (avgRssi > bestAvgRssi) {
bestAvgRssi = avgRssi; bestAvgRssi = avgRssi;
bestUuid = entry.key; best = beacon;
} }
} }
if (bestUuid != null) {
return BeaconResult(
uuid: bestUuid,
beaconId: _uuidToBeaconId[bestUuid]!,
avgRssi: bestAvgRssi,
);
}
} }
return null; return best;
} }
Future<void> _navigateToNextScreen() async { Future<void> _navigateToNextScreen() async {
if (!mounted || _navigating) return; if (!mounted) return;
setState(() {
_navigating = true;
});
final appState = context.read<AppState>();
// Get beacon mapping if we found a beacon
if (_bestBeacon != null) { if (_bestBeacon != null) {
try { final beacon = _bestBeacon!;
_beaconMapping = await Api.getBusinessFromBeacon(beaconId: _bestBeacon!.beaconId); print('[Splash] 📊 Best beacon: ${beacon.businessName}, hasChildren=${beacon.hasChildren}, parent=${beacon.parentBusinessName}');
print('[Splash] 📍 Beacon maps to: ${_beaconMapping!.businessName}');
} catch (e) {
print('[Splash] Error mapping beacon to business: $e');
_beaconMapping = null;
}
}
// Check for existing cart if user is logged in // Check if this business has child businesses (food court scenario)
final userId = appState.userId; if (beacon.hasChildren) {
if (userId != null && userId > 0) { print('[Splash] 🏢 Business has children - showing selector');
try { // Need to fetch children and show selector
_existingCart = await Api.getActiveCart(userId: userId); try {
if (_existingCart != null && _existingCart!.hasItems) { final children = await Api.getChildBusinesses(businessId: beacon.businessId);
print('[Splash] 🛒 Found existing cart: ${_existingCart!.itemCount} items at ${_existingCart!.businessName}'); if (!mounted) return;
} else {
_existingCart = null; if (children.isNotEmpty) {
Navigator.of(context).pushReplacementNamed(
AppRoutes.businessSelector,
arguments: {
"parentBusinessId": beacon.businessId,
"parentBusinessName": beacon.businessName,
"servicePointId": beacon.servicePointId,
"servicePointName": beacon.servicePointName,
"children": children,
},
);
return;
}
} catch (e) {
print('[Splash] Error fetching children: $e');
} }
} catch (e) {
print('[Splash] Error checking for existing cart: $e');
_existingCart = null;
} }
}
if (!mounted) return; // Single business - go directly to menu
final appState = context.read<AppState>();
// DECISION TREE: appState.setBusinessAndServicePoint(
// 1. Beacon found? beacon.businessId,
// - Yes: Is there an existing cart? beacon.servicePointId,
// - Yes: Same restaurant? businessName: beacon.businessName,
// - Yes: Continue order as dine-in, update service point servicePointName: beacon.servicePointName,
// - No: Start fresh with beacon's restaurant (dine-in) parentBusinessId: beacon.hasParent ? beacon.parentBusinessId : null,
// - No: Start fresh with beacon's restaurant (dine-in) parentBusinessName: beacon.hasParent ? beacon.parentBusinessName : null,
// - 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) { // Beacon detected = dine-in at a table
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); appState.setOrderType(OrderType.dineIn);
} else if (_existingCart!.isTakeaway) { Api.setBusinessId(beacon.businessId);
appState.setOrderType(OrderType.takeaway);
} else if (_existingCart!.isDelivery) { print('[Splash] 🎉 Auto-selected: ${beacon.businessName}');
appState.setOrderType(OrderType.delivery);
} else { Navigator.of(context).pushReplacementNamed(
appState.setOrderType(null); // Undecided - will choose at checkout AppRoutes.menuBrowse,
arguments: {
'businessId': beacon.businessId,
'servicePointId': beacon.servicePointId,
},
);
return;
} }
appState.setCartOrder( // No beacon or error - go to restaurant select
orderId: _existingCart!.orderId, print('[Splash] Going to restaurant select');
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); Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
} }
/// Skip the beacon scan and proceed without dine-in detection
void _skipScan() {
if (_scanSkipped || _navigating) return;
print('[Splash] ⏭️ User skipped beacon scan');
setState(() {
_scanSkipped = true;
_scanComplete = true;
_bestBeacon = null; // No beacon since we skipped
});
// Proceed with navigation (will check for existing cart)
_navigateToNextScreen();
}
@override @override
void dispose() { void dispose() {
_bounceController.dispose(); _bounceController.dispose();
@ -672,44 +458,8 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
), ),
), ),
), ),
// Skip button at bottom - show until we start navigating away
if (!_navigating)
Positioned(
bottom: 50,
left: 0,
right: 0,
child: Center(
child: TextButton(
onPressed: _skipScan,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
),
child: const Text(
"Skip Scan",
style: TextStyle(
color: Colors.white70,
fontSize: 16,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
),
),
),
),
),
], ],
), ),
); );
} }
} }
class BeaconResult {
final String uuid;
final int beaconId;
final double avgRssi;
const BeaconResult({
required this.uuid,
required this.beaconId,
required this.avgRssi,
});
}

View file

@ -8,6 +8,7 @@ import "../models/order_detail.dart";
import "../models/order_history.dart"; import "../models/order_history.dart";
import "../models/restaurant.dart"; import "../models/restaurant.dart";
import "../models/service_point.dart"; import "../models/service_point.dart";
import "../models/task_type.dart";
import "../models/user_profile.dart"; import "../models/user_profile.dart";
import "auth_storage.dart"; import "auth_storage.dart";
@ -23,6 +24,13 @@ class ApiRawResponse {
}); });
} }
class MenuItemsResult {
final List<MenuItem> items;
final String? brandColor;
const MenuItemsResult({required this.items, this.brandColor});
}
class LoginResponse { class LoginResponse {
final int userId; final int userId;
final String userFirstName; final String userFirstName;
@ -408,12 +416,15 @@ class Api {
// Businesses (legacy model name: Restaurant) // Businesses (legacy model name: Restaurant)
// ------------------------- // -------------------------
static Future<ApiRawResponse> listRestaurantsRaw() async { static Future<ApiRawResponse> listRestaurantsRaw({double? lat, double? lng}) async {
if (lat != null && lng != null) {
return _postRaw("/businesses/list.cfm", {"lat": lat, "lng": lng}, businessIdOverride: _mvpBusinessId);
}
return _getRaw("/businesses/list.cfm", businessIdOverride: _mvpBusinessId); return _getRaw("/businesses/list.cfm", businessIdOverride: _mvpBusinessId);
} }
static Future<List<Restaurant>> listRestaurants() async { static Future<List<Restaurant>> listRestaurants({double? lat, double? lng}) async {
final raw = await listRestaurantsRaw(); final raw = await listRestaurantsRaw(lat: lat, lng: lng);
final j = _requireJson(raw, "Businesses"); final j = _requireJson(raw, "Businesses");
if (!_ok(j)) { if (!_ok(j)) {
@ -478,7 +489,7 @@ class Api {
// Menu Items // Menu Items
// ------------------------- // -------------------------
static Future<List<MenuItem>> listMenuItems({required int businessId}) async { static Future<MenuItemsResult> listMenuItems({required int businessId}) async {
final raw = await _postRaw( final raw = await _postRaw(
"/menu/items.cfm", "/menu/items.cfm",
{"BusinessID": businessId}, {"BusinessID": businessId},
@ -506,7 +517,15 @@ class Api {
out.add(MenuItem.fromJson(e.cast<String, dynamic>())); out.add(MenuItem.fromJson(e.cast<String, dynamic>()));
} }
} }
return out;
// Extract brand color if provided
String? brandColor;
final bc = j["BRANDCOLOR"] ?? j["BrandColor"] ?? j["brandColor"];
if (bc is String && bc.isNotEmpty) {
brandColor = bc;
}
return MenuItemsResult(items: out, brandColor: brandColor);
} }
// ------------------------- // -------------------------
@ -698,6 +717,21 @@ class Api {
// Tasks / Service Requests // Tasks / Service Requests
// ------------------------- // -------------------------
/// Get requestable task types for a business (for bell icon menu)
static Future<List<TaskType>> getTaskTypes({required int businessId}) async {
final raw = await _postRaw("/tasks/listTypes.cfm", {"BusinessID": businessId});
final j = _requireJson(raw, "GetTaskTypes");
if (!_ok(j)) {
throw StateError(
"GetTaskTypes failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}",
);
}
final arr = j["TASK_TYPES"] as List<dynamic>? ?? [];
return arr.map((e) => TaskType.fromJson(e as Map<String, dynamic>)).toList();
}
/// Call server to the table - creates a service request task /// Call server to the table - creates a service request task
static Future<void> callServer({ static Future<void> callServer({
required int businessId, required int businessId,
@ -705,6 +739,7 @@ class Api {
int? orderId, int? orderId,
int? userId, int? userId,
String? message, String? message,
int? taskTypeId,
}) async { }) async {
final body = <String, dynamic>{ final body = <String, dynamic>{
"BusinessID": businessId, "BusinessID": businessId,
@ -713,6 +748,7 @@ class Api {
if (orderId != null && orderId > 0) body["OrderID"] = orderId; if (orderId != null && orderId > 0) body["OrderID"] = orderId;
if (userId != null && userId > 0) body["UserID"] = userId; if (userId != null && userId > 0) body["UserID"] = userId;
if (message != null && message.isNotEmpty) body["Message"] = message; if (message != null && message.isNotEmpty) body["Message"] = message;
if (taskTypeId != null && taskTypeId > 0) body["TaskTypeID"] = taskTypeId;
final raw = await _postRaw("/tasks/callServer.cfm", body); final raw = await _postRaw("/tasks/callServer.cfm", body);
final j = _requireJson(raw, "CallServer"); final j = _requireJson(raw, "CallServer");
@ -728,6 +764,73 @@ class Api {
// Beacons // Beacons
// ------------------------- // -------------------------
/// Lookup beacons by UUID - sends found UUIDs to server to check if registered
static Future<List<BeaconLookupResult>> lookupBeacons(List<String> uuids) async {
if (uuids.isEmpty) return [];
final raw = await _postRaw("/beacons/lookup.cfm", {"UUIDs": uuids});
final j = _requireJson(raw, "LookupBeacons");
if (!_ok(j)) {
throw StateError(
"LookupBeacons API returned OK=false\nERROR: ${_err(j)}\nHTTP Status: ${raw.statusCode}",
);
}
final arr = j["BEACONS"] as List<dynamic>? ?? [];
final results = <BeaconLookupResult>[];
for (final e in arr) {
if (e is! Map) continue;
results.add(BeaconLookupResult(
uuid: (e["UUID"] as String?) ?? "",
beaconId: _parseInt(e["BeaconID"]) ?? 0,
beaconName: (e["BeaconName"] as String?) ?? "",
businessId: _parseInt(e["BusinessID"]) ?? 0,
businessName: (e["BusinessName"] as String?) ?? "",
servicePointId: _parseInt(e["ServicePointID"]) ?? 0,
servicePointName: (e["ServicePointName"] as String?) ?? "",
parentBusinessId: _parseInt(e["ParentBusinessID"]) ?? 0,
parentBusinessName: (e["ParentBusinessName"] as String?) ?? "",
hasChildren: e["HasChildren"] == true,
));
}
return results;
}
/// Get beacons for a specific business (optimized for rescan)
static Future<Map<String, int>> listBeaconsByBusiness({required int businessId}) async {
final raw = await _postRaw("/beacons/list.cfm", {"BusinessID": businessId});
final j = _requireJson(raw, "ListBeaconsByBusiness");
if (!_ok(j)) {
throw StateError(
"ListBeaconsByBusiness API returned OK=false\nERROR: ${_err(j)}\nHTTP Status: ${raw.statusCode}",
);
}
final arr = _pickArray(j, const ["BEACONS", "beacons"]);
if (arr == null) return {};
final Map<String, int> uuidToBeaconId = {};
for (final e in arr) {
if (e is! Map) continue;
final item = e is Map<String, dynamic> ? e : e.cast<String, dynamic>();
final uuid = (item["UUID"] ?? item["uuid"] ?? "").toString().trim().toUpperCase();
final beaconId = item["BeaconID"] ?? item["BEACONID"] ?? item["beaconId"];
if (uuid.isNotEmpty && beaconId is num) {
uuidToBeaconId[uuid] = beaconId.toInt();
}
}
return uuidToBeaconId;
}
/// @deprecated Use lookupBeacons instead - this downloads ALL beacons which doesn't scale
static Future<Map<String, int>> listAllBeacons() async { static Future<Map<String, int>> listAllBeacons() async {
final raw = await _getRaw("/beacons/list_all.cfm"); final raw = await _getRaw("/beacons/list_all.cfm");
final j = _requireJson(raw, "ListAllBeacons"); final j = _requireJson(raw, "ListAllBeacons");
@ -747,7 +850,7 @@ class Api {
if (e is! Map) continue; if (e is! Map) continue;
final item = e is Map<String, dynamic> ? e : e.cast<String, dynamic>(); final item = e is Map<String, dynamic> ? e : e.cast<String, dynamic>();
final uuid = (item["BeaconUUID"] ?? item["BEACONUUID"] ?? "").toString().trim(); final uuid = (item["BeaconUUID"] ?? item["BEACONUUID"] ?? "").toString().trim().toUpperCase();
final beaconId = item["BeaconID"] ?? item["BEACONID"]; final beaconId = item["BeaconID"] ?? item["BEACONID"];
if (uuid.isNotEmpty && beaconId is num) { if (uuid.isNotEmpty && beaconId is num) {
@ -777,6 +880,32 @@ class Api {
final business = j["BUSINESS"] as Map<String, dynamic>? ?? {}; final business = j["BUSINESS"] as Map<String, dynamic>? ?? {};
final servicePoint = j["SERVICEPOINT"] as Map<String, dynamic>? ?? {}; final servicePoint = j["SERVICEPOINT"] as Map<String, dynamic>? ?? {};
// Parse child businesses if present
final List<ChildBusiness> childBusinesses = [];
final businessesArr = j["BUSINESSES"] as List<dynamic>?;
if (businessesArr != null) {
for (final b in businessesArr) {
if (b is Map) {
childBusinesses.add(ChildBusiness(
businessId: _parseInt(b["BusinessID"]) ?? 0,
businessName: (b["BusinessName"] as String?) ?? "",
servicePointId: _parseInt(b["ServicePointID"]) ?? 0,
servicePointName: (b["ServicePointName"] as String?) ?? "",
));
}
}
}
// Parse parent if present
BeaconParent? parent;
final parentMap = j["PARENT"] as Map<String, dynamic>?;
if (parentMap != null) {
parent = BeaconParent(
businessId: _parseInt(parentMap["BusinessID"]) ?? 0,
businessName: (parentMap["BusinessName"] as String?) ?? "",
);
}
return BeaconBusinessMapping( return BeaconBusinessMapping(
beaconId: _parseInt(beacon["BeaconID"]) ?? 0, beaconId: _parseInt(beacon["BeaconID"]) ?? 0,
beaconName: (beacon["BeaconName"] as String?) ?? "", beaconName: (beacon["BeaconName"] as String?) ?? "",
@ -784,9 +913,39 @@ class Api {
businessName: (business["BusinessName"] as String?) ?? "", businessName: (business["BusinessName"] as String?) ?? "",
servicePointId: _parseInt(servicePoint["ServicePointID"]) ?? 0, servicePointId: _parseInt(servicePoint["ServicePointID"]) ?? 0,
servicePointName: (servicePoint["ServicePointName"] as String?) ?? "", servicePointName: (servicePoint["ServicePointName"] as String?) ?? "",
businesses: childBusinesses,
parent: parent,
); );
} }
/// Get child businesses for a parent business
static Future<List<ChildBusiness>> getChildBusinesses({
required int businessId,
}) async {
final raw = await _getRaw("/businesses/getChildren.cfm?BusinessID=$businessId");
final j = _requireJson(raw, "GetChildBusinesses");
if (!_ok(j)) {
return [];
}
final List<ChildBusiness> children = [];
final arr = _pickArray(j, const ["BUSINESSES", "businesses"]);
if (arr != null) {
for (final b in arr) {
if (b is Map) {
children.add(ChildBusiness(
businessId: _parseInt(b["BusinessID"]) ?? 0,
businessName: (b["BusinessName"] as String?) ?? "",
servicePointId: _parseInt(b["ServicePointID"]) ?? 0,
servicePointName: (b["ServicePointName"] as String?) ?? "",
));
}
}
}
return children;
}
static int? _parseInt(dynamic value) { static int? _parseInt(dynamic value) {
if (value == null) return null; if (value == null) return null;
if (value is int) return value; if (value is int) return value;
@ -965,7 +1124,9 @@ class Api {
final j = _requireJson(raw, "GetOrderHistory"); final j = _requireJson(raw, "GetOrderHistory");
if (!_ok(j)) { if (!_ok(j)) {
throw StateError("GetOrderHistory failed: ${_err(j)}"); final detail = j["DETAIL"] ?? j["detail"] ?? "";
final debugLine = j["DEBUG_LINE"] ?? j["debug_line"] ?? "";
throw StateError("GetOrderHistory failed: ${_err(j)} - $detail (line: $debugLine)");
} }
final ordersJson = j["ORDERS"] as List<dynamic>? ?? []; final ordersJson = j["ORDERS"] as List<dynamic>? ?? [];
@ -1056,16 +1217,18 @@ class Api {
// Chat // Chat
// ------------------------- // -------------------------
/// Check if there's an active chat for the service point /// Check if there's an active chat for the user at a service point
/// Returns the task ID if found, null otherwise /// Returns the task ID if found, null otherwise
static Future<int?> getActiveChat({ static Future<int?> getActiveChat({
required int businessId, required int businessId,
required int servicePointId, required int servicePointId,
int? userId,
}) async { }) async {
final body = <String, dynamic>{ final body = <String, dynamic>{
"BusinessID": businessId, "BusinessID": businessId,
"ServicePointID": servicePointId, "ServicePointID": servicePointId,
}; };
if (userId != null && userId > 0) body["UserID"] = userId;
final raw = await _postRaw("/chat/getActiveChat.cfm", body); final raw = await _postRaw("/chat/getActiveChat.cfm", body);
final j = _requireJson(raw, "GetActiveChat"); final j = _requireJson(raw, "GetActiveChat");
@ -1199,6 +1362,20 @@ class OrderHistoryResponse {
}); });
} }
class ChildBusiness {
final int businessId;
final String businessName;
final int servicePointId;
final String servicePointName;
const ChildBusiness({
required this.businessId,
required this.businessName,
this.servicePointId = 0,
this.servicePointName = "",
});
}
class BeaconBusinessMapping { class BeaconBusinessMapping {
final int beaconId; final int beaconId;
final String beaconName; final String beaconName;
@ -1206,6 +1383,8 @@ class BeaconBusinessMapping {
final String businessName; final String businessName;
final int servicePointId; final int servicePointId;
final String servicePointName; final String servicePointName;
final List<ChildBusiness> businesses;
final BeaconParent? parent;
const BeaconBusinessMapping({ const BeaconBusinessMapping({
required this.beaconId, required this.beaconId,
@ -1214,9 +1393,50 @@ class BeaconBusinessMapping {
required this.businessName, required this.businessName,
required this.servicePointId, required this.servicePointId,
required this.servicePointName, required this.servicePointName,
this.businesses = const [],
this.parent,
}); });
} }
class BeaconParent {
final int businessId;
final String businessName;
const BeaconParent({
required this.businessId,
required this.businessName,
});
}
/// Result from beacon UUID lookup - contains all info needed to navigate
class BeaconLookupResult {
final String uuid;
final int beaconId;
final String beaconName;
final int businessId;
final String businessName;
final int servicePointId;
final String servicePointName;
final int parentBusinessId;
final String parentBusinessName;
final bool hasChildren;
const BeaconLookupResult({
required this.uuid,
required this.beaconId,
required this.beaconName,
required this.businessId,
required this.businessName,
required this.servicePointId,
required this.servicePointName,
this.parentBusinessId = 0,
this.parentBusinessName = "",
this.hasChildren = false,
});
bool get hasParent => parentBusinessId > 0;
}
class PendingOrder { class PendingOrder {
final int orderId; final int orderId;
final String orderUuid; final String orderUuid;
@ -1293,18 +1513,20 @@ class DeliveryAddress {
}); });
factory DeliveryAddress.fromJson(Map<String, dynamic> json) { factory DeliveryAddress.fromJson(Map<String, dynamic> json) {
final rawId = json["AddressID"] ?? json["ADDRESSID"] ?? 0;
final rawStateId = json["StateID"] ?? json["STATEID"] ?? 0;
return DeliveryAddress( return DeliveryAddress(
addressId: (json["AddressID"] ?? json["ADDRESSID"] ?? 0) as int, addressId: rawId is int ? rawId : int.tryParse(rawId.toString()) ?? 0,
label: (json["Label"] ?? json["LABEL"] ?? "Address") as String, label: (json["Label"] ?? json["LABEL"] ?? "Address").toString(),
isDefault: (json["IsDefault"] ?? json["ISDEFAULT"] ?? false) == true, isDefault: (json["IsDefault"] ?? json["ISDEFAULT"] ?? false) == true,
line1: (json["Line1"] ?? json["LINE1"] ?? "") as String, line1: (json["Line1"] ?? json["LINE1"] ?? "").toString(),
line2: (json["Line2"] ?? json["LINE2"] ?? "") as String, line2: (json["Line2"] ?? json["LINE2"] ?? "").toString(),
city: (json["City"] ?? json["CITY"] ?? "") as String, city: (json["City"] ?? json["CITY"] ?? "").toString(),
stateId: (json["StateID"] ?? json["STATEID"] ?? 0) as int, stateId: rawStateId is int ? rawStateId : int.tryParse(rawStateId.toString()) ?? 0,
stateAbbr: (json["StateAbbr"] ?? json["STATEABBR"] ?? "") as String, stateAbbr: (json["StateAbbr"] ?? json["STATEABBR"] ?? "").toString(),
stateName: (json["StateName"] ?? json["STATENAME"] ?? "") as String, stateName: (json["StateName"] ?? json["STATENAME"] ?? "").toString(),
zipCode: (json["ZIPCode"] ?? json["ZIPCODE"] ?? "") as String, zipCode: (json["ZIPCode"] ?? json["ZIPCODE"] ?? "").toString(),
displayText: (json["DisplayText"] ?? json["DISPLAYTEXT"] ?? "") as String, displayText: (json["DisplayText"] ?? json["DISPLAYTEXT"] ?? "").toString(),
); );
} }
} }

View file

@ -0,0 +1,47 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class BeaconCache {
static const _keyBeaconData = 'beacon_cache_data';
static const _keyBeaconTimestamp = 'beacon_cache_timestamp';
static const _cacheDuration = Duration(hours: 24); // Cache for 24 hours
/// Save beacon list to cache
static Future<void> save(Map<String, int> beacons) async {
final prefs = await SharedPreferences.getInstance();
final json = jsonEncode(beacons);
await prefs.setString(_keyBeaconData, json);
await prefs.setInt(_keyBeaconTimestamp, DateTime.now().millisecondsSinceEpoch);
}
/// Load beacon list from cache (returns null if expired or not found)
static Future<Map<String, int>?> load() async {
final prefs = await SharedPreferences.getInstance();
final timestamp = prefs.getInt(_keyBeaconTimestamp);
final data = prefs.getString(_keyBeaconData);
if (timestamp == null || data == null) {
return null;
}
// Check if cache is expired
final cachedTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
if (DateTime.now().difference(cachedTime) > _cacheDuration) {
return null;
}
try {
final decoded = jsonDecode(data) as Map<String, dynamic>;
return decoded.map((k, v) => MapEntry(k, v as int));
} catch (e) {
return null;
}
}
/// Clear the beacon cache
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyBeaconData);
await prefs.remove(_keyBeaconTimestamp);
}
}

View file

@ -0,0 +1,204 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart';
import 'api.dart';
import 'beacon_permissions.dart';
/// Result of a beacon scan
class BeaconScanResult {
final BeaconLookupResult? bestBeacon;
final int beaconsFound;
final String? error;
const BeaconScanResult({
this.bestBeacon,
this.beaconsFound = 0,
this.error,
});
bool get success => error == null;
bool get foundBeacon => bestBeacon != null;
}
/// Global beacon scanner service for rescanning from anywhere in the app
class BeaconScannerService {
static final BeaconScannerService _instance = BeaconScannerService._internal();
factory BeaconScannerService() => _instance;
BeaconScannerService._internal();
bool _isScanning = false;
bool get isScanning => _isScanning;
// Callbacks for UI updates
final _scanStateController = StreamController<bool>.broadcast();
Stream<bool> get scanStateStream => _scanStateController.stream;
/// Perform a beacon scan
/// If [businessId] is provided, only scans for that business's beacons (optimized)
/// Otherwise, scans for all beacons and looks them up
Future<BeaconScanResult> scan({int? businessId}) async {
if (_isScanning) {
return const BeaconScanResult(error: "Scan already in progress");
}
_isScanning = true;
_scanStateController.add(true);
try {
// Request permissions
final granted = await BeaconPermissions.requestPermissions();
if (!granted) {
return const BeaconScanResult(error: "Permissions denied");
}
// Check Bluetooth
final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled();
if (!bluetoothOn) {
return const BeaconScanResult(error: "Bluetooth is off");
}
// Get beacon UUIDs to scan for
Map<String, int> knownBeacons = {};
if (businessId != null && businessId > 0) {
// Optimized: only get beacons for this business
debugPrint('[BeaconScanner] Scanning for business $businessId beacons only');
try {
knownBeacons = await Api.listBeaconsByBusiness(businessId: businessId);
debugPrint('[BeaconScanner] Got ${knownBeacons.length} beacons for business');
} catch (e) {
debugPrint('[BeaconScanner] Failed to get business beacons: $e');
}
}
// Fall back to all beacons if business-specific didn't work
if (knownBeacons.isEmpty) {
debugPrint('[BeaconScanner] Fetching all beacon UUIDs');
try {
knownBeacons = await Api.listAllBeacons();
debugPrint('[BeaconScanner] Got ${knownBeacons.length} total beacons');
} catch (e) {
debugPrint('[BeaconScanner] Failed to fetch beacons: $e');
return BeaconScanResult(error: "Failed to fetch beacons: $e");
}
}
if (knownBeacons.isEmpty) {
return const BeaconScanResult(error: "No beacons configured");
}
// Initialize scanning
await flutterBeacon.initializeScanning;
// Create regions for all known UUIDs
final regions = knownBeacons.keys.map((uuid) {
final formattedUUID = '${uuid.substring(0, 8)}-${uuid.substring(8, 12)}-${uuid.substring(12, 16)}-${uuid.substring(16, 20)}-${uuid.substring(20)}';
return Region(identifier: uuid, proximityUUID: formattedUUID);
}).toList();
// Collect RSSI samples
final Map<String, List<int>> rssiSamples = {};
final Map<String, int> detectionCounts = {};
debugPrint('[BeaconScanner] Starting 2-second scan...');
StreamSubscription<RangingResult>? subscription;
subscription = flutterBeacon.ranging(regions).listen((result) {
for (var beacon in result.beacons) {
final uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', '');
final rssi = beacon.rssi;
rssiSamples.putIfAbsent(uuid, () => []).add(rssi);
detectionCounts[uuid] = (detectionCounts[uuid] ?? 0) + 1;
debugPrint('[BeaconScanner] Found $uuid RSSI=$rssi');
}
});
await Future.delayed(const Duration(milliseconds: 2000));
await subscription.cancel();
if (rssiSamples.isEmpty) {
debugPrint('[BeaconScanner] No beacons detected');
return const BeaconScanResult(beaconsFound: 0);
}
// Lookup found beacons
debugPrint('[BeaconScanner] Looking up ${rssiSamples.length} beacons...');
final uuids = rssiSamples.keys.toList();
List<BeaconLookupResult> lookupResults = [];
try {
lookupResults = await Api.lookupBeacons(uuids);
debugPrint('[BeaconScanner] Server returned ${lookupResults.length} registered beacons');
} catch (e) {
debugPrint('[BeaconScanner] Lookup error: $e');
return BeaconScanResult(
beaconsFound: rssiSamples.length,
error: "Failed to lookup beacons",
);
}
// Find the best registered beacon
final bestBeacon = _findBestBeacon(lookupResults, rssiSamples, detectionCounts);
debugPrint('[BeaconScanner] Best beacon: ${bestBeacon?.beaconName ?? "none"}');
return BeaconScanResult(
bestBeacon: bestBeacon,
beaconsFound: rssiSamples.length,
);
} catch (e) {
debugPrint('[BeaconScanner] Scan error: $e');
return BeaconScanResult(error: e.toString());
} finally {
_isScanning = false;
_scanStateController.add(false);
}
}
/// Find the best registered beacon based on RSSI
BeaconLookupResult? _findBestBeacon(
List<BeaconLookupResult> registeredBeacons,
Map<String, List<int>> rssiSamples,
Map<String, int> detectionCounts,
) {
if (registeredBeacons.isEmpty) return null;
BeaconLookupResult? best;
double bestAvgRssi = -999;
for (final beacon in registeredBeacons) {
final samples = rssiSamples[beacon.uuid];
if (samples == null || samples.isEmpty) continue;
final detections = detectionCounts[beacon.uuid] ?? 0;
if (detections < 2) continue; // Need at least 2 detections
final avgRssi = samples.reduce((a, b) => a + b) / samples.length;
if (avgRssi > bestAvgRssi && avgRssi >= -85) {
bestAvgRssi = avgRssi;
best = beacon;
}
}
// Fall back to strongest if none meet threshold
if (best == null) {
for (final beacon in registeredBeacons) {
final samples = rssiSamples[beacon.uuid];
if (samples == null || samples.isEmpty) continue;
final avgRssi = samples.reduce((a, b) => a + b) / samples.length;
if (avgRssi > bestAvgRssi) {
bestAvgRssi = avgRssi;
best = beacon;
}
}
}
return best;
}
void dispose() {
_scanStateController.close();
}
}

View file

@ -0,0 +1,114 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api.dart';
import '../models/task_type.dart';
/// Centralized preload cache for app startup optimization
class PreloadCache {
static const _keyTaskTypes = 'preload_task_types';
static const _keyTaskTypesTimestamp = 'preload_task_types_ts';
static const _cacheDuration = Duration(hours: 12);
// In-memory cache for current session
static Map<int, List<TaskType>> _taskTypesCache = {};
/// Preload all cacheable data during splash
/// Note: Task types require a business ID, so they're loaded on-demand per business
static Future<void> preloadAll() async {
debugPrint('[PreloadCache] Preload cache initialized');
// Future: Add any global preloads here
}
/// Get or fetch task types for a business
static Future<List<TaskType>> getTaskTypes(int businessId) async {
// Check in-memory cache first
if (_taskTypesCache.containsKey(businessId)) {
return _taskTypesCache[businessId]!;
}
// Check disk cache
final cached = await _loadTaskTypesFromDisk(businessId);
if (cached != null) {
_taskTypesCache[businessId] = cached;
// Refresh in background
_refreshTaskTypesInBackground(businessId);
return cached;
}
// Fetch from server
final types = await Api.getTaskTypes(businessId: businessId);
_taskTypesCache[businessId] = types;
await _saveTaskTypesToDisk(businessId, types);
return types;
}
/// Preload task types for a specific business (call when entering a business)
static Future<void> preloadTaskTypes(int businessId) async {
if (_taskTypesCache.containsKey(businessId)) return;
try {
final types = await Api.getTaskTypes(businessId: businessId);
_taskTypesCache[businessId] = types;
await _saveTaskTypesToDisk(businessId, types);
debugPrint('[PreloadCache] Preloaded ${types.length} task types for business $businessId');
} catch (e) {
debugPrint('[PreloadCache] Failed to preload task types: $e');
}
}
static void _refreshTaskTypesInBackground(int businessId) {
Api.getTaskTypes(businessId: businessId).then((types) {
_taskTypesCache[businessId] = types;
_saveTaskTypesToDisk(businessId, types);
}).catchError((e) {
debugPrint('[PreloadCache] Task types refresh failed: $e');
});
}
static Future<List<TaskType>?> _loadTaskTypesFromDisk(int businessId) async {
final prefs = await SharedPreferences.getInstance();
final key = '${_keyTaskTypes}_$businessId';
final tsKey = '${_keyTaskTypesTimestamp}_$businessId';
final ts = prefs.getInt(tsKey);
final data = prefs.getString(key);
if (ts == null || data == null) return null;
if (DateTime.now().difference(DateTime.fromMillisecondsSinceEpoch(ts)) > _cacheDuration) {
return null;
}
try {
final list = jsonDecode(data) as List;
return list.map((j) => TaskType.fromJson(j as Map<String, dynamic>)).toList();
} catch (e) {
return null;
}
}
static Future<void> _saveTaskTypesToDisk(int businessId, List<TaskType> types) async {
final prefs = await SharedPreferences.getInstance();
final key = '${_keyTaskTypes}_$businessId';
final tsKey = '${_keyTaskTypesTimestamp}_$businessId';
final data = types.map((t) => {
'tasktypeid': t.taskTypeId,
'tasktypename': t.taskTypeName,
'tasktypeicon': t.taskTypeIcon,
}).toList();
await prefs.setString(key, jsonEncode(data));
await prefs.setInt(tsKey, DateTime.now().millisecondsSinceEpoch);
}
/// Clear all caches
static Future<void> clearAll() async {
_taskTypesCache.clear();
final prefs = await SharedPreferences.getInstance();
final keys = prefs.getKeys().where((k) => k.startsWith('preload_'));
for (final key in keys) {
await prefs.remove(key);
}
}
}

View file

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

View file

@ -0,0 +1,239 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app/app_router.dart';
import '../app/app_state.dart';
import '../services/api.dart';
import '../services/beacon_scanner_service.dart';
/// A button that triggers a beacon rescan
/// Can be used anywhere in the app - will use the current business if available
class RescanButton extends StatefulWidget {
final bool showLabel;
final Color? iconColor;
const RescanButton({
super.key,
this.showLabel = false,
this.iconColor,
});
@override
State<RescanButton> createState() => _RescanButtonState();
}
class _RescanButtonState extends State<RescanButton> {
final _scanner = BeaconScannerService();
bool _isScanning = false;
Future<void> _performRescan() async {
if (_isScanning) return;
setState(() => _isScanning = true);
final appState = context.read<AppState>();
final currentBusinessId = appState.selectedBusinessId;
// Show scanning indicator
final scaffold = ScaffoldMessenger.of(context);
scaffold.showSnackBar(
const SnackBar(
content: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
),
SizedBox(width: 12),
Text('Scanning for your table...'),
],
),
duration: Duration(seconds: 3),
),
);
try {
// Use current business for optimized scan if available
final result = await _scanner.scan(businessId: currentBusinessId);
if (!mounted) return;
scaffold.hideCurrentSnackBar();
if (result.foundBeacon) {
final beacon = result.bestBeacon!;
// Check if it's the same business/table or a new one
// Also check if the beacon's business matches our parent (food court scenario)
final isSameLocation = (beacon.businessId == currentBusinessId &&
beacon.servicePointId == appState.selectedServicePointId);
final isSameParentLocation = (appState.hasParentBusiness &&
beacon.businessId == appState.parentBusinessId &&
beacon.servicePointId == appState.selectedServicePointId);
if (isSameLocation || isSameParentLocation) {
// Same location - just confirm
scaffold.showSnackBar(
SnackBar(
content: Text('Still at ${appState.selectedServicePointName ?? beacon.servicePointName}'),
duration: const Duration(seconds: 2),
),
);
} else {
// Different location - ask to switch
_showSwitchDialog(beacon);
}
} else if (result.beaconsFound > 0) {
scaffold.showSnackBar(
SnackBar(
content: Text('Found ${result.beaconsFound} beacon(s) but none registered'),
duration: const Duration(seconds: 2),
),
);
} else {
scaffold.showSnackBar(
const SnackBar(
content: Text('No beacons detected nearby'),
duration: Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
scaffold.hideCurrentSnackBar();
scaffold.showSnackBar(
SnackBar(
content: Text('Scan failed: $e'),
duration: const Duration(seconds: 2),
),
);
}
} finally {
if (mounted) {
setState(() => _isScanning = false);
}
}
}
void _showSwitchDialog(BeaconLookupResult beacon) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('New Location Detected'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('You appear to be at:'),
const SizedBox(height: 8),
Text(
beacon.businessName,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
if (beacon.servicePointName.isNotEmpty)
Text(
beacon.servicePointName,
style: TextStyle(color: Colors.grey.shade600),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Stay Here'),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
_switchToBeacon(beacon);
},
child: const Text('Switch'),
),
],
),
);
}
Future<void> _switchToBeacon(BeaconLookupResult beacon) async {
final appState = context.read<AppState>();
// Handle parent business (food court scenario)
if (beacon.hasChildren) {
try {
final children = await Api.getChildBusinesses(businessId: beacon.businessId);
if (!mounted) return;
if (children.isNotEmpty) {
Navigator.of(context).pushReplacementNamed(
AppRoutes.businessSelector,
arguments: {
'parentBusinessId': beacon.businessId,
'parentBusinessName': beacon.businessName,
'servicePointId': beacon.servicePointId,
'servicePointName': beacon.servicePointName,
'children': children,
},
);
return;
}
} catch (e) {
debugPrint('[Rescan] Error fetching children: $e');
}
}
// Single business - update state and navigate
appState.setBusinessAndServicePoint(
beacon.businessId,
beacon.servicePointId,
businessName: beacon.businessName,
servicePointName: beacon.servicePointName,
parentBusinessId: beacon.hasParent ? beacon.parentBusinessId : null,
parentBusinessName: beacon.hasParent ? beacon.parentBusinessName : null,
);
appState.setOrderType(OrderType.dineIn);
Api.setBusinessId(beacon.businessId);
if (!mounted) return;
Navigator.of(context).pushReplacementNamed(
AppRoutes.menuBrowse,
arguments: {
'businessId': beacon.businessId,
'servicePointId': beacon.servicePointId,
},
);
}
@override
Widget build(BuildContext context) {
if (widget.showLabel) {
return TextButton.icon(
onPressed: _isScanning ? null : _performRescan,
icon: _isScanning
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(Icons.bluetooth_searching, color: widget.iconColor),
label: Text(
_isScanning ? 'Scanning...' : 'Find My Table',
style: TextStyle(color: widget.iconColor),
),
);
}
return IconButton(
icon: _isScanning
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(Icons.bluetooth_searching, color: widget.iconColor),
tooltip: 'Find My Table',
onPressed: _isScanning ? null : _performRescan,
);
}
}

View file

@ -0,0 +1,404 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../app/app_state.dart';
import '../services/api.dart';
import '../services/auth_storage.dart';
/// A dialog that handles phone number + OTP sign-in inline.
/// Returns true if sign-in was successful, false if cancelled.
class SignInDialog extends StatefulWidget {
const SignInDialog({super.key});
/// Shows the sign-in dialog and returns true if authenticated successfully
static Future<bool> show(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => const SignInDialog(),
);
return result ?? false;
}
@override
State<SignInDialog> createState() => _SignInDialogState();
}
enum _SignInStep { phone, otp }
class _SignInDialogState extends State<SignInDialog> {
_SignInStep _currentStep = _SignInStep.phone;
final _phoneController = TextEditingController();
final _otpController = TextEditingController();
final _phoneFocus = FocusNode();
final _otpFocus = FocusNode();
String _uuid = '';
String _phone = '';
bool _isLoading = false;
String? _errorMessage;
@override
void initState() {
super.initState();
// Auto-focus the phone field when dialog opens
WidgetsBinding.instance.addPostFrameCallback((_) {
_phoneFocus.requestFocus();
});
}
@override
void dispose() {
_phoneController.dispose();
_otpController.dispose();
_phoneFocus.dispose();
_otpFocus.dispose();
super.dispose();
}
String _formatPhoneNumber(String input) {
final digits = input.replaceAll(RegExp(r'[^\d]'), '');
if (digits.length == 11 && digits.startsWith('1')) {
return digits.substring(1);
}
return digits;
}
String _formatPhoneDisplay(String phone) {
if (phone.length == 10) {
return '(${phone.substring(0, 3)}) ${phone.substring(3, 6)}-${phone.substring(6)}';
}
return phone;
}
Future<void> _handleSendOtp() async {
final phone = _formatPhoneNumber(_phoneController.text);
if (phone.length != 10) {
setState(() {
_errorMessage = 'Please enter a valid 10-digit phone number';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await Api.sendLoginOtp(phone: phone);
if (!mounted) return;
if (response.uuid.isEmpty) {
setState(() {
_errorMessage = 'Server error - please try again';
_isLoading = false;
});
return;
}
setState(() {
_uuid = response.uuid;
_phone = phone;
_currentStep = _SignInStep.otp;
_isLoading = false;
});
// Auto-focus the OTP field
WidgetsBinding.instance.addPostFrameCallback((_) {
_otpFocus.requestFocus();
});
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = e.toString().replaceFirst('StateError: ', '');
_isLoading = false;
});
}
}
Future<void> _handleVerifyOtp() async {
if (_uuid.isEmpty) {
setState(() {
_errorMessage = 'Session expired. Please go back and try again.';
});
return;
}
final otp = _otpController.text.trim();
if (otp.length != 6) {
setState(() {
_errorMessage = 'Please enter the 6-digit code';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await Api.verifyLoginOtp(uuid: _uuid, otp: otp);
if (!mounted) return;
// Save credentials for persistent login
await AuthStorage.saveAuth(
userId: response.userId,
token: response.token,
);
// Update app state
final appState = context.read<AppState>();
appState.setUserId(response.userId);
// Close dialog with success
Navigator.of(context).pop(true);
// Show welcome message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Welcome${response.userFirstName.isNotEmpty ? ', ${response.userFirstName}' : ''}!',
style: const TextStyle(color: Colors.black),
),
backgroundColor: const Color(0xFF90EE90),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
),
);
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = e.toString().replaceFirst('StateError: ', '');
_isLoading = false;
});
}
}
Future<void> _handleResendOtp() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await Api.sendLoginOtp(phone: _phone);
if (!mounted) return;
setState(() {
_uuid = response.uuid;
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('New code sent!', style: TextStyle(color: Colors.black)),
backgroundColor: Color(0xFF90EE90),
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = e.toString().replaceFirst('StateError: ', '');
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Row(
children: [
Expanded(
child: Text(
_currentStep == _SignInStep.phone ? 'Sign In to Continue' : 'Enter Code',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: _isLoading ? null : () => Navigator.of(context).pop(false),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 8),
Text(
_currentStep == _SignInStep.phone
? 'Enter your phone number to add items to your cart'
: 'We sent a code to ${_formatPhoneDisplay(_phone)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 24),
// Form content
if (_currentStep == _SignInStep.phone) _buildPhoneStep(),
if (_currentStep == _SignInStep.otp) _buildOtpStep(),
// Error message
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade600, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(color: Colors.red.shade800, fontSize: 13),
),
),
],
),
),
],
],
),
),
);
}
Widget _buildPhoneStep() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _phoneController,
focusNode: _phoneFocus,
decoration: InputDecoration(
labelText: 'Phone Number',
hintText: '(555) 123-4567',
hintStyle: TextStyle(color: Colors.grey.shade400),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.phone),
prefixText: '+1 ',
),
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
enabled: !_isLoading,
onFieldSubmitted: (_) => _handleSendOtp(),
),
const SizedBox(height: 20),
FilledButton(
onPressed: _isLoading ? null : _handleSendOtp,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text('Send Code'),
),
],
);
}
Widget _buildOtpStep() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _otpController,
focusNode: _otpFocus,
decoration: InputDecoration(
labelText: 'Verification Code',
hintText: '123456',
hintStyle: TextStyle(color: Colors.grey.shade400),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock),
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 20,
letterSpacing: 6,
fontWeight: FontWeight.bold,
),
enabled: !_isLoading,
onFieldSubmitted: (_) => _handleVerifyOtp(),
),
const SizedBox(height: 20),
FilledButton(
onPressed: _isLoading ? null : _handleVerifyOtp,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text('Verify & Continue'),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: _isLoading ? null : _handleResendOtp,
child: const Text('Resend Code'),
),
const SizedBox(width: 8),
TextButton(
onPressed: _isLoading
? null
: () {
setState(() {
_currentStep = _SignInStep.phone;
_otpController.clear();
_errorMessage = null;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_phoneFocus.requestFocus();
});
},
child: const Text('Change Number'),
),
],
),
],
);
}
}

View file

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

View file

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

View file

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