fix: Make transactions atomic / AutoLogout

This commit is contained in:
2026-08-03 02:22:26 +02:00
parent 6060ca2713
commit 0997d110d4
14 changed files with 604 additions and 285 deletions
+15
View File
@@ -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;
+20
View File
@@ -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;
}
}
}
+97 -41
View File
@@ -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();
}
}
+35 -18
View File
@@ -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');
}
}
}
+14 -36
View File
@@ -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();
+12
View File
@@ -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();
}
}
+5 -5
View File
@@ -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);
+1 -1
View File
@@ -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');
});