Compare commits
10 commits
9a489f20bb
...
821eae4170
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
821eae4170 | ||
|
|
b47c68b63a | ||
|
|
28e41a445e | ||
|
|
ce8cc74e96 | ||
|
|
c4792189dd | ||
|
|
5033e751ab | ||
|
|
213bc5e401 | ||
|
|
e8c99844f8 | ||
|
|
d111833aab | ||
|
|
f6428a14ff |
33 changed files with 3103 additions and 1092 deletions
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>))
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
239
lib/models/task_type.dart
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
435
lib/screens/business_selector_screen.dart
Normal file
435
lib/screens/business_selector_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,25 @@ import '../services/chat_service.dart';
|
||||||
import '../services/auth_storage.dart';
|
import '../services/auth_storage.dart';
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
final int taskId;
|
final int? taskId; // null if task needs to be created
|
||||||
final String userType; // 'customer' or 'worker'
|
final String userType; // 'customer' or 'worker'
|
||||||
final String? otherPartyName;
|
final String? otherPartyName;
|
||||||
|
|
||||||
|
// Required for creating task when taskId is null
|
||||||
|
final int? businessId;
|
||||||
|
final int? servicePointId;
|
||||||
|
final int? orderId;
|
||||||
|
final int? userId;
|
||||||
|
|
||||||
const ChatScreen({
|
const ChatScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.taskId,
|
this.taskId,
|
||||||
required this.userType,
|
required this.userType,
|
||||||
this.otherPartyName,
|
this.otherPartyName,
|
||||||
|
this.businessId,
|
||||||
|
this.servicePointId,
|
||||||
|
this.orderId,
|
||||||
|
this.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -28,6 +38,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
final TextEditingController _messageController = TextEditingController();
|
final TextEditingController _messageController = TextEditingController();
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
final List<ChatMessage> _messages = [];
|
final List<ChatMessage> _messages = [];
|
||||||
|
final List<String> _pendingMessages = []; // Messages queued before task created
|
||||||
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isConnecting = false;
|
bool _isConnecting = false;
|
||||||
|
|
@ -36,6 +47,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
String? _otherUserName;
|
String? _otherUserName;
|
||||||
String? _error;
|
String? _error;
|
||||||
bool _chatEnded = false;
|
bool _chatEnded = false;
|
||||||
|
bool _isCreatingTask = false; // True while creating task in background
|
||||||
|
int? _taskId; // Actual task ID (may be null initially)
|
||||||
|
|
||||||
StreamSubscription<ChatMessage>? _messageSubscription;
|
StreamSubscription<ChatMessage>? _messageSubscription;
|
||||||
StreamSubscription<TypingEvent>? _typingSubscription;
|
StreamSubscription<TypingEvent>? _typingSubscription;
|
||||||
|
|
@ -47,16 +60,70 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_otherUserName = widget.otherPartyName;
|
_otherUserName = widget.otherPartyName;
|
||||||
|
_taskId = widget.taskId;
|
||||||
_initializeChat();
|
_initializeChat();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initializeChat() async {
|
Future<void> _initializeChat() async {
|
||||||
// Ensure auth is loaded first before any API calls
|
// Ensure auth is loaded first before any API calls
|
||||||
await _ensureAuth();
|
await _ensureAuth();
|
||||||
|
|
||||||
|
// If no taskId provided, we need to create the task
|
||||||
|
if (_taskId == null) {
|
||||||
|
setState(() {
|
||||||
|
_isCreatingTask = true;
|
||||||
|
_isLoading = false; // Allow user to see chat UI immediately
|
||||||
|
});
|
||||||
|
await _createTask();
|
||||||
|
} else {
|
||||||
// Then load messages and connect
|
// Then load messages and connect
|
||||||
await _loadMessages();
|
await _loadMessages();
|
||||||
_connectToChat();
|
_connectToChat();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createTask() async {
|
||||||
|
try {
|
||||||
|
final taskId = await Api.createChatTask(
|
||||||
|
businessId: widget.businessId!,
|
||||||
|
servicePointId: widget.servicePointId!,
|
||||||
|
orderId: widget.orderId,
|
||||||
|
userId: widget.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_taskId = taskId;
|
||||||
|
_isCreatingTask = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now load messages and connect
|
||||||
|
await _loadMessages();
|
||||||
|
_connectToChat();
|
||||||
|
|
||||||
|
// Send any pending messages that were queued
|
||||||
|
_sendPendingMessages();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Failed to start chat: $e';
|
||||||
|
_isCreatingTask = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendPendingMessages() async {
|
||||||
|
if (_pendingMessages.isEmpty || _taskId == null) return;
|
||||||
|
|
||||||
|
final messages = List<String>.from(_pendingMessages);
|
||||||
|
_pendingMessages.clear();
|
||||||
|
|
||||||
|
for (final text in messages) {
|
||||||
|
await _sendMessageText(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _ensureAuth() async {
|
Future<void> _ensureAuth() async {
|
||||||
if (Api.authToken == null || Api.authToken!.isEmpty) {
|
if (Api.authToken == null || Api.authToken!.isEmpty) {
|
||||||
|
|
@ -81,13 +148,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadMessages() async {
|
Future<void> _loadMessages() async {
|
||||||
|
if (_taskId == null) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await Api.getChatMessages(taskId: widget.taskId);
|
final result = await Api.getChatMessages(taskId: _taskId!);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final wasClosed = result.chatClosed && !_chatEnded;
|
final wasClosed = result.chatClosed && !_chatEnded;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -138,7 +207,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
// Mark as read if from the other party
|
// Mark as read if from the other party
|
||||||
if (message.senderType != widget.userType) {
|
if (message.senderType != widget.userType) {
|
||||||
Api.markChatMessagesRead(
|
Api.markChatMessagesRead(
|
||||||
taskId: widget.taskId,
|
taskId: _taskId!,
|
||||||
readerType: widget.userType,
|
readerType: widget.userType,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +283,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
final connected = await _chatService.connect(
|
final connected = await _chatService.connect(
|
||||||
taskId: widget.taskId,
|
taskId: _taskId!,
|
||||||
userToken: token,
|
userToken: token,
|
||||||
userType: widget.userType,
|
userType: widget.userType,
|
||||||
);
|
);
|
||||||
|
|
@ -237,15 +306,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pollNewMessages() async {
|
Future<void> _pollNewMessages() async {
|
||||||
|
if (_taskId == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final lastMessageId = _messages.isNotEmpty ? _messages.last.messageId : 0;
|
final lastMessageId = _messages.isNotEmpty ? _messages.last.messageId : 0;
|
||||||
final result = await Api.getChatMessages(
|
final result = await Api.getChatMessages(
|
||||||
taskId: widget.taskId,
|
taskId: _taskId!,
|
||||||
afterMessageId: lastMessageId,
|
afterMessageId: lastMessageId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
// Check if chat has been closed by worker
|
// Check if chat has been closed (by worker or system auto-close)
|
||||||
if (result.chatClosed && !_chatEnded) {
|
if (result.chatClosed && !_chatEnded) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_chatEnded = true;
|
_chatEnded = true;
|
||||||
|
|
@ -253,7 +324,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
_pollTimer?.cancel();
|
_pollTimer?.cancel();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: const Text('This chat has been closed by staff', style: TextStyle(color: Colors.black)),
|
content: const Text('This chat has ended', style: TextStyle(color: Colors.black)),
|
||||||
backgroundColor: const Color(0xFF90EE90),
|
backgroundColor: const Color(0xFF90EE90),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
|
|
@ -307,18 +378,43 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
final text = _messageController.text.trim();
|
final text = _messageController.text.trim();
|
||||||
if (text.isEmpty || _isSending || _chatEnded) return;
|
if (text.isEmpty || _isSending || _chatEnded) return;
|
||||||
|
|
||||||
setState(() => _isSending = true);
|
_messageController.clear();
|
||||||
_chatService.setTyping(false);
|
_chatService.setTyping(false);
|
||||||
|
|
||||||
|
// If task not created yet, queue the message
|
||||||
|
if (_taskId == null) {
|
||||||
|
setState(() {
|
||||||
|
_pendingMessages.add(text);
|
||||||
|
// Add optimistic message to UI
|
||||||
|
_messages.add(ChatMessage(
|
||||||
|
messageId: -(_pendingMessages.length), // Negative ID for pending
|
||||||
|
taskId: 0,
|
||||||
|
senderUserId: widget.userId ?? 0,
|
||||||
|
senderType: widget.userType,
|
||||||
|
senderName: 'Me',
|
||||||
|
text: text,
|
||||||
|
createdOn: DateTime.now(),
|
||||||
|
isRead: false,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
_scrollToBottom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _sendMessageText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendMessageText(String text) async {
|
||||||
|
if (_taskId == null) return;
|
||||||
|
|
||||||
|
setState(() => _isSending = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bool sentViaWebSocket = false;
|
bool sentViaWebSocket = false;
|
||||||
|
|
||||||
if (_chatService.isConnected) {
|
if (_chatService.isConnected) {
|
||||||
// Try to send via WebSocket
|
// Try to send via WebSocket
|
||||||
sentViaWebSocket = _chatService.sendMessage(text);
|
sentViaWebSocket = _chatService.sendMessage(text);
|
||||||
if (sentViaWebSocket) {
|
|
||||||
_messageController.clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sentViaWebSocket) {
|
if (!sentViaWebSocket) {
|
||||||
|
|
@ -331,12 +427,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
await Api.sendChatMessage(
|
await Api.sendChatMessage(
|
||||||
taskId: widget.taskId,
|
taskId: _taskId!,
|
||||||
message: text,
|
message: text,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
senderType: widget.userType,
|
senderType: widget.userType,
|
||||||
);
|
);
|
||||||
_messageController.clear();
|
|
||||||
|
|
||||||
// Refresh messages since we used HTTP
|
// Refresh messages since we used HTTP
|
||||||
await _loadMessages();
|
await _loadMessages();
|
||||||
|
|
@ -344,6 +439,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final message = e.toString().replaceAll('StateError: ', '');
|
final message = e.toString().replaceAll('StateError: ', '');
|
||||||
|
// Check if chat was closed - update state and show appropriate message
|
||||||
|
if (message.contains('chat has ended') || message.contains('chat_closed')) {
|
||||||
|
setState(() {
|
||||||
|
_chatEnded = true;
|
||||||
|
});
|
||||||
|
_pollTimer?.cancel();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('This chat has ended', style: TextStyle(color: Colors.black)),
|
||||||
|
backgroundColor: const Color(0xFF90EE90),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to send: $message'),
|
content: Text('Failed to send: $message'),
|
||||||
|
|
@ -351,6 +461,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isSending = false);
|
setState(() => _isSending = false);
|
||||||
|
|
@ -462,6 +573,33 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
|
if (_isCreatingTask)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
color: Colors.blue.shade50,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.blue.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'Finding available staff...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.blue.shade700,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
if (_chatEnded)
|
if (_chatEnded)
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,12 @@ import "../app/app_router.dart";
|
||||||
import "../app/app_state.dart";
|
import "../app/app_state.dart";
|
||||||
import "../models/cart.dart";
|
import "../models/cart.dart";
|
||||||
import "../models/menu_item.dart";
|
import "../models/menu_item.dart";
|
||||||
|
import "../models/task_type.dart";
|
||||||
import "../services/api.dart";
|
import "../services/api.dart";
|
||||||
import "../services/auth_storage.dart";
|
import "../services/auth_storage.dart";
|
||||||
|
import "../services/preload_cache.dart";
|
||||||
|
import "../widgets/rescan_button.dart";
|
||||||
|
import "../widgets/sign_in_dialog.dart";
|
||||||
import "chat_screen.dart";
|
import "chat_screen.dart";
|
||||||
|
|
||||||
class MenuBrowseScreen extends StatefulWidget {
|
class MenuBrowseScreen extends StatefulWidget {
|
||||||
|
|
@ -31,6 +35,12 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
// Track which category is currently expanded (null = none)
|
// Track which category is currently expanded (null = none)
|
||||||
int? _expandedCategoryId;
|
int? _expandedCategoryId;
|
||||||
|
|
||||||
|
// Task types for bell icon - fetched on load
|
||||||
|
List<TaskType> _taskTypes = [];
|
||||||
|
|
||||||
|
// Brand color for HUD gradients (decorative, from business settings)
|
||||||
|
Color _brandColor = const Color(0xFF1B4D3E); // Default forest green
|
||||||
|
|
||||||
int? _asIntNullable(dynamic v) {
|
int? _asIntNullable(dynamic v) {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
if (v is int) return v;
|
if (v is int) return v;
|
||||||
|
|
@ -60,9 +70,17 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
final u = appState.userId;
|
final u = appState.userId;
|
||||||
|
|
||||||
final args = ModalRoute.of(context)?.settings.arguments;
|
final args = ModalRoute.of(context)?.settings.arguments;
|
||||||
|
int? b;
|
||||||
|
int? sp;
|
||||||
|
|
||||||
if (args is Map) {
|
if (args is Map) {
|
||||||
final b = _asIntNullable(args["BusinessID"]) ?? _asIntNullable(args["businessId"]);
|
b = _asIntNullable(args["BusinessID"]) ?? _asIntNullable(args["businessId"]);
|
||||||
final sp = _asIntNullable(args["ServicePointID"]) ?? _asIntNullable(args["servicePointId"]);
|
sp = _asIntNullable(args["ServicePointID"]) ?? _asIntNullable(args["servicePointId"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to AppState if args don't have businessId
|
||||||
|
b ??= appState.selectedBusinessId;
|
||||||
|
sp ??= appState.selectedServicePointId;
|
||||||
|
|
||||||
if (_businessId != b || _servicePointId != sp || _userId != u || _future == null) {
|
if (_businessId != b || _servicePointId != sp || _userId != u || _future == null) {
|
||||||
_businessId = b;
|
_businessId = b;
|
||||||
|
|
@ -76,37 +94,114 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<MenuItem>> _loadMenu() async {
|
Future<List<MenuItem>> _loadMenu() async {
|
||||||
final items = await Api.listMenuItems(businessId: _businessId!);
|
// Preload task types in background (for faster service bell response)
|
||||||
|
_preloadTaskTypes();
|
||||||
|
|
||||||
|
final result = await Api.listMenuItems(businessId: _businessId!);
|
||||||
|
|
||||||
|
// If no menu items, check for child businesses
|
||||||
|
if (result.items.isEmpty) {
|
||||||
|
final children = await Api.getChildBusinesses(businessId: _businessId!);
|
||||||
|
if (children.isNotEmpty && mounted) {
|
||||||
|
// Navigate to business selector with the children
|
||||||
|
final appState = context.read<AppState>();
|
||||||
|
Navigator.of(context).pushReplacementNamed(
|
||||||
|
AppRoutes.businessSelector,
|
||||||
|
arguments: {
|
||||||
|
'parentBusinessId': _businessId,
|
||||||
|
'parentBusinessName': appState.selectedBusinessName,
|
||||||
|
'servicePointId': _servicePointId,
|
||||||
|
'servicePointName': appState.selectedServicePointName,
|
||||||
|
'children': children,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return []; // Return empty, we're navigating away
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse brand color if provided - use as default HUD color
|
||||||
|
if (result.brandColor != null && result.brandColor!.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final hex = result.brandColor!.replaceFirst('#', '');
|
||||||
|
_brandColor = Color(int.parse('FF$hex', radix: 16));
|
||||||
|
// Also store in AppState for other screens
|
||||||
|
if (mounted) {
|
||||||
|
context.read<AppState>().setBrandColor(result.brandColor);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Keep default color on parse error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_allItems = items;
|
_allItems = result.items;
|
||||||
_organizeItems();
|
_organizeItems();
|
||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
return result.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isCallingServer = false;
|
bool _isCallingServer = false;
|
||||||
|
|
||||||
/// Show bottom sheet with choice: Server Visit or Chat (dine-in) or just Chat (non-dine-in)
|
/// Preload task types in background for faster service bell response
|
||||||
|
Future<void> _preloadTaskTypes() async {
|
||||||
|
if (_businessId == null) return;
|
||||||
|
try {
|
||||||
|
// Use PreloadCache for faster response (may be cached from previous visits)
|
||||||
|
final types = await PreloadCache.getTaskTypes(_businessId!);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _taskTypes = types);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[MenuBrowse] Failed to preload task types: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show bottom sheet with dynamic task type options
|
||||||
Future<void> _handleCallServer(AppState appState) async {
|
Future<void> _handleCallServer(AppState appState) async {
|
||||||
if (_businessId == null) return;
|
if (_businessId == null) return;
|
||||||
|
|
||||||
// For non-dine-in without a service point, use 0 as placeholder
|
// For non-dine-in without a service point, use 0 as placeholder
|
||||||
final servicePointId = _servicePointId ?? 0;
|
final servicePointId = _servicePointId ?? 0;
|
||||||
|
final userId = appState.userId;
|
||||||
|
|
||||||
// Check for active chat first
|
// Use preloaded task types if available, otherwise fetch from cache
|
||||||
int? activeTaskId;
|
int? activeTaskId;
|
||||||
|
List<TaskType> taskTypes = _taskTypes;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
debugPrint('[MenuBrowse] Checking for active chat: businessId=$_businessId, servicePointId=$servicePointId, userId=$userId');
|
||||||
|
|
||||||
|
// Only fetch task types if not preloaded, but always check for active chat
|
||||||
|
if (taskTypes.isEmpty) {
|
||||||
|
final results = await Future.wait([
|
||||||
|
PreloadCache.getTaskTypes(_businessId!),
|
||||||
|
Api.getActiveChat(businessId: _businessId!, servicePointId: servicePointId, userId: userId)
|
||||||
|
.then<int?>((v) => v)
|
||||||
|
.catchError((e) {
|
||||||
|
debugPrint('[MenuBrowse] getActiveChat error: $e');
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
taskTypes = results[0] as List<TaskType>;
|
||||||
|
activeTaskId = results[1] as int?;
|
||||||
|
} else {
|
||||||
|
// Task types preloaded, just check for active chat
|
||||||
activeTaskId = await Api.getActiveChat(
|
activeTaskId = await Api.getActiveChat(
|
||||||
businessId: _businessId!,
|
businessId: _businessId!,
|
||||||
servicePointId: servicePointId,
|
servicePointId: servicePointId,
|
||||||
);
|
userId: userId,
|
||||||
|
).catchError((e) {
|
||||||
|
debugPrint('[MenuBrowse] getActiveChat error: $e');
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
debugPrint('[MenuBrowse] Got ${taskTypes.length} task types, activeTaskId=$activeTaskId');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Continue without active chat
|
// Fall back to showing default options if API fails
|
||||||
|
debugPrint('[MenuBrowse] Failed to fetch task types: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -138,25 +233,39 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Only show "Request Server Visit" for dine-in orders
|
// Build task type options dynamically
|
||||||
if (isDineIn && _servicePointId != null) ...[
|
..._buildTaskTypeOptions(taskTypes, appState, isDineIn, activeTaskId),
|
||||||
ListTile(
|
|
||||||
leading: const CircleAvatar(
|
|
||||||
backgroundColor: Colors.orange,
|
|
||||||
child: Icon(Icons.room_service, color: Colors.white),
|
|
||||||
),
|
|
||||||
title: const Text('Request Server Visit'),
|
|
||||||
subtitle: const Text('Staff will come to your table'),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_sendServerRequest(appState);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
],
|
],
|
||||||
// Show either "Rejoin Chat" OR "Chat with Staff" - never both
|
),
|
||||||
if (activeTaskId != null)
|
),
|
||||||
ListTile(
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build list tiles for each task type
|
||||||
|
List<Widget> _buildTaskTypeOptions(
|
||||||
|
List<TaskType> taskTypes,
|
||||||
|
AppState appState,
|
||||||
|
bool isDineIn,
|
||||||
|
int? activeTaskId,
|
||||||
|
) {
|
||||||
|
debugPrint('[MenuBrowse] _buildTaskTypeOptions called with ${taskTypes.length} task types, activeTaskId=$activeTaskId');
|
||||||
|
final widgets = <Widget>[];
|
||||||
|
|
||||||
|
for (final taskType in taskTypes) {
|
||||||
|
// Chat task type - identified by icon ('chat' or 'message') or name containing 'chat'
|
||||||
|
final iconLower = taskType.taskTypeIcon.toLowerCase();
|
||||||
|
final isChat = iconLower == 'chat' ||
|
||||||
|
iconLower == 'message' ||
|
||||||
|
iconLower.contains('chat') ||
|
||||||
|
iconLower.contains('message') ||
|
||||||
|
taskType.taskTypeName.toLowerCase().contains('chat');
|
||||||
|
debugPrint('[MenuBrowse] TaskType: id=${taskType.taskTypeId}, name="${taskType.taskTypeName}", icon="${taskType.taskTypeIcon}", isChat=$isChat');
|
||||||
|
if (isChat) {
|
||||||
|
debugPrint('[MenuBrowse] Building chat option: activeTaskId=$activeTaskId');
|
||||||
|
if (activeTaskId != null) {
|
||||||
|
debugPrint('[MenuBrowse] Showing REJOIN option');
|
||||||
|
widgets.add(ListTile(
|
||||||
leading: const CircleAvatar(
|
leading: const CircleAvatar(
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
child: Icon(Icons.chat_bubble, color: Colors.white),
|
child: Icon(Icons.chat_bubble, color: Colors.white),
|
||||||
|
|
@ -164,28 +273,112 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
title: const Text('Rejoin Chat'),
|
title: const Text('Rejoin Chat'),
|
||||||
subtitle: const Text('Continue your conversation'),
|
subtitle: const Text('Continue your conversation'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
debugPrint('[MenuBrowse] Rejoin tapped');
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_rejoinChat(activeTaskId!);
|
_rejoinChat(activeTaskId);
|
||||||
},
|
},
|
||||||
)
|
));
|
||||||
else
|
} else {
|
||||||
ListTile(
|
debugPrint('[MenuBrowse] Showing START option');
|
||||||
leading: const CircleAvatar(
|
widgets.add(ListTile(
|
||||||
backgroundColor: Colors.blue,
|
leading: CircleAvatar(
|
||||||
child: Icon(Icons.chat, color: Colors.white),
|
backgroundColor: taskType.iconColor,
|
||||||
|
child: Icon(taskType.icon, color: Colors.white),
|
||||||
),
|
),
|
||||||
title: const Text('Chat with Staff'),
|
title: Text(taskType.taskTypeName),
|
||||||
subtitle: const Text('Send a message to our team'),
|
subtitle: Text(taskType.taskTypeDescription.isNotEmpty
|
||||||
|
? taskType.taskTypeDescription
|
||||||
|
: 'Send a message to our team'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
debugPrint('[MenuBrowse] Start chat tapped');
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_startChat(appState);
|
_startChat(appState);
|
||||||
},
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
widgets.add(const Divider());
|
||||||
|
}
|
||||||
|
// All other task types - only show for dine-in with service point
|
||||||
|
else if (isDineIn && _servicePointId != null) {
|
||||||
|
widgets.add(ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: taskType.iconColor,
|
||||||
|
child: Icon(taskType.icon, color: Colors.white),
|
||||||
),
|
),
|
||||||
|
title: Text(taskType.taskTypeName),
|
||||||
|
subtitle: Text(taskType.taskTypeDescription.isNotEmpty
|
||||||
|
? taskType.taskTypeDescription
|
||||||
|
: 'Request this service'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_sendTaskRequest(appState, taskType);
|
||||||
|
},
|
||||||
|
));
|
||||||
|
widgets.add(const Divider());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing divider if present
|
||||||
|
if (widgets.isNotEmpty && widgets.last is Divider) {
|
||||||
|
widgets.removeLast();
|
||||||
|
}
|
||||||
|
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a task request (server call, pay cash, custom, etc.)
|
||||||
|
Future<void> _sendTaskRequest(AppState appState, TaskType taskType) async {
|
||||||
|
if (_isCallingServer) return;
|
||||||
|
setState(() => _isCallingServer = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Api.callServer(
|
||||||
|
businessId: _businessId!,
|
||||||
|
servicePointId: _servicePointId!,
|
||||||
|
orderId: appState.cartOrderId,
|
||||||
|
userId: appState.userId,
|
||||||
|
message: taskType.taskTypeName,
|
||||||
|
taskTypeId: taskType.taskTypeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
Icon(taskType.icon, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text("${taskType.taskTypeName} requested", style: const TextStyle(color: Colors.white))),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
backgroundColor: taskType.color,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error, color: Colors.black),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text("Failed to send request: $e", style: const TextStyle(color: Colors.black))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red.shade100,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isCallingServer = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if user is logged in, prompt login if not
|
/// Check if user is logged in, prompt login if not
|
||||||
|
|
@ -226,8 +419,13 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
|
|
||||||
/// Rejoin an existing active chat
|
/// Rejoin an existing active chat
|
||||||
Future<void> _rejoinChat(int taskId) async {
|
Future<void> _rejoinChat(int taskId) async {
|
||||||
if (!await _ensureLoggedIn()) return;
|
debugPrint('[MenuBrowse] Rejoining chat: taskId=$taskId');
|
||||||
|
if (!await _ensureLoggedIn()) {
|
||||||
|
debugPrint('[MenuBrowse] Login required for rejoin - user cancelled or failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[MenuBrowse] Navigating to chat screen');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
|
|
@ -240,115 +438,56 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a server visit request (ping)
|
|
||||||
Future<void> _sendServerRequest(AppState appState) async {
|
|
||||||
if (_isCallingServer) return;
|
|
||||||
setState(() => _isCallingServer = true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Api.callServer(
|
|
||||||
businessId: _businessId!,
|
|
||||||
servicePointId: _servicePointId!,
|
|
||||||
orderId: appState.cartOrderId,
|
|
||||||
userId: appState.userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: const Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.check_circle, color: Colors.black),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Expanded(child: Text("Server has been notified", style: TextStyle(color: Colors.black))),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
backgroundColor: const Color(0xFF90EE90),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.error, color: Colors.black),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(child: Text("Failed to call server: $e", style: const TextStyle(color: Colors.black))),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
backgroundColor: Colors.red.shade100,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _isCallingServer = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start a new chat with staff
|
/// Start a new chat with staff
|
||||||
Future<void> _startChat(AppState appState) async {
|
Future<void> _startChat(AppState appState) async {
|
||||||
if (_isCallingServer) return;
|
if (_isCallingServer) return;
|
||||||
|
|
||||||
|
// Check we have required info - businessId is always required
|
||||||
|
if (_businessId == null) {
|
||||||
|
debugPrint('[MenuBrowse] Cannot start chat - missing businessId');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Unable to start chat - please try again'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check login first
|
// Check login first
|
||||||
if (!await _ensureLoggedIn()) return;
|
if (!await _ensureLoggedIn()) {
|
||||||
|
debugPrint('[MenuBrowse] Login cancelled or failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _isCallingServer = true);
|
// Get userId for chat screen
|
||||||
|
|
||||||
try {
|
|
||||||
// Reload auth to get userId
|
|
||||||
final auth = await AuthStorage.loadAuth();
|
final auth = await AuthStorage.loadAuth();
|
||||||
final userId = auth?.userId;
|
final userId = auth?.userId;
|
||||||
|
|
||||||
// Create new chat
|
// For non-dine-in users without a service point, use 0
|
||||||
final taskId = await Api.createChatTask(
|
final servicePointId = _servicePointId ?? 0;
|
||||||
businessId: _businessId!,
|
|
||||||
servicePointId: _servicePointId!,
|
debugPrint('[MenuBrowse] Starting chat immediately: businessId=$_businessId, servicePointId=$servicePointId, userId=$userId, isDineIn=${appState.isDineIn}');
|
||||||
orderId: appState.cartOrderId,
|
|
||||||
userId: userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// Navigate to chat screen
|
// Navigate to chat screen IMMEDIATELY - let it create the task in background
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => ChatScreen(
|
builder: (context) => ChatScreen(
|
||||||
taskId: taskId,
|
taskId: null, // Task will be created by ChatScreen
|
||||||
userType: 'customer',
|
userType: 'customer',
|
||||||
|
businessId: _businessId,
|
||||||
|
servicePointId: servicePointId,
|
||||||
|
orderId: appState.cartOrderId,
|
||||||
|
userId: userId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
debugPrint('[MenuBrowse] Navigated to ChatScreen (task will be created in background)');
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.error, color: Colors.black),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(child: Text("Failed to start chat: $e", style: const TextStyle(color: Colors.black))),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
backgroundColor: Colors.red.shade100,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _isCallingServer = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _organizeItems() {
|
void _organizeItems() {
|
||||||
|
|
@ -411,45 +550,26 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Row(
|
leading: IconButton(
|
||||||
children: [
|
icon: const Icon(Icons.arrow_back),
|
||||||
// Business logo
|
onPressed: () {
|
||||||
if (_businessId != null)
|
// If this business has a parent, go back to business selector
|
||||||
Padding(
|
if (appState.hasParentBusiness) {
|
||||||
padding: const EdgeInsets.only(right: 12),
|
Navigator.of(context).pushReplacementNamed(
|
||||||
child: ClipRRect(
|
AppRoutes.businessSelector,
|
||||||
borderRadius: BorderRadius.circular(6),
|
arguments: {
|
||||||
child: SizedBox(
|
"parentBusinessId": appState.parentBusinessId,
|
||||||
width: 36,
|
"parentBusinessName": appState.parentBusinessName,
|
||||||
height: 36,
|
"servicePointId": appState.selectedServicePointId,
|
||||||
child: Image.network(
|
"servicePointName": appState.selectedServicePointName,
|
||||||
"$_imageBaseUrl/logos/$_businessId.png",
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
|
||||||
return Image.network(
|
|
||||||
"$_imageBaseUrl/logos/$_businessId.jpg",
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.store,
|
|
||||||
size: 20,
|
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
title: Column(
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -468,14 +588,13 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
// Call Server (dine-in) or Chat (non-dine-in) button
|
// Rescan for table button
|
||||||
|
const RescanButton(),
|
||||||
|
// Service bell for dine-in, chat bubble for non-dine-in
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(appState.isDineIn ? Icons.room_service : Icons.chat_bubble_outline),
|
icon: Icon(appState.isDineIn ? Icons.notifications_active : Icons.chat_bubble_outline),
|
||||||
tooltip: appState.isDineIn ? "Call Server" : "Chat",
|
tooltip: appState.isDineIn ? "Call Server" : "Chat With Staff",
|
||||||
onPressed: () => _handleCallServer(appState),
|
onPressed: () => _handleCallServer(appState),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
@ -561,35 +680,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
color: const Color(0xFFF0F0F0),
|
color: const Color(0xFFF0F0F0),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Top gradient transition from category bar
|
|
||||||
Container(
|
|
||||||
height: 12,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [
|
|
||||||
const Color(0xFF1B4D3E).withAlpha(60),
|
|
||||||
const Color(0xFFF0F0F0),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
...items.map((item) => _buildMenuItem(item)),
|
...items.map((item) => _buildMenuItem(item)),
|
||||||
// Bottom fade-out gradient to show end of expanded section
|
|
||||||
Container(
|
|
||||||
height: 24,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [
|
|
||||||
const Color(0xFFF0F0F0),
|
|
||||||
const Color(0xFF1B4D3E).withAlpha(60),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -619,27 +710,27 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 180,
|
height: 180,
|
||||||
child: Stack(
|
color: _brandColor, // Fill empty space with business brand color
|
||||||
fit: StackFit.expand,
|
child: Center(
|
||||||
children: [
|
child: Image.network(
|
||||||
// Header background image
|
|
||||||
Image.network(
|
|
||||||
"$_imageBaseUrl/headers/$_businessId.png",
|
"$_imageBaseUrl/headers/$_businessId.png",
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.fitWidth,
|
||||||
|
width: double.infinity,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
return Image.network(
|
return Image.network(
|
||||||
"$_imageBaseUrl/headers/$_businessId.jpg",
|
"$_imageBaseUrl/headers/$_businessId.jpg",
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.fitWidth,
|
||||||
|
width: double.infinity,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
// No header image - show gradient background
|
// No header image - show gradient with brand color
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [
|
colors: [
|
||||||
Theme.of(context).colorScheme.primary,
|
_brandColor,
|
||||||
Theme.of(context).colorScheme.secondary,
|
_brandColor.withAlpha(200),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -648,45 +739,6 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Top edge gradient
|
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: 16,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [
|
|
||||||
Colors.black.withAlpha(180),
|
|
||||||
Colors.black.withAlpha(0),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Bottom edge gradient
|
|
||||||
Positioned(
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: 16,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [
|
|
||||||
Colors.black.withAlpha(0),
|
|
||||||
Colors.black.withAlpha(200),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -800,7 +852,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
children: [
|
children: [
|
||||||
// Category image background or styled text fallback
|
// Category image background or styled text fallback
|
||||||
_buildCategoryBackground(categoryId, categoryName),
|
_buildCategoryBackground(categoryId, categoryName),
|
||||||
// Top edge gradient (subtle forest green)
|
// Top edge gradient (brand color)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|
@ -812,14 +864,14 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [
|
||||||
const Color(0xFF1B4D3E).withAlpha(120),
|
_brandColor.withAlpha(150),
|
||||||
const Color(0xFF1B4D3E).withAlpha(0),
|
_brandColor.withAlpha(0),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Bottom edge gradient (subtle forest green)
|
// Bottom edge gradient (brand color)
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|
@ -831,8 +883,46 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [
|
||||||
const Color(0xFF1B4D3E).withAlpha(0),
|
_brandColor.withAlpha(0),
|
||||||
const Color(0xFF1B4D3E).withAlpha(150),
|
_brandColor.withAlpha(150),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Left edge gradient (brand color)
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 16,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
colors: [
|
||||||
|
_brandColor.withAlpha(150),
|
||||||
|
_brandColor.withAlpha(0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Right edge gradient (brand color)
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 16,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
colors: [
|
||||||
|
_brandColor.withAlpha(0),
|
||||||
|
_brandColor.withAlpha(150),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -870,13 +960,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
onTap: () {
|
onTap: () => _showItemCustomization(item),
|
||||||
if (hasModifiers) {
|
|
||||||
_showItemCustomization(item);
|
|
||||||
} else {
|
|
||||||
_addToCart(item, {});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -964,31 +1048,24 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addToCart(MenuItem item, Set<int> selectedModifierIds, {int quantity = 1}) async {
|
Future<void> _addToCart(MenuItem item, Set<int> selectedModifierIds, {int quantity = 1}) async {
|
||||||
// Check if user is logged in - if not, navigate to login
|
// Check if user is logged in - if not, show inline sign-in dialog
|
||||||
if (_userId == null) {
|
if (_userId == null) {
|
||||||
final shouldLogin = await showDialog<bool>(
|
final signedIn = await SignInDialog.show(context);
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text("Login Required"),
|
|
||||||
content: const Text("Please login to add items to your cart."),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, false),
|
|
||||||
child: const Text("Cancel"),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.pop(context, true),
|
|
||||||
child: const Text("Login"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (shouldLogin == true && mounted) {
|
if (!mounted) return;
|
||||||
Navigator.of(context).pushNamed(AppRoutes.login);
|
|
||||||
}
|
if (signedIn) {
|
||||||
|
// Refresh user ID from app state after successful sign-in
|
||||||
|
final appState = context.read<AppState>();
|
||||||
|
setState(() {
|
||||||
|
_userId = appState.userId;
|
||||||
|
});
|
||||||
|
// Continue with adding to cart below
|
||||||
|
} else {
|
||||||
|
// User cancelled sign-in
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (_businessId == null || _servicePointId == null) {
|
if (_businessId == null || _servicePointId == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|
@ -1065,7 +1142,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
(li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted
|
(li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted
|
||||||
).firstOrNull;
|
).firstOrNull;
|
||||||
|
|
||||||
final newQuantity = (existingItem?.quantity ?? 0) + 1;
|
final newQuantity = (existingItem?.quantity ?? 0) + quantity;
|
||||||
|
|
||||||
cart = await Api.setLineItem(
|
cart = await Api.setLineItem(
|
||||||
orderId: cart.orderId,
|
orderId: cart.orderId,
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@
|
||||||
|
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:provider/provider.dart";
|
import "package:provider/provider.dart";
|
||||||
|
import "package:geolocator/geolocator.dart";
|
||||||
|
|
||||||
import "../app/app_state.dart";
|
import "../app/app_state.dart";
|
||||||
import "../models/menu_item.dart";
|
import "../models/menu_item.dart";
|
||||||
import "../models/restaurant.dart";
|
import "../models/restaurant.dart";
|
||||||
import "../models/service_point.dart";
|
import "../models/service_point.dart";
|
||||||
import "../services/api.dart";
|
import "../services/api.dart";
|
||||||
|
import "../widgets/rescan_button.dart";
|
||||||
|
|
||||||
class RestaurantSelectScreen extends StatefulWidget {
|
class RestaurantSelectScreen extends StatefulWidget {
|
||||||
const RestaurantSelectScreen({super.key});
|
const RestaurantSelectScreen({super.key});
|
||||||
|
|
@ -34,7 +36,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_restaurantsFuture = _loadRestaurants();
|
_restaurantsFuture = _loadRestaurantsWithLocation();
|
||||||
|
|
||||||
// Clear order type when arriving at restaurant select (no beacon = not dine-in)
|
// Clear order type when arriving at restaurant select (no beacon = not dine-in)
|
||||||
// This ensures the table change icon doesn't appear for delivery/takeaway orders
|
// This ensures the table change icon doesn't appear for delivery/takeaway orders
|
||||||
|
|
@ -44,11 +46,32 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Restaurant>> _loadRestaurants() async {
|
Future<List<Restaurant>> _loadRestaurantsWithLocation() async {
|
||||||
final raw = await Api.listRestaurantsRaw();
|
double? lat;
|
||||||
|
double? lng;
|
||||||
|
|
||||||
|
// Try to get user location for distance-based sorting
|
||||||
|
try {
|
||||||
|
final permission = await Geolocator.checkPermission();
|
||||||
|
if (permission == LocationPermission.always || permission == LocationPermission.whileInUse) {
|
||||||
|
final position = await Geolocator.getCurrentPosition(
|
||||||
|
locationSettings: const LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.low,
|
||||||
|
timeLimit: Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
lat = position.latitude;
|
||||||
|
lng = position.longitude;
|
||||||
|
debugPrint('[RestaurantSelect] Got location: $lat, $lng');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[RestaurantSelect] Location error (continuing without): $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
final raw = await Api.listRestaurantsRaw(lat: lat, lng: lng);
|
||||||
_debugLastRaw = raw.rawBody;
|
_debugLastRaw = raw.rawBody;
|
||||||
_debugLastStatus = raw.statusCode;
|
_debugLastStatus = raw.statusCode;
|
||||||
return Api.listRestaurants();
|
return Api.listRestaurants(lat: lat, lng: lng);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadMenuForBusiness(int businessId) async {
|
Future<void> _loadMenuForBusiness(int businessId) async {
|
||||||
|
|
@ -113,7 +136,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
|
||||||
Api.setBusinessId(restaurant.businessId);
|
Api.setBusinessId(restaurant.businessId);
|
||||||
|
|
||||||
// Navigate to full menu browse screen
|
// Navigate to full menu browse screen
|
||||||
Navigator.of(context).pushReplacementNamed(
|
Navigator.of(context).pushNamed(
|
||||||
'/menu_browse',
|
'/menu_browse',
|
||||||
arguments: {
|
arguments: {
|
||||||
'businessId': restaurant.businessId,
|
'businessId': restaurant.businessId,
|
||||||
|
|
@ -122,15 +145,48 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _onWillPop() async {
|
||||||
|
final shouldExit = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text("Exit App?"),
|
||||||
|
content: const Text("Are you sure you want to exit?"),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text("Exit"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return shouldExit ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
|
canPop: false,
|
||||||
|
onPopInvokedWithResult: (didPop, result) async {
|
||||||
|
if (didPop) return;
|
||||||
|
final shouldExit = await _onWillPop();
|
||||||
|
if (shouldExit && context.mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
title: const Text("Nearby Restaurants"),
|
title: const Text("Nearby Restaurants"),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
actions: const [
|
||||||
|
RescanButton(iconColor: Colors.white),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: FutureBuilder<List<Restaurant>>(
|
body: FutureBuilder<List<Restaurant>>(
|
||||||
future: _restaurantsFuture,
|
future: _restaurantsFuture,
|
||||||
|
|
@ -147,7 +203,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
|
||||||
message: snapshot.error.toString(),
|
message: snapshot.error.toString(),
|
||||||
statusCode: _debugLastStatus,
|
statusCode: _debugLastStatus,
|
||||||
raw: _debugLastRaw,
|
raw: _debugLastRaw,
|
||||||
onRetry: () => setState(() => _restaurantsFuture = _loadRestaurants()),
|
onRetry: () => setState(() => _restaurantsFuture = _loadRestaurantsWithLocation()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,7 +214,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
|
||||||
message: "No Payfrit restaurants nearby.",
|
message: "No Payfrit restaurants nearby.",
|
||||||
statusCode: _debugLastStatus,
|
statusCode: _debugLastStatus,
|
||||||
raw: _debugLastRaw,
|
raw: _debugLastRaw,
|
||||||
onRetry: () => setState(() => _restaurantsFuture = _loadRestaurants()),
|
onRetry: () => setState(() => _restaurantsFuture = _loadRestaurantsWithLocation()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,6 +238,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -205,186 +262,119 @@ class _RestaurantBar extends StatelessWidget {
|
||||||
required this.imageBaseUrl,
|
required this.imageBaseUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
Widget _buildLogoPlaceholder(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
Theme.of(context).colorScheme.primary,
|
|
||||||
Theme.of(context).colorScheme.tertiary,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
restaurant.name.isNotEmpty ? restaurant.name[0].toUpperCase() : "?",
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Restaurant header bar with logo
|
// Restaurant card with header image (matches business selector style)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 80,
|
height: 120,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: isExpanded
|
boxShadow: [
|
||||||
? Theme.of(context).colorScheme.primaryContainer
|
BoxShadow(
|
||||||
: Colors.grey.shade900,
|
color: Colors.black.withAlpha(100),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Background header image (subtle) - ignorePointer so taps go through
|
// Header image background
|
||||||
IgnorePointer(
|
Positioned.fill(
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Opacity(
|
|
||||||
opacity: 0.3,
|
|
||||||
child: SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 80,
|
|
||||||
child: Image.network(
|
child: Image.network(
|
||||||
"$imageBaseUrl/headers/${restaurant.businessId}.png",
|
"$imageBaseUrl/headers/${restaurant.businessId}.png",
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
gaplessPlayback: true,
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
return Image.network(
|
return Image.network(
|
||||||
"$imageBaseUrl/headers/${restaurant.businessId}.jpg",
|
"$imageBaseUrl/headers/${restaurant.businessId}.jpg",
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
gaplessPlayback: true,
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
return const SizedBox.shrink();
|
// Fallback to logo centered
|
||||||
},
|
return Container(
|
||||||
);
|
color: Colors.grey.shade800,
|
||||||
},
|
child: Center(
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Sharp gradient edges
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: 20,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: const BorderRadius.only(
|
|
||||||
topLeft: Radius.circular(12),
|
|
||||||
bottomLeft: Radius.circular(12),
|
|
||||||
),
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.centerLeft,
|
|
||||||
end: Alignment.centerRight,
|
|
||||||
colors: [
|
|
||||||
isExpanded
|
|
||||||
? Colors.transparent
|
|
||||||
: Colors.grey.shade900,
|
|
||||||
Colors.transparent,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: 20,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: const BorderRadius.only(
|
|
||||||
topRight: Radius.circular(12),
|
|
||||||
bottomRight: Radius.circular(12),
|
|
||||||
),
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.centerRight,
|
|
||||||
end: Alignment.centerLeft,
|
|
||||||
colors: [
|
|
||||||
isExpanded
|
|
||||||
? Colors.transparent
|
|
||||||
: Colors.grey.shade900,
|
|
||||||
Colors.transparent,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Content
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// Logo (56x56 recommended, or 112x112 for 2x)
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
child: Image.network(
|
child: Image.network(
|
||||||
"$imageBaseUrl/logos/${restaurant.businessId}.png",
|
"$imageBaseUrl/logos/${restaurant.businessId}.png",
|
||||||
fit: BoxFit.cover,
|
width: 60,
|
||||||
gaplessPlayback: true,
|
height: 60,
|
||||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
fit: BoxFit.contain,
|
||||||
// Show placeholder immediately, image loads on top
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
_buildLogoPlaceholder(context),
|
|
||||||
if (frame != null) child,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
return Image.network(
|
return Image.network(
|
||||||
"$imageBaseUrl/logos/${restaurant.businessId}.jpg",
|
"$imageBaseUrl/logos/${restaurant.businessId}.jpg",
|
||||||
fit: BoxFit.cover,
|
width: 60,
|
||||||
gaplessPlayback: true,
|
height: 60,
|
||||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
fit: BoxFit.contain,
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
_buildLogoPlaceholder(context),
|
|
||||||
if (frame != null) child,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
return _buildLogoPlaceholder(context);
|
return Text(
|
||||||
|
restaurant.name.isNotEmpty ? restaurant.name[0].toUpperCase() : "?",
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white54,
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
),
|
||||||
// Name
|
// Gradient overlay
|
||||||
|
Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
Colors.black.withAlpha(180),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Business name and arrow at bottom
|
||||||
|
Positioned(
|
||||||
|
bottom: 12,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
restaurant.name,
|
restaurant.name,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: isExpanded
|
color: Colors.white,
|
||||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
fontSize: 20,
|
||||||
: Colors.white,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 18,
|
shadows: [
|
||||||
fontWeight: FontWeight.w600,
|
Shadow(
|
||||||
|
offset: Offset(0, 1),
|
||||||
|
blurRadius: 3,
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Icon(
|
||||||
|
Icons.arrow_forward_ios,
|
||||||
|
color: Colors.white70,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -392,6 +382,7 @@ class _RestaurantBar extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Expanded menu content
|
// Expanded menu content
|
||||||
AnimatedCrossFade(
|
AnimatedCrossFade(
|
||||||
|
|
@ -560,7 +551,7 @@ class _CategorySection extends StatelessWidget {
|
||||||
Api.setBusinessId(restaurant.businessId);
|
Api.setBusinessId(restaurant.businessId);
|
||||||
|
|
||||||
// Navigate to full menu browse screen
|
// Navigate to full menu browse screen
|
||||||
Navigator.of(context).pushReplacementNamed(
|
Navigator.of(context).pushNamed(
|
||||||
'/menu_browse',
|
'/menu_browse',
|
||||||
arguments: {
|
arguments: {
|
||||||
'businessId': restaurant.businessId,
|
'businessId': restaurant.businessId,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
print('[Splash] 📥 Loading beacon list...');
|
||||||
|
Map<String, int> knownBeacons = {};
|
||||||
|
|
||||||
|
// Try cache first
|
||||||
|
final cached = await BeaconCache.load();
|
||||||
|
if (cached != null && cached.isNotEmpty) {
|
||||||
|
print('[Splash] ✅ Got ${cached.length} beacon UUIDs from cache');
|
||||||
|
knownBeacons = cached;
|
||||||
|
// Refresh cache in background (fire and forget)
|
||||||
|
Api.listAllBeacons().then((fresh) {
|
||||||
|
BeaconCache.save(fresh);
|
||||||
|
print('[Splash] 🔄 Background refresh: saved ${fresh.length} beacons to cache');
|
||||||
|
}).catchError((e) {
|
||||||
|
print('[Splash] ⚠️ Background refresh failed: $e');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No cache - must fetch from server
|
||||||
try {
|
try {
|
||||||
_uuidToBeaconId = await Api.listAllBeacons();
|
knownBeacons = await Api.listAllBeacons();
|
||||||
print('[Splash] Loaded ${_uuidToBeaconId.length} beacons from database');
|
print('[Splash] ✅ Got ${knownBeacons.length} beacon UUIDs from server');
|
||||||
|
// Save to cache
|
||||||
|
await BeaconCache.save(knownBeacons);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[Splash] Error loading beacons: $e');
|
print('[Splash] ❌ Failed to fetch beacons: $e');
|
||||||
_scanComplete = true;
|
_scanComplete = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (_uuidToBeaconId.isEmpty) {
|
if (knownBeacons.isEmpty) {
|
||||||
print('[Splash] No beacons in database');
|
print('[Splash] ⚠️ No beacons configured');
|
||||||
_scanComplete = true;
|
_scanComplete = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
StreamSubscription<RangingResult>? subscription;
|
||||||
subscription = flutterBeacon.ranging(regions).listen((result) {
|
subscription = flutterBeacon.ranging(regions).listen((result) {
|
||||||
for (var beacon in result.beacons) {
|
for (var beacon in result.beacons) {
|
||||||
final uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', '');
|
final uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', '');
|
||||||
final rssi = beacon.rssi;
|
final rssi = beacon.rssi;
|
||||||
|
|
||||||
if (_uuidToBeaconId.containsKey(uuid)) {
|
|
||||||
_beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi);
|
_beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi);
|
||||||
_beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1;
|
_beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1;
|
||||||
print('[Splash] ✅ Beacon ${_uuidToBeaconId[uuid]}, RSSI=$rssi');
|
print('[Splash] 📶 Found $uuid RSSI=$rssi');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
await Future.delayed(const Duration(milliseconds: 2000));
|
||||||
await subscription.cancel();
|
await subscription.cancel();
|
||||||
|
|
||||||
// Check for early exit after 3 cycles
|
// Now lookup business info for found beacons
|
||||||
if (scanCycle >= 3 && _beaconRssiSamples.isNotEmpty && _canExitEarly()) {
|
if (_beaconRssiSamples.isNotEmpty) {
|
||||||
print('[Splash] ⚡ Early exit - stable readings');
|
print('[Splash] 🔍 Looking up ${_beaconRssiSamples.length} beacons...');
|
||||||
break;
|
final uuids = _beaconRssiSamples.keys.toList();
|
||||||
}
|
|
||||||
|
|
||||||
if (scanCycle < 5) {
|
try {
|
||||||
await Future.delayed(const Duration(milliseconds: 200));
|
final lookupResults = await Api.lookupBeacons(uuids);
|
||||||
|
print('[Splash] 📋 Server returned ${lookupResults.length} registered beacons');
|
||||||
|
|
||||||
|
// Find the best registered beacon based on RSSI
|
||||||
|
_bestBeacon = _findBestRegisteredBeacon(lookupResults);
|
||||||
|
} catch (e) {
|
||||||
|
print('[Splash] Error looking up beacons: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find best beacon
|
print('[Splash] 🎯 Best beacon: ${_bestBeacon?.beaconName ?? "none"}');
|
||||||
_bestBeacon = _findBestBeacon();
|
|
||||||
print('[Splash] 🎯 Best beacon: ${_bestBeacon?.beaconId ?? "none"}');
|
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[Splash] Scan error: $e');
|
print('[Splash] Scan error: $e');
|
||||||
|
|
@ -277,346 +302,107 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||||
_scanComplete = true;
|
_scanComplete = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _canExitEarly() {
|
/// Find the best registered beacon from lookup results based on RSSI
|
||||||
if (_beaconRssiSamples.isEmpty) return false;
|
BeaconLookupResult? _findBestRegisteredBeacon(List<BeaconLookupResult> registeredBeacons) {
|
||||||
|
if (registeredBeacons.isEmpty) return null;
|
||||||
|
|
||||||
bool hasEnoughSamples = _beaconRssiSamples.values.any((s) => s.length >= 3);
|
BeaconLookupResult? best;
|
||||||
if (!hasEnoughSamples) return false;
|
|
||||||
|
|
||||||
for (final entry in _beaconRssiSamples.entries) {
|
|
||||||
final samples = entry.value;
|
|
||||||
if (samples.length < 3) continue;
|
|
||||||
|
|
||||||
final avg = samples.reduce((a, b) => a + b) / samples.length;
|
|
||||||
final variance = samples.map((r) => (r - avg) * (r - avg)).reduce((a, b) => a + b) / samples.length;
|
|
||||||
|
|
||||||
if (variance > 50) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_beaconRssiSamples.length > 1) {
|
|
||||||
final avgRssis = <String, double>{};
|
|
||||||
for (final entry in _beaconRssiSamples.entries) {
|
|
||||||
if (entry.value.isNotEmpty) {
|
|
||||||
avgRssis[entry.key] = entry.value.reduce((a, b) => a + b) / entry.value.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final sorted = avgRssis.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
|
|
||||||
if (sorted.length >= 2 && (sorted[0].value - sorted[1].value) < 5) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
BeaconResult? _findBestBeacon() {
|
|
||||||
if (_beaconRssiSamples.isEmpty) return null;
|
|
||||||
|
|
||||||
String? bestUuid;
|
|
||||||
double bestAvgRssi = -999;
|
double bestAvgRssi = -999;
|
||||||
|
|
||||||
for (final entry in _beaconRssiSamples.entries) {
|
for (final beacon in registeredBeacons) {
|
||||||
final samples = entry.value;
|
final samples = _beaconRssiSamples[beacon.uuid];
|
||||||
final detections = _beaconDetectionCount[entry.key] ?? 0;
|
if (samples == null || samples.isEmpty) continue;
|
||||||
|
|
||||||
if (detections < 3) continue;
|
final detections = _beaconDetectionCount[beacon.uuid] ?? 0;
|
||||||
|
if (detections < 2) continue; // Need at least 2 detections
|
||||||
|
|
||||||
final avgRssi = samples.reduce((a, b) => a + b) / samples.length;
|
final avgRssi = samples.reduce((a, b) => a + b) / samples.length;
|
||||||
if (avgRssi > bestAvgRssi && avgRssi >= -85) {
|
if (avgRssi > bestAvgRssi && avgRssi >= -85) {
|
||||||
bestAvgRssi = avgRssi;
|
bestAvgRssi = avgRssi;
|
||||||
bestUuid = entry.key;
|
best = beacon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bestUuid != null) {
|
// Fall back to strongest registered beacon if none meet threshold
|
||||||
return BeaconResult(
|
if (best == null) {
|
||||||
uuid: bestUuid,
|
for (final beacon in registeredBeacons) {
|
||||||
beaconId: _uuidToBeaconId[bestUuid]!,
|
final samples = _beaconRssiSamples[beacon.uuid];
|
||||||
avgRssi: bestAvgRssi,
|
if (samples == null || samples.isEmpty) continue;
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to strongest signal even if doesn't meet threshold
|
|
||||||
if (_beaconRssiSamples.isNotEmpty) {
|
|
||||||
for (final entry in _beaconRssiSamples.entries) {
|
|
||||||
final samples = entry.value;
|
|
||||||
if (samples.isEmpty) continue;
|
|
||||||
final avgRssi = samples.reduce((a, b) => a + b) / samples.length;
|
final avgRssi = samples.reduce((a, b) => a + b) / samples.length;
|
||||||
if (avgRssi > bestAvgRssi) {
|
if (avgRssi > bestAvgRssi) {
|
||||||
bestAvgRssi = avgRssi;
|
bestAvgRssi = avgRssi;
|
||||||
bestUuid = entry.key;
|
best = beacon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (bestUuid != null) {
|
|
||||||
return BeaconResult(
|
|
||||||
uuid: bestUuid,
|
|
||||||
beaconId: _uuidToBeaconId[bestUuid]!,
|
|
||||||
avgRssi: bestAvgRssi,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _navigateToNextScreen() async {
|
Future<void> _navigateToNextScreen() async {
|
||||||
if (!mounted || _navigating) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_navigating = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
final appState = context.read<AppState>();
|
|
||||||
|
|
||||||
// Get beacon mapping if we found a beacon
|
|
||||||
if (_bestBeacon != null) {
|
if (_bestBeacon != null) {
|
||||||
try {
|
final beacon = _bestBeacon!;
|
||||||
_beaconMapping = await Api.getBusinessFromBeacon(beaconId: _bestBeacon!.beaconId);
|
print('[Splash] 📊 Best beacon: ${beacon.businessName}, hasChildren=${beacon.hasChildren}, parent=${beacon.parentBusinessName}');
|
||||||
print('[Splash] 📍 Beacon maps to: ${_beaconMapping!.businessName}');
|
|
||||||
} catch (e) {
|
|
||||||
print('[Splash] Error mapping beacon to business: $e');
|
|
||||||
_beaconMapping = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for existing cart if user is logged in
|
// Check if this business has child businesses (food court scenario)
|
||||||
final userId = appState.userId;
|
if (beacon.hasChildren) {
|
||||||
if (userId != null && userId > 0) {
|
print('[Splash] 🏢 Business has children - showing selector');
|
||||||
|
// Need to fetch children and show selector
|
||||||
try {
|
try {
|
||||||
_existingCart = await Api.getActiveCart(userId: userId);
|
final children = await Api.getChildBusinesses(businessId: beacon.businessId);
|
||||||
if (_existingCart != null && _existingCart!.hasItems) {
|
|
||||||
print('[Splash] 🛒 Found existing cart: ${_existingCart!.itemCount} items at ${_existingCart!.businessName}');
|
|
||||||
} else {
|
|
||||||
_existingCart = null;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('[Splash] Error checking for existing cart: $e');
|
|
||||||
_existingCart = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// DECISION TREE:
|
if (children.isNotEmpty) {
|
||||||
// 1. Beacon found?
|
Navigator.of(context).pushReplacementNamed(
|
||||||
// - Yes: Is there an existing cart?
|
AppRoutes.businessSelector,
|
||||||
// - Yes: Same restaurant?
|
arguments: {
|
||||||
// - Yes: Continue order as dine-in, update service point
|
"parentBusinessId": beacon.businessId,
|
||||||
// - No: Start fresh with beacon's restaurant (dine-in)
|
"parentBusinessName": beacon.businessName,
|
||||||
// - No: Start fresh with beacon's restaurant (dine-in)
|
"servicePointId": beacon.servicePointId,
|
||||||
// - No: Is there an existing cart?
|
"servicePointName": beacon.servicePointName,
|
||||||
// - Yes: Show "Continue or Start Fresh?" popup
|
"children": children,
|
||||||
// - No: Go to restaurant select
|
},
|
||||||
|
);
|
||||||
if (_beaconMapping != null) {
|
return;
|
||||||
// BEACON FOUND
|
|
||||||
if (_existingCart != null && _existingCart!.businessId == _beaconMapping!.businessId) {
|
|
||||||
// Same restaurant - continue order, update to dine-in with new service point
|
|
||||||
print('[Splash] ✅ Continuing existing order at same restaurant (dine-in)');
|
|
||||||
await _continueExistingOrderWithBeacon();
|
|
||||||
} else {
|
|
||||||
// Different restaurant or no cart - start fresh with beacon
|
|
||||||
print('[Splash] 🆕 Starting fresh dine-in order at ${_beaconMapping!.businessName}');
|
|
||||||
_startFreshWithBeacon();
|
|
||||||
}
|
}
|
||||||
} else {
|
} catch (e) {
|
||||||
// NO BEACON
|
print('[Splash] Error fetching children: $e');
|
||||||
if (_existingCart != null) {
|
}
|
||||||
// Has existing cart - ask user what to do
|
}
|
||||||
print('[Splash] ❓ No beacon, but has existing cart - showing choice dialog');
|
|
||||||
_showContinueOrStartFreshDialog();
|
// Single business - go directly to menu
|
||||||
} else {
|
final appState = context.read<AppState>();
|
||||||
// No cart, no beacon - go to restaurant select
|
appState.setBusinessAndServicePoint(
|
||||||
print('[Splash] 📋 No beacon, no cart - going to restaurant select');
|
beacon.businessId,
|
||||||
|
beacon.servicePointId,
|
||||||
|
businessName: beacon.businessName,
|
||||||
|
servicePointName: beacon.servicePointName,
|
||||||
|
parentBusinessId: beacon.hasParent ? beacon.parentBusinessId : null,
|
||||||
|
parentBusinessName: beacon.hasParent ? beacon.parentBusinessName : null,
|
||||||
|
);
|
||||||
|
// Beacon detected = dine-in at a table
|
||||||
|
appState.setOrderType(OrderType.dineIn);
|
||||||
|
Api.setBusinessId(beacon.businessId);
|
||||||
|
|
||||||
|
print('[Splash] 🎉 Auto-selected: ${beacon.businessName}');
|
||||||
|
|
||||||
|
Navigator.of(context).pushReplacementNamed(
|
||||||
|
AppRoutes.menuBrowse,
|
||||||
|
arguments: {
|
||||||
|
'businessId': beacon.businessId,
|
||||||
|
'servicePointId': beacon.servicePointId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No beacon or error - go to restaurant select
|
||||||
|
print('[Splash] Going to restaurant select');
|
||||||
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
|
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Continue existing order and update to dine-in with beacon's service point
|
|
||||||
Future<void> _continueExistingOrderWithBeacon() async {
|
|
||||||
if (!mounted || _existingCart == null || _beaconMapping == null) return;
|
|
||||||
|
|
||||||
final appState = context.read<AppState>();
|
|
||||||
|
|
||||||
// Update order type to dine-in and set service point
|
|
||||||
try {
|
|
||||||
await Api.setOrderType(
|
|
||||||
orderId: _existingCart!.orderId,
|
|
||||||
orderTypeId: 1, // dine-in
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
print('[Splash] Error updating order type: $e');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set app state
|
|
||||||
appState.setBusinessAndServicePoint(
|
|
||||||
_beaconMapping!.businessId,
|
|
||||||
_beaconMapping!.servicePointId,
|
|
||||||
businessName: _beaconMapping!.businessName,
|
|
||||||
servicePointName: _beaconMapping!.servicePointName,
|
|
||||||
);
|
|
||||||
appState.setOrderType(OrderType.dineIn);
|
|
||||||
appState.setCartOrder(
|
|
||||||
orderId: _existingCart!.orderId,
|
|
||||||
orderUuid: _existingCart!.orderUuid,
|
|
||||||
itemCount: _existingCart!.itemCount,
|
|
||||||
);
|
|
||||||
Api.setBusinessId(_beaconMapping!.businessId);
|
|
||||||
|
|
||||||
Navigator.of(context).pushReplacementNamed(
|
|
||||||
AppRoutes.menuBrowse,
|
|
||||||
arguments: {
|
|
||||||
'businessId': _beaconMapping!.businessId,
|
|
||||||
'servicePointId': _beaconMapping!.servicePointId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start fresh dine-in order with beacon
|
|
||||||
void _startFreshWithBeacon() {
|
|
||||||
if (!mounted || _beaconMapping == null) return;
|
|
||||||
|
|
||||||
final appState = context.read<AppState>();
|
|
||||||
|
|
||||||
// Clear any existing cart reference
|
|
||||||
appState.clearCart();
|
|
||||||
|
|
||||||
appState.setBusinessAndServicePoint(
|
|
||||||
_beaconMapping!.businessId,
|
|
||||||
_beaconMapping!.servicePointId,
|
|
||||||
businessName: _beaconMapping!.businessName,
|
|
||||||
servicePointName: _beaconMapping!.servicePointName,
|
|
||||||
);
|
|
||||||
appState.setOrderType(OrderType.dineIn);
|
|
||||||
Api.setBusinessId(_beaconMapping!.businessId);
|
|
||||||
|
|
||||||
Navigator.of(context).pushReplacementNamed(
|
|
||||||
AppRoutes.menuBrowse,
|
|
||||||
arguments: {
|
|
||||||
'businessId': _beaconMapping!.businessId,
|
|
||||||
'servicePointId': _beaconMapping!.servicePointId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Show dialog asking user to continue existing order or start fresh
|
|
||||||
void _showContinueOrStartFreshDialog() {
|
|
||||||
if (!mounted || _existingCart == null) return;
|
|
||||||
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text("Existing Order Found"),
|
|
||||||
content: Text(
|
|
||||||
"You have an existing order at ${_existingCart!.businessName} "
|
|
||||||
"with ${_existingCart!.itemCount} item${_existingCart!.itemCount == 1 ? '' : 's'}.\n\n"
|
|
||||||
"Would you like to continue with this order or start fresh?",
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
_startFresh();
|
|
||||||
},
|
|
||||||
child: const Text("Start Fresh"),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
_continueExistingOrder();
|
|
||||||
},
|
|
||||||
child: const Text("Continue Order"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Continue with existing order (no beacon)
|
|
||||||
void _continueExistingOrder() {
|
|
||||||
if (!mounted || _existingCart == null) return;
|
|
||||||
|
|
||||||
final appState = context.read<AppState>();
|
|
||||||
|
|
||||||
// Only use service point if this is actually a dine-in order
|
|
||||||
// Otherwise clear it to avoid showing stale table info
|
|
||||||
final isDineIn = _existingCart!.isDineIn;
|
|
||||||
appState.setBusinessAndServicePoint(
|
|
||||||
_existingCart!.businessId,
|
|
||||||
isDineIn && _existingCart!.servicePointId > 0 ? _existingCart!.servicePointId : 0,
|
|
||||||
businessName: _existingCart!.businessName,
|
|
||||||
servicePointName: isDineIn ? _existingCart!.servicePointName : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set order type based on existing cart
|
|
||||||
if (isDineIn) {
|
|
||||||
appState.setOrderType(OrderType.dineIn);
|
|
||||||
} else if (_existingCart!.isTakeaway) {
|
|
||||||
appState.setOrderType(OrderType.takeaway);
|
|
||||||
} else if (_existingCart!.isDelivery) {
|
|
||||||
appState.setOrderType(OrderType.delivery);
|
|
||||||
} else {
|
|
||||||
appState.setOrderType(null); // Undecided - will choose at checkout
|
|
||||||
}
|
|
||||||
|
|
||||||
appState.setCartOrder(
|
|
||||||
orderId: _existingCart!.orderId,
|
|
||||||
orderUuid: _existingCart!.orderUuid,
|
|
||||||
itemCount: _existingCart!.itemCount,
|
|
||||||
);
|
|
||||||
Api.setBusinessId(_existingCart!.businessId);
|
|
||||||
|
|
||||||
Navigator.of(context).pushReplacementNamed(
|
|
||||||
AppRoutes.menuBrowse,
|
|
||||||
arguments: {
|
|
||||||
'businessId': _existingCart!.businessId,
|
|
||||||
'servicePointId': _existingCart!.servicePointId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start fresh - abandon existing order and go to restaurant select
|
|
||||||
Future<void> _startFresh() async {
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
final appState = context.read<AppState>();
|
|
||||||
|
|
||||||
// Abandon the existing order on the server
|
|
||||||
if (_existingCart != null) {
|
|
||||||
print('[Splash] Abandoning order ${_existingCart!.orderId}...');
|
|
||||||
try {
|
|
||||||
await Api.abandonOrder(orderId: _existingCart!.orderId);
|
|
||||||
print('[Splash] Order abandoned successfully');
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore errors - just proceed with clearing local state
|
|
||||||
print('[Splash] Failed to abandon order: $e');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
print('[Splash] No existing cart to abandon');
|
|
||||||
}
|
|
||||||
|
|
||||||
appState.clearCart();
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Skip the beacon scan and proceed without dine-in detection
|
|
||||||
void _skipScan() {
|
|
||||||
if (_scanSkipped || _navigating) return;
|
|
||||||
|
|
||||||
print('[Splash] ⏭️ User skipped beacon scan');
|
|
||||||
setState(() {
|
|
||||||
_scanSkipped = true;
|
|
||||||
_scanComplete = true;
|
|
||||||
_bestBeacon = null; // No beacon since we skipped
|
|
||||||
});
|
|
||||||
|
|
||||||
// Proceed with navigation (will check for existing cart)
|
|
||||||
_navigateToNextScreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
lib/services/beacon_cache.dart
Normal file
47
lib/services/beacon_cache.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
204
lib/services/beacon_scanner_service.dart
Normal file
204
lib/services/beacon_scanner_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
114
lib/services/preload_cache.dart
Normal file
114
lib/services/preload_cache.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -56,10 +56,12 @@ class StripeService {
|
||||||
|
|
||||||
/// Initialize Stripe with publishable key
|
/// Initialize Stripe with publishable key
|
||||||
static Future<void> initialize(String publishableKey) async {
|
static Future<void> initialize(String publishableKey) async {
|
||||||
if (_isInitialized) return;
|
// Always update the key if it's different (allows switching between test/live)
|
||||||
|
if (Stripe.publishableKey != publishableKey) {
|
||||||
|
print('[Stripe] Updating publishable key to: ${publishableKey.substring(0, 20)}...');
|
||||||
Stripe.publishableKey = publishableKey;
|
Stripe.publishableKey = publishableKey;
|
||||||
await Stripe.instance.applySettings();
|
await Stripe.instance.applySettings();
|
||||||
|
}
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
239
lib/widgets/rescan_button.dart
Normal file
239
lib/widgets/rescan_button.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
404
lib/widgets/sign_in_dialog.dart
Normal file
404
lib/widgets/sign_in_dialog.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
pubspec.lock
64
pubspec.lock
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue