Fix modifier hierarchy and add order status polling

Flutter App Changes:
- Fixed modifier saving to maintain full hierarchy (containers + selections)
- Fixed cart display to show breadcrumb paths (e.g., "Customize Patties > Mustard grilled")
- Added order status polling service (30-second intervals)
- Updated AppState to track active order and status
- Fixed Android Bluetooth permissions for release builds
- Disabled minification to preserve beacon library in release builds
- Added ProGuard rules for beacon/Bluetooth classes
- Added foreground service permissions for beacon monitoring

Technical Details:
- menu_browse_screen.dart: Added _hasSelectedDescendants() to ensure container items are saved
- cart_view_screen.dart: Added _buildModifierPaths() for breadcrumb display
- order_polling_service.dart: Polls checkStatusUpdate.cfm every 30s
- api.dart: Added checkOrderStatus() method
- Android permissions: Removed neverForLocation flag, added FOREGROUND_SERVICE
- ProGuard: Disabled shrinking, added keep rules for beacon classes

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2025-12-31 21:37:55 -08:00
parent 008b6d45b2
commit 9c91737b1a
7 changed files with 171 additions and 50 deletions

View file

@ -35,6 +35,14 @@ android {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
// Disable minification and shrinking to preserve beacon library
isMinifyEnabled = false
isShrinkResources = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
} }
} }
} }

9
android/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,9 @@
# Keep beacon library classes
-keep class com.flutterbeacon.** { *; }
-keep class org.altbeacon.** { *; }
# Keep Bluetooth classes
-keep class android.bluetooth.** { *; }
# Keep location classes
-keep class android.location.** { *; }

View file

@ -3,12 +3,21 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- Beacon scanning permissions --> <!-- Beacon scanning permissions -->
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<!-- Android 12+ Bluetooth permissions -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Location permissions for beacon ranging -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Foreground service for background beacon monitoring -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<application <application
android:label="payfrit_app" android:label="payfrit_app"
android:name="${applicationName}" android:name="${applicationName}"

View file

@ -7,6 +7,17 @@ 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';
/// Helper class to store modifier breadcrumb paths
class ModifierPath {
final List<String> names;
final double price;
const ModifierPath({
required this.names,
required this.price,
});
}
class CartViewScreen extends StatefulWidget { class CartViewScreen extends StatefulWidget {
const CartViewScreen({super.key}); const CartViewScreen({super.key});
@ -286,12 +297,13 @@ class _CartViewScreenState extends State<CartViewScreen> {
final menuItem = _menuItemsById[rootItem.itemId]; final menuItem = _menuItemsById[rootItem.itemId];
final itemName = menuItem?.name ?? "Item #${rootItem.itemId}"; final itemName = menuItem?.name ?? "Item #${rootItem.itemId}";
// Find all modifiers for this root item print('[Cart] Building card for root item: $itemName (OrderLineItemID=${rootItem.orderLineItemId})');
final modifiers = _cart!.lineItems print('[Cart] Total line items in cart: ${_cart!.lineItems.length}');
.where((item) =>
item.parentOrderLineItemId == rootItem.orderLineItemId && // Find ALL modifiers (recursively) and build breadcrumb paths for leaf items only
!item.isDeleted) final modifierPaths = _buildModifierPaths(rootItem.orderLineItemId);
.toList();
print('[Cart] Found ${modifierPaths.length} modifier paths for this root item');
return Card( return Card(
child: Padding( child: Padding(
@ -316,9 +328,9 @@ class _CartViewScreenState extends State<CartViewScreen> {
), ),
], ],
), ),
if (modifiers.isNotEmpty) ...[ if (modifierPaths.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
...modifiers.map((mod) => _buildModifierRow(mod)), ...modifierPaths.map((path) => _buildModifierPathRow(path)),
], ],
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
@ -357,9 +369,51 @@ class _CartViewScreenState extends State<CartViewScreen> {
); );
} }
Widget _buildModifierRow(OrderLineItem modifier) { /// Build breadcrumb paths for all leaf modifiers
final menuItem = _menuItemsById[modifier.itemId]; List<ModifierPath> _buildModifierPaths(int rootOrderLineItemId) {
final modName = menuItem?.name ?? "Modifier #${modifier.itemId}"; final paths = <ModifierPath>[];
// Get direct children of root
final directChildren = _cart!.lineItems
.where((item) =>
item.parentOrderLineItemId == rootOrderLineItemId &&
!item.isDeleted)
.toList();
// Recursively collect leaf items with their paths
void collectLeafPaths(OrderLineItem item, List<String> currentPath) {
final children = _cart!.lineItems
.where((child) =>
child.parentOrderLineItemId == item.orderLineItemId &&
!child.isDeleted)
.toList();
final menuItem = _menuItemsById[item.itemId];
final itemName = menuItem?.name ?? "Item #${item.itemId}";
if (children.isEmpty) {
// This is a leaf - add its path
paths.add(ModifierPath(
names: [...currentPath, itemName],
price: item.price,
));
} else {
// This has children - recurse into them
for (final child in children) {
collectLeafPaths(child, [...currentPath, itemName]);
}
}
}
for (final child in directChildren) {
collectLeafPaths(child, []);
}
return paths;
}
Widget _buildModifierPathRow(ModifierPath path) {
final displayText = path.names.join(' > ');
return Padding( return Padding(
padding: const EdgeInsets.only(left: 16, top: 4), padding: const EdgeInsets.only(left: 16, top: 4),
@ -369,16 +423,16 @@ class _CartViewScreenState extends State<CartViewScreen> {
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded( Expanded(
child: Text( child: Text(
modName, displayText,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: Colors.grey, color: Colors.grey,
), ),
), ),
), ),
if (modifier.price > 0) if (path.price > 0)
Text( Text(
"+\$${modifier.price.toStringAsFixed(2)}", "+\$${path.price.toStringAsFixed(2)}",
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: Colors.grey, color: Colors.grey,

View file

@ -417,6 +417,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
); );
// Add all selected modifiers recursively // Add all selected modifiers recursively
print('[MenuBrowse] Adding ${selectedModifierIds.length} modifiers to root item OrderLineItemID=${rootLineItem.orderLineItemId}');
await _addModifiersRecursively( await _addModifiersRecursively(
cart.orderId, cart.orderId,
rootLineItem.orderLineItemId, rootLineItem.orderLineItemId,
@ -425,7 +426,9 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
); );
// Refresh cart to get final state // Refresh cart to get final state
print('[MenuBrowse] Refreshing cart to get final state');
cart = await Api.getCart(orderId: cart.orderId); cart = await Api.getCart(orderId: cart.orderId);
print('[MenuBrowse] Final cart has ${cart.lineItems.length} total line items');
appState.updateCartItemCount(cart.itemCount); appState.updateCartItemCount(cart.itemCount);
@ -454,38 +457,58 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
Set<int> selectedItemIds, Set<int> selectedItemIds,
) async { ) async {
final children = _itemsByParent[parentItemId] ?? []; final children = _itemsByParent[parentItemId] ?? [];
print('[MenuBrowse] _addModifiersRecursively: parentItemId=$parentItemId has ${children.length} children');
for (final child in children) { for (final child in children) {
final isSelected = selectedItemIds.contains(child.itemId); final isSelected = selectedItemIds.contains(child.itemId);
final grandchildren = _itemsByParent[child.itemId] ?? [];
final hasGrandchildren = grandchildren.isNotEmpty;
final hasSelectedDescendants = _hasSelectedDescendants(child.itemId, selectedItemIds);
// Only add selected items to the cart print('[MenuBrowse] Child ${child.name} (ItemID=${child.itemId}): selected=$isSelected, hasChildren=$hasGrandchildren, hasSelectedDescendants=$hasSelectedDescendants');
if (!isSelected) {
continue; // Add this item if it's selected OR if it has selected descendants (to maintain hierarchy)
if (isSelected || hasSelectedDescendants) {
print('[MenuBrowse] Adding ${isSelected ? "selected" : "container"} item ${child.name} with ParentOrderLineItemID=$parentOrderLineItemId');
final cart = await Api.setLineItem(
orderId: orderId,
parentOrderLineItemId: parentOrderLineItemId,
itemId: child.itemId,
isSelected: true,
);
// Find the OrderLineItemID of this item we just added
final childLineItem = cart.lineItems.lastWhere(
(li) => li.itemId == child.itemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted,
orElse: () => throw StateError('Child line item not found for ItemID=${child.itemId}'),
);
// Recursively add children with this item as the new parent
if (hasGrandchildren) {
await _addModifiersRecursively(
orderId,
childLineItem.orderLineItemId,
child.itemId,
selectedItemIds,
);
}
} }
// Add this modifier with the correct parent OrderLineItemID
final cart = await Api.setLineItem(
orderId: orderId,
parentOrderLineItemId: parentOrderLineItemId,
itemId: child.itemId,
isSelected: true,
);
// Find the OrderLineItemID of this modifier we just added
final childLineItem = cart.lineItems.lastWhere(
(li) => li.itemId == child.itemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted,
orElse: () => throw StateError('Child line item not found for ItemID=${child.itemId}'),
);
// Recursively add grandchildren
await _addModifiersRecursively(
orderId,
childLineItem.orderLineItemId,
child.itemId,
selectedItemIds,
);
} }
} }
/// Check if any descendants of this item are selected
bool _hasSelectedDescendants(int itemId, Set<int> selectedItemIds) {
final children = _itemsByParent[itemId] ?? [];
for (final child in children) {
if (selectedItemIds.contains(child.itemId)) {
return true;
}
if (_hasSelectedDescendants(child.itemId, selectedItemIds)) {
return true;
}
}
return false;
}
} }
/// Recursive item customization sheet with full rule support /// Recursive item customization sheet with full rule support

View file

@ -386,6 +386,29 @@ class Api {
} }
} }
static Future<Map<String, dynamic>> checkOrderStatus({
required int orderId,
required int lastKnownStatusId,
}) async {
final raw = await _postRaw(
"/orders/checkStatusUpdate.cfm",
{
"OrderID": orderId,
"LastKnownStatusID": lastKnownStatusId,
},
);
final j = _requireJson(raw, "CheckOrderStatus");
if (!_ok(j)) {
throw StateError(
"CheckOrderStatus API returned OK=false\nERROR: ${_err(j)}\nHTTP Status: ${raw.statusCode}",
);
}
return j;
}
// ------------------------- // -------------------------
// Beacons // Beacons
// ------------------------- // -------------------------

View file

@ -61,19 +61,14 @@ class OrderPollingService {
try { try {
print('[OrderPolling] 🔍 Checking status for OrderID=$_currentOrderId'); print('[OrderPolling] 🔍 Checking status for OrderID=$_currentOrderId');
final response = await Api.post( final data = await Api.checkOrderStatus(
'/orders/checkStatusUpdate.cfm', orderId: _currentOrderId!,
body: { lastKnownStatusId: _lastKnownStatusId!,
'OrderID': _currentOrderId,
'LastKnownStatusID': _lastKnownStatusId,
},
); );
final data = response.data;
if (data['OK'] == true && data['HAS_UPDATE'] == true) { if (data['OK'] == true && data['HAS_UPDATE'] == true) {
final orderStatus = data['ORDER_STATUS']; final orderStatus = data['ORDER_STATUS'];
final newStatusId = orderStatus['StatusID'] as int; final newStatusId = (orderStatus['StatusID'] as num).toInt();
final statusName = orderStatus['StatusName'] as String; final statusName = orderStatus['StatusName'] as String;
final message = orderStatus['Message'] as String; final message = orderStatus['Message'] as String;