fix: Make transactions atomic / AutoLogout
This commit is contained in:
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'app_lifecycle_lock.dart';
|
||||
import '../models/settings.dart';
|
||||
import '../viewmodels/pin_lock_view_model.dart';
|
||||
import '../viewmodels/settings_view_model.dart';
|
||||
import '../theme.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
@@ -17,15 +19,28 @@ class KoolTabApp extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _KoolTabAppState extends State<KoolTabApp> {
|
||||
late final AppLifecycleLockObserver _lifecycleLockObserver;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_lifecycleLockObserver = AppLifecycleLockObserver(
|
||||
onLock: () => context.read<PinLockViewModel>().lock(),
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(_lifecycleLockObserver);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<SettingsViewModel>().ensureLoaded();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(_lifecycleLockObserver);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsViewModel>().settings;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppLifecycleLockObserver extends WidgetsBindingObserver {
|
||||
final VoidCallback onLock;
|
||||
|
||||
AppLifecycleLockObserver({required this.onLock});
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.hidden:
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.detached:
|
||||
onLock();
|
||||
case AppLifecycleState.resumed:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import '../models/closed_tab_item.dart';
|
||||
import '../models/payment_method.dart';
|
||||
import '../models/product.dart';
|
||||
import '../models/tab_item.dart';
|
||||
import 'product_service.dart';
|
||||
|
||||
abstract class BarTabService {
|
||||
Future<List<BarTab>> getOpenTabs();
|
||||
@@ -19,18 +20,19 @@ abstract class BarTabService {
|
||||
Future<void> addProductToTab({
|
||||
required String tabId,
|
||||
required Product product,
|
||||
Future<void> Function()? stockAdjustment,
|
||||
});
|
||||
|
||||
Future<void> updateTabItemQuantity({
|
||||
Future<int> adjustTabItemQuantity({
|
||||
required String tabItemId,
|
||||
required int quantity,
|
||||
Future<void> Function()? stockAdjustment,
|
||||
required int delta,
|
||||
});
|
||||
|
||||
/// Archives the tab's current items into history and clears them.
|
||||
/// The tab itself stays open under the same customer name.
|
||||
Future<void> closeTab(String tabId, {PaymentMethod paymentMethod = PaymentMethod.cash});
|
||||
Future<void> closeTab(
|
||||
String tabId, {
|
||||
PaymentMethod paymentMethod = PaymentMethod.cash,
|
||||
});
|
||||
|
||||
Future<List<ClosedTab>> getClosedTabs();
|
||||
|
||||
@@ -94,6 +96,54 @@ class DriftBarTabService implements BarTabService {
|
||||
return rows.map(_mapItemRow).toList();
|
||||
}
|
||||
|
||||
Future<void> _decreaseProductStock(String productId, int amount) async {
|
||||
if (amount <= 0) {
|
||||
throw ArgumentError.value(amount, 'amount', 'Must be greater than zero');
|
||||
}
|
||||
|
||||
final updatedRows = await database.customUpdate(
|
||||
'UPDATE products '
|
||||
'SET stock_quantity = stock_quantity - ? '
|
||||
'WHERE id = ? AND stock_quantity >= ?',
|
||||
variables: [
|
||||
Variable.withInt(amount),
|
||||
Variable.withString(productId),
|
||||
Variable.withInt(amount),
|
||||
],
|
||||
updates: {database.products},
|
||||
);
|
||||
|
||||
if (updatedRows == 1) return;
|
||||
|
||||
final product = await (database.select(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals(productId))).getSingleOrNull();
|
||||
|
||||
if (product == null) {
|
||||
throw Exception('Product not found');
|
||||
}
|
||||
|
||||
throw const InsufficientStockException();
|
||||
}
|
||||
|
||||
Future<void> _increaseProductStock(String productId, int amount) async {
|
||||
if (amount <= 0) {
|
||||
throw ArgumentError.value(amount, 'amount', 'Must be greater than zero');
|
||||
}
|
||||
|
||||
final updatedRows = await database.customUpdate(
|
||||
'UPDATE products '
|
||||
'SET stock_quantity = stock_quantity + ? '
|
||||
'WHERE id = ?',
|
||||
variables: [Variable.withInt(amount), Variable.withString(productId)],
|
||||
updates: {database.products},
|
||||
);
|
||||
|
||||
if (updatedRows == 1) return;
|
||||
|
||||
throw Exception('Product not found');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<BarTab>> getOpenTabs() async {
|
||||
final query = database.select(database.barTabs)
|
||||
@@ -157,9 +207,10 @@ class DriftBarTabService implements BarTabService {
|
||||
Future<void> addProductToTab({
|
||||
required String tabId,
|
||||
required Product product,
|
||||
Future<void> Function()? stockAdjustment,
|
||||
}) async {
|
||||
await database.transaction(() async {
|
||||
await _decreaseProductStock(product.id, 1);
|
||||
|
||||
final existingItemQuery = database.select(database.tabItems)
|
||||
..where(
|
||||
(item) =>
|
||||
@@ -176,10 +227,6 @@ class DriftBarTabService implements BarTabService {
|
||||
TabItemsCompanion(quantity: Value(existingItem.quantity + 1)),
|
||||
);
|
||||
|
||||
if (stockAdjustment != null) {
|
||||
await stockAdjustment();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -196,46 +243,60 @@ class DriftBarTabService implements BarTabService {
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
if (stockAdjustment != null) {
|
||||
await stockAdjustment();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateTabItemQuantity({
|
||||
Future<int> adjustTabItemQuantity({
|
||||
required String tabItemId,
|
||||
required int quantity,
|
||||
Future<void> Function()? stockAdjustment,
|
||||
required int delta,
|
||||
}) async {
|
||||
await database.transaction(() async {
|
||||
if (quantity <= 0) {
|
||||
final deleteQuery = database.delete(database.tabItems)
|
||||
..where((item) => item.id.equals(tabItemId));
|
||||
return database.transaction(() async {
|
||||
final item = await (database.select(
|
||||
database.tabItems,
|
||||
)..where((row) => row.id.equals(tabItemId))).getSingleOrNull();
|
||||
|
||||
await deleteQuery.go();
|
||||
if (item == null || delta == 0) return 0;
|
||||
|
||||
if (stockAdjustment != null) {
|
||||
await stockAdjustment();
|
||||
final requestedQuantity = item.quantity + delta;
|
||||
final actualDelta = requestedQuantity <= 0 ? -item.quantity : delta;
|
||||
|
||||
if (actualDelta > 0) {
|
||||
await _decreaseProductStock(item.productId, actualDelta);
|
||||
} else {
|
||||
await _increaseProductStock(item.productId, -actualDelta);
|
||||
}
|
||||
|
||||
if (requestedQuantity <= 0) {
|
||||
final deletedRows = await (database.delete(
|
||||
database.tabItems,
|
||||
)..where((row) => row.id.equals(tabItemId))).go();
|
||||
|
||||
if (deletedRows != 1) {
|
||||
throw StateError('Tab item was changed before it could be deleted');
|
||||
}
|
||||
} else {
|
||||
final updatedRows =
|
||||
await (database.update(database.tabItems)
|
||||
..where((row) => row.id.equals(tabItemId)))
|
||||
.write(TabItemsCompanion(quantity: Value(requestedQuantity)));
|
||||
|
||||
return;
|
||||
if (updatedRows != 1) {
|
||||
throw StateError(
|
||||
'Tab item was changed before its quantity was updated',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final updateQuery = database.update(database.tabItems)
|
||||
..where((item) => item.id.equals(tabItemId));
|
||||
|
||||
await updateQuery.write(TabItemsCompanion(quantity: Value(quantity)));
|
||||
|
||||
if (stockAdjustment != null) {
|
||||
await stockAdjustment();
|
||||
}
|
||||
return actualDelta;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> closeTab(String tabId, {PaymentMethod paymentMethod = PaymentMethod.cash}) async {
|
||||
Future<void> closeTab(
|
||||
String tabId, {
|
||||
PaymentMethod paymentMethod = PaymentMethod.cash,
|
||||
}) async {
|
||||
await database.transaction(() async {
|
||||
final tabQuery = database.select(database.barTabs)
|
||||
..where((tab) => tab.id.equals(tabId));
|
||||
@@ -307,8 +368,7 @@ class DriftBarTabService implements BarTabService {
|
||||
..limit(limit, offset: offset);
|
||||
|
||||
if (customerName != null && customerName.isNotEmpty) {
|
||||
query = query
|
||||
..where((tab) => tab.customerName.equals(customerName));
|
||||
query = query..where((tab) => tab.customerName.equals(customerName));
|
||||
}
|
||||
|
||||
final closedTabRows = await query.get();
|
||||
@@ -355,10 +415,6 @@ class DriftBarTabService implements BarTabService {
|
||||
@override
|
||||
Future<List<String>> getDistinctCustomerNames() async {
|
||||
final rows = await database.select(database.closedTabs).get();
|
||||
return rows
|
||||
.map((row) => row.customerName)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
return rows.map((row) => row.customerName).toSet().toList()..sort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,31 +130,48 @@ class DriftProductService implements ProductService {
|
||||
|
||||
@override
|
||||
Future<void> decreaseStock(String productId, int amount) async {
|
||||
final product = await getProductById(productId);
|
||||
_validateStockAmount(amount);
|
||||
|
||||
if (product == null) {
|
||||
throw Exception('Product not found');
|
||||
}
|
||||
|
||||
if (product.stockQuantity < amount) {
|
||||
throw const InsufficientStockException();
|
||||
}
|
||||
|
||||
await updateProduct(
|
||||
product.copyWith(stockQuantity: product.stockQuantity - amount),
|
||||
final updatedRows = await database.customUpdate(
|
||||
'UPDATE products '
|
||||
'SET stock_quantity = stock_quantity - ? '
|
||||
'WHERE id = ? AND stock_quantity >= ?',
|
||||
variables: [
|
||||
Variable.withInt(amount),
|
||||
Variable.withString(productId),
|
||||
Variable.withInt(amount),
|
||||
],
|
||||
updates: {database.products},
|
||||
);
|
||||
|
||||
if (updatedRows == 1) return;
|
||||
|
||||
final product = await getProductById(productId);
|
||||
if (product == null) throw Exception('Product not found');
|
||||
|
||||
throw const InsufficientStockException();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> increaseStock(String productId, int amount) async {
|
||||
final product = await getProductById(productId);
|
||||
_validateStockAmount(amount);
|
||||
|
||||
if (product == null) {
|
||||
throw Exception('Product not found');
|
||||
}
|
||||
|
||||
await updateProduct(
|
||||
product.copyWith(stockQuantity: product.stockQuantity + amount),
|
||||
final updatedRows = await database.customUpdate(
|
||||
'UPDATE products '
|
||||
'SET stock_quantity = stock_quantity + ? '
|
||||
'WHERE id = ?',
|
||||
variables: [Variable.withInt(amount), Variable.withString(productId)],
|
||||
updates: {database.products},
|
||||
);
|
||||
|
||||
if (updatedRows == 1) return;
|
||||
|
||||
throw Exception('Product not found');
|
||||
}
|
||||
|
||||
void _validateStockAmount(int amount) {
|
||||
if (amount <= 0) {
|
||||
throw ArgumentError.value(amount, 'amount', 'Must be greater than zero');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,16 +95,9 @@ class BarScreenViewModel extends ChangeNotifier {
|
||||
|
||||
if (tab == null) return;
|
||||
|
||||
if (product.stockQuantity <= 0) {
|
||||
throw const InsufficientStockException();
|
||||
}
|
||||
|
||||
try {
|
||||
await barTabService.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: product,
|
||||
stockAdjustment: () => inventory.decreaseStock(product.id, 1),
|
||||
);
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: product);
|
||||
inventory.applyStockDelta(product.id, -1);
|
||||
|
||||
await _reloadTabs();
|
||||
} catch (e, stack) {
|
||||
@@ -118,35 +111,15 @@ class BarScreenViewModel extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> changeItemQuantity(TabItem item, int quantity) async {
|
||||
final difference = quantity - item.quantity;
|
||||
|
||||
if (difference > 0) {
|
||||
Product? product;
|
||||
for (final candidate in inventory.products) {
|
||||
if (candidate.id == item.productId) {
|
||||
product = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (product != null && product.stockQuantity < difference) {
|
||||
throw const InsufficientStockException();
|
||||
}
|
||||
}
|
||||
Future<void> changeItemQuantity(TabItem item, int delta) async {
|
||||
if (delta == 0) return;
|
||||
|
||||
try {
|
||||
await barTabService.updateTabItemQuantity(
|
||||
final actualDelta = await barTabService.adjustTabItemQuantity(
|
||||
tabItemId: item.id,
|
||||
quantity: quantity,
|
||||
stockAdjustment: () async {
|
||||
if (difference > 0) {
|
||||
await inventory.decreaseStock(item.productId, difference);
|
||||
} else if (difference < 0) {
|
||||
await inventory.increaseStock(item.productId, -difference);
|
||||
}
|
||||
},
|
||||
delta: delta,
|
||||
);
|
||||
inventory.applyStockDelta(item.productId, -actualDelta);
|
||||
|
||||
await _reloadTabs();
|
||||
} catch (e, stack) {
|
||||
@@ -160,7 +133,9 @@ class BarScreenViewModel extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> closeSelectedTab({PaymentMethod paymentMethod = PaymentMethod.cash}) async {
|
||||
Future<void> closeSelectedTab({
|
||||
PaymentMethod paymentMethod = PaymentMethod.cash,
|
||||
}) async {
|
||||
final tab = selectedTab;
|
||||
|
||||
if (tab == null) return;
|
||||
@@ -177,7 +152,10 @@ class BarScreenViewModel extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> closeTab(String tabId, {PaymentMethod paymentMethod = PaymentMethod.cash}) async {
|
||||
Future<void> closeTab(
|
||||
String tabId, {
|
||||
PaymentMethod paymentMethod = PaymentMethod.cash,
|
||||
}) async {
|
||||
try {
|
||||
await barTabService.closeTab(tabId, paymentMethod: paymentMethod);
|
||||
await _reloadTabs();
|
||||
|
||||
@@ -65,4 +65,16 @@ class InventoryViewModel extends ChangeNotifier {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void applyStockDelta(String productId, int delta) {
|
||||
if (delta == 0) return;
|
||||
|
||||
final index = _products.indexWhere((product) => product.id == productId);
|
||||
if (index == -1) return;
|
||||
|
||||
_products[index] = _products[index].copyWith(
|
||||
stockQuantity: _products[index].stockQuantity + delta,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@ class _TabPanel extends StatefulWidget {
|
||||
final Map<String, int> stockByProductId;
|
||||
final VoidCallback onNewTabPressed;
|
||||
final ValueChanged<String> onTabSelected;
|
||||
final Future<void> Function(TabItem item, int quantity) onItemQuantityChanged;
|
||||
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
|
||||
final VoidCallback onCloseTabPressed;
|
||||
final Future<void> Function(String) onTabClosed;
|
||||
|
||||
@@ -634,7 +634,7 @@ class _OpenTabsList extends StatelessWidget {
|
||||
class _SelectedTabDetails extends StatelessWidget {
|
||||
final BarTab tab;
|
||||
final Map<String, int> stockByProductId;
|
||||
final Future<void> Function(TabItem item, int quantity) onItemQuantityChanged;
|
||||
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
|
||||
final VoidCallback onCloseTabPressed;
|
||||
|
||||
const _SelectedTabDetails({
|
||||
@@ -747,7 +747,7 @@ class _SelectedTabDetails extends StatelessWidget {
|
||||
class _TabItemRow extends StatelessWidget {
|
||||
final TabItem item;
|
||||
final Map<String, int> stockByProductId;
|
||||
final Future<void> Function(TabItem item, int quantity) onQuantityChanged;
|
||||
final Future<void> Function(TabItem item, int delta) onQuantityChanged;
|
||||
|
||||
const _TabItemRow({
|
||||
required this.item,
|
||||
@@ -796,7 +796,7 @@ class _TabItemRow extends StatelessWidget {
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await onQuantityChanged(item, item.quantity - 1);
|
||||
await onQuantityChanged(item, -1);
|
||||
} catch (e, stack) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
if (context.mounted) {
|
||||
@@ -821,7 +821,7 @@ class _TabItemRow extends StatelessWidget {
|
||||
onPressed: canIncrease
|
||||
? () async {
|
||||
try {
|
||||
await onQuantityChanged(item, item.quantity + 1);
|
||||
await onQuantityChanged(item, 1);
|
||||
} catch (e, stack) {
|
||||
if (e is! InsufficientStockException) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
|
||||
@@ -44,7 +44,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
|
||||
void _startVersionHold() {
|
||||
_versionHoldTimer?.cancel();
|
||||
_versionHoldTimer = Timer(const Duration(seconds: 3), () {
|
||||
_versionHoldTimer = Timer(const Duration(seconds: 1), () {
|
||||
_versionHoldTimer = null;
|
||||
if (mounted) context.push('/dev');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user