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:go_router/go_router.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'app_lifecycle_lock.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
|
import '../viewmodels/pin_lock_view_model.dart';
|
||||||
import '../viewmodels/settings_view_model.dart';
|
import '../viewmodels/settings_view_model.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
@@ -17,15 +19,28 @@ class KoolTabApp extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _KoolTabAppState extends State<KoolTabApp> {
|
class _KoolTabAppState extends State<KoolTabApp> {
|
||||||
|
late final AppLifecycleLockObserver _lifecycleLockObserver;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
_lifecycleLockObserver = AppLifecycleLockObserver(
|
||||||
|
onLock: () => context.read<PinLockViewModel>().lock(),
|
||||||
|
);
|
||||||
|
WidgetsBinding.instance.addObserver(_lifecycleLockObserver);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<SettingsViewModel>().ensureLoaded();
|
context.read<SettingsViewModel>().ensureLoaded();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(_lifecycleLockObserver);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final settings = context.watch<SettingsViewModel>().settings;
|
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/payment_method.dart';
|
||||||
import '../models/product.dart';
|
import '../models/product.dart';
|
||||||
import '../models/tab_item.dart';
|
import '../models/tab_item.dart';
|
||||||
|
import 'product_service.dart';
|
||||||
|
|
||||||
abstract class BarTabService {
|
abstract class BarTabService {
|
||||||
Future<List<BarTab>> getOpenTabs();
|
Future<List<BarTab>> getOpenTabs();
|
||||||
@@ -19,18 +20,19 @@ abstract class BarTabService {
|
|||||||
Future<void> addProductToTab({
|
Future<void> addProductToTab({
|
||||||
required String tabId,
|
required String tabId,
|
||||||
required Product product,
|
required Product product,
|
||||||
Future<void> Function()? stockAdjustment,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> updateTabItemQuantity({
|
Future<int> adjustTabItemQuantity({
|
||||||
required String tabItemId,
|
required String tabItemId,
|
||||||
required int quantity,
|
required int delta,
|
||||||
Future<void> Function()? stockAdjustment,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Archives the tab's current items into history and clears them.
|
/// Archives the tab's current items into history and clears them.
|
||||||
/// The tab itself stays open under the same customer name.
|
/// 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();
|
Future<List<ClosedTab>> getClosedTabs();
|
||||||
|
|
||||||
@@ -94,6 +96,54 @@ class DriftBarTabService implements BarTabService {
|
|||||||
return rows.map(_mapItemRow).toList();
|
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
|
@override
|
||||||
Future<List<BarTab>> getOpenTabs() async {
|
Future<List<BarTab>> getOpenTabs() async {
|
||||||
final query = database.select(database.barTabs)
|
final query = database.select(database.barTabs)
|
||||||
@@ -157,9 +207,10 @@ class DriftBarTabService implements BarTabService {
|
|||||||
Future<void> addProductToTab({
|
Future<void> addProductToTab({
|
||||||
required String tabId,
|
required String tabId,
|
||||||
required Product product,
|
required Product product,
|
||||||
Future<void> Function()? stockAdjustment,
|
|
||||||
}) async {
|
}) async {
|
||||||
await database.transaction(() async {
|
await database.transaction(() async {
|
||||||
|
await _decreaseProductStock(product.id, 1);
|
||||||
|
|
||||||
final existingItemQuery = database.select(database.tabItems)
|
final existingItemQuery = database.select(database.tabItems)
|
||||||
..where(
|
..where(
|
||||||
(item) =>
|
(item) =>
|
||||||
@@ -176,10 +227,6 @@ class DriftBarTabService implements BarTabService {
|
|||||||
TabItemsCompanion(quantity: Value(existingItem.quantity + 1)),
|
TabItemsCompanion(quantity: Value(existingItem.quantity + 1)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (stockAdjustment != null) {
|
|
||||||
await stockAdjustment();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,46 +243,60 @@ class DriftBarTabService implements BarTabService {
|
|||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (stockAdjustment != null) {
|
|
||||||
await stockAdjustment();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> updateTabItemQuantity({
|
Future<int> adjustTabItemQuantity({
|
||||||
required String tabItemId,
|
required String tabItemId,
|
||||||
required int quantity,
|
required int delta,
|
||||||
Future<void> Function()? stockAdjustment,
|
|
||||||
}) async {
|
}) async {
|
||||||
await database.transaction(() async {
|
return database.transaction(() async {
|
||||||
if (quantity <= 0) {
|
final item = await (database.select(
|
||||||
final deleteQuery = database.delete(database.tabItems)
|
database.tabItems,
|
||||||
..where((item) => item.id.equals(tabItemId));
|
)..where((row) => row.id.equals(tabItemId))).getSingleOrNull();
|
||||||
|
|
||||||
await deleteQuery.go();
|
if (item == null || delta == 0) return 0;
|
||||||
|
|
||||||
if (stockAdjustment != null) {
|
final requestedQuantity = item.quantity + delta;
|
||||||
await stockAdjustment();
|
final actualDelta = requestedQuantity <= 0 ? -item.quantity : delta;
|
||||||
|
|
||||||
|
if (actualDelta > 0) {
|
||||||
|
await _decreaseProductStock(item.productId, actualDelta);
|
||||||
|
} else {
|
||||||
|
await _increaseProductStock(item.productId, -actualDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
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)));
|
||||||
|
|
||||||
|
if (updatedRows != 1) {
|
||||||
|
throw StateError(
|
||||||
|
'Tab item was changed before its quantity was updated',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final updateQuery = database.update(database.tabItems)
|
return actualDelta;
|
||||||
..where((item) => item.id.equals(tabItemId));
|
|
||||||
|
|
||||||
await updateQuery.write(TabItemsCompanion(quantity: Value(quantity)));
|
|
||||||
|
|
||||||
if (stockAdjustment != null) {
|
|
||||||
await stockAdjustment();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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 {
|
await database.transaction(() async {
|
||||||
final tabQuery = database.select(database.barTabs)
|
final tabQuery = database.select(database.barTabs)
|
||||||
..where((tab) => tab.id.equals(tabId));
|
..where((tab) => tab.id.equals(tabId));
|
||||||
@@ -307,8 +368,7 @@ class DriftBarTabService implements BarTabService {
|
|||||||
..limit(limit, offset: offset);
|
..limit(limit, offset: offset);
|
||||||
|
|
||||||
if (customerName != null && customerName.isNotEmpty) {
|
if (customerName != null && customerName.isNotEmpty) {
|
||||||
query = query
|
query = query..where((tab) => tab.customerName.equals(customerName));
|
||||||
..where((tab) => tab.customerName.equals(customerName));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final closedTabRows = await query.get();
|
final closedTabRows = await query.get();
|
||||||
@@ -355,10 +415,6 @@ class DriftBarTabService implements BarTabService {
|
|||||||
@override
|
@override
|
||||||
Future<List<String>> getDistinctCustomerNames() async {
|
Future<List<String>> getDistinctCustomerNames() async {
|
||||||
final rows = await database.select(database.closedTabs).get();
|
final rows = await database.select(database.closedTabs).get();
|
||||||
return rows
|
return rows.map((row) => row.customerName).toSet().toList()..sort();
|
||||||
.map((row) => row.customerName)
|
|
||||||
.toSet()
|
|
||||||
.toList()
|
|
||||||
..sort();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,31 +130,48 @@ class DriftProductService implements ProductService {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> decreaseStock(String productId, int amount) async {
|
Future<void> decreaseStock(String productId, int amount) async {
|
||||||
final product = await getProductById(productId);
|
_validateStockAmount(amount);
|
||||||
|
|
||||||
if (product == null) {
|
final updatedRows = await database.customUpdate(
|
||||||
throw Exception('Product not found');
|
'UPDATE products '
|
||||||
}
|
'SET stock_quantity = stock_quantity - ? '
|
||||||
|
'WHERE id = ? AND stock_quantity >= ?',
|
||||||
if (product.stockQuantity < amount) {
|
variables: [
|
||||||
throw const InsufficientStockException();
|
Variable.withInt(amount),
|
||||||
}
|
Variable.withString(productId),
|
||||||
|
Variable.withInt(amount),
|
||||||
await updateProduct(
|
],
|
||||||
product.copyWith(stockQuantity: product.stockQuantity - 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
|
@override
|
||||||
Future<void> increaseStock(String productId, int amount) async {
|
Future<void> increaseStock(String productId, int amount) async {
|
||||||
final product = await getProductById(productId);
|
_validateStockAmount(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;
|
||||||
|
|
||||||
if (product == null) {
|
|
||||||
throw Exception('Product not found');
|
throw Exception('Product not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateProduct(
|
void _validateStockAmount(int amount) {
|
||||||
product.copyWith(stockQuantity: product.stockQuantity + 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 (tab == null) return;
|
||||||
|
|
||||||
if (product.stockQuantity <= 0) {
|
|
||||||
throw const InsufficientStockException();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await barTabService.addProductToTab(
|
await barTabService.addProductToTab(tabId: tab.id, product: product);
|
||||||
tabId: tab.id,
|
inventory.applyStockDelta(product.id, -1);
|
||||||
product: product,
|
|
||||||
stockAdjustment: () => inventory.decreaseStock(product.id, 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
await _reloadTabs();
|
await _reloadTabs();
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
@@ -118,35 +111,15 @@ class BarScreenViewModel extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> changeItemQuantity(TabItem item, int quantity) async {
|
Future<void> changeItemQuantity(TabItem item, int delta) async {
|
||||||
final difference = quantity - item.quantity;
|
if (delta == 0) return;
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await barTabService.updateTabItemQuantity(
|
final actualDelta = await barTabService.adjustTabItemQuantity(
|
||||||
tabItemId: item.id,
|
tabItemId: item.id,
|
||||||
quantity: quantity,
|
delta: delta,
|
||||||
stockAdjustment: () async {
|
|
||||||
if (difference > 0) {
|
|
||||||
await inventory.decreaseStock(item.productId, difference);
|
|
||||||
} else if (difference < 0) {
|
|
||||||
await inventory.increaseStock(item.productId, -difference);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
inventory.applyStockDelta(item.productId, -actualDelta);
|
||||||
|
|
||||||
await _reloadTabs();
|
await _reloadTabs();
|
||||||
} catch (e, stack) {
|
} 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;
|
final tab = selectedTab;
|
||||||
|
|
||||||
if (tab == null) return;
|
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 {
|
try {
|
||||||
await barTabService.closeTab(tabId, paymentMethod: paymentMethod);
|
await barTabService.closeTab(tabId, paymentMethod: paymentMethod);
|
||||||
await _reloadTabs();
|
await _reloadTabs();
|
||||||
|
|||||||
@@ -65,4 +65,16 @@ class InventoryViewModel extends ChangeNotifier {
|
|||||||
rethrow;
|
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 Map<String, int> stockByProductId;
|
||||||
final VoidCallback onNewTabPressed;
|
final VoidCallback onNewTabPressed;
|
||||||
final ValueChanged<String> onTabSelected;
|
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 VoidCallback onCloseTabPressed;
|
||||||
final Future<void> Function(String) onTabClosed;
|
final Future<void> Function(String) onTabClosed;
|
||||||
|
|
||||||
@@ -634,7 +634,7 @@ class _OpenTabsList extends StatelessWidget {
|
|||||||
class _SelectedTabDetails extends StatelessWidget {
|
class _SelectedTabDetails extends StatelessWidget {
|
||||||
final BarTab tab;
|
final BarTab tab;
|
||||||
final Map<String, int> stockByProductId;
|
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;
|
final VoidCallback onCloseTabPressed;
|
||||||
|
|
||||||
const _SelectedTabDetails({
|
const _SelectedTabDetails({
|
||||||
@@ -747,7 +747,7 @@ class _SelectedTabDetails extends StatelessWidget {
|
|||||||
class _TabItemRow extends StatelessWidget {
|
class _TabItemRow extends StatelessWidget {
|
||||||
final TabItem item;
|
final TabItem item;
|
||||||
final Map<String, int> stockByProductId;
|
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({
|
const _TabItemRow({
|
||||||
required this.item,
|
required this.item,
|
||||||
@@ -796,7 +796,7 @@ class _TabItemRow extends StatelessWidget {
|
|||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
try {
|
try {
|
||||||
await onQuantityChanged(item, item.quantity - 1);
|
await onQuantityChanged(item, -1);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
Sentry.captureException(e, stackTrace: stack);
|
Sentry.captureException(e, stackTrace: stack);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -821,7 +821,7 @@ class _TabItemRow extends StatelessWidget {
|
|||||||
onPressed: canIncrease
|
onPressed: canIncrease
|
||||||
? () async {
|
? () async {
|
||||||
try {
|
try {
|
||||||
await onQuantityChanged(item, item.quantity + 1);
|
await onQuantityChanged(item, 1);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
if (e is! InsufficientStockException) {
|
if (e is! InsufficientStockException) {
|
||||||
Sentry.captureException(e, stackTrace: stack);
|
Sentry.captureException(e, stackTrace: stack);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
|||||||
|
|
||||||
void _startVersionHold() {
|
void _startVersionHold() {
|
||||||
_versionHoldTimer?.cancel();
|
_versionHoldTimer?.cancel();
|
||||||
_versionHoldTimer = Timer(const Duration(seconds: 3), () {
|
_versionHoldTimer = Timer(const Duration(seconds: 1), () {
|
||||||
_versionHoldTimer = null;
|
_versionHoldTimer = null;
|
||||||
if (mounted) context.push('/dev');
|
if (mounted) context.push('/dev');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions DisableDelayedExpansion
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||||
|
"$content = [IO.File]::ReadAllText('pubspec.yaml'); " ^
|
||||||
|
"$match = [regex]::Match($content, '(?m)(?:\A|\r?\n)version:[ \t]*([^\r\n]*)'); " ^
|
||||||
|
"if (-not $match.Success) { Write-Error 'Could not find the version line in pubspec.yaml.'; exit 1 }; " ^
|
||||||
|
"Write-Output ('Current version: ' + $match.Groups[1].Value.Trim())"
|
||||||
|
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Could not read the current version. Release cancelled.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set "APP_VERSION="
|
||||||
|
set /p "APP_VERSION=Enter release version (for example 1.0.10 or 1.0.10+2): "
|
||||||
|
|
||||||
|
if not defined APP_VERSION (
|
||||||
|
echo No version entered. Release cancelled.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||||
|
"$version = $env:APP_VERSION; " ^
|
||||||
|
"if ($version -notmatch '\A[0-9]+\.[0-9]+\.[0-9]+(\+[0-9]+)?\z') { " ^
|
||||||
|
" Write-Error 'Use a version like 1.0.10 or 1.0.10+2.'; exit 1 " ^
|
||||||
|
"}; " ^
|
||||||
|
"$path = 'pubspec.yaml'; " ^
|
||||||
|
"$content = [IO.File]::ReadAllText($path); " ^
|
||||||
|
"$updated = ([regex]::new('(?m)(\A|\r?\n)version:[^\r\n]*')).Replace($content, ('${1}version: ' + $version), 1); " ^
|
||||||
|
"if ($updated -eq $content) { Write-Error 'Could not find the version line in pubspec.yaml.'; exit 1 }; " ^
|
||||||
|
"[IO.File]::WriteAllText($path, $updated)"
|
||||||
|
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Version update failed. Release cancelled.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Updated pubspec.yaml to version %APP_VERSION%.
|
||||||
|
echo Building release APK...
|
||||||
|
flutter build apk --release
|
||||||
|
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Release APK build failed.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Release APK created at build\app\outputs\flutter-apk\app-release.apk
|
||||||
|
endlocal
|
||||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.9+1
|
version: 1.0.11
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:kooltab2/app/app_lifecycle_lock.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('locks when the app is no longer active', () {
|
||||||
|
var lockCount = 0;
|
||||||
|
final observer = AppLifecycleLockObserver(onLock: () => lockCount++);
|
||||||
|
|
||||||
|
for (final state in [
|
||||||
|
AppLifecycleState.inactive,
|
||||||
|
AppLifecycleState.hidden,
|
||||||
|
AppLifecycleState.paused,
|
||||||
|
AppLifecycleState.detached,
|
||||||
|
]) {
|
||||||
|
observer.didChangeAppLifecycleState(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(lockCount, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not lock while the app is active', () {
|
||||||
|
var lockCount = 0;
|
||||||
|
final observer = AppLifecycleLockObserver(onLock: () => lockCount++);
|
||||||
|
|
||||||
|
observer.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||||
|
|
||||||
|
expect(lockCount, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:kooltab2/models/product.dart';
|
|
||||||
import 'package:kooltab2/models/tab_item.dart';
|
import 'package:kooltab2/models/tab_item.dart';
|
||||||
import 'package:kooltab2/services/bar_tab_service.dart';
|
import 'package:kooltab2/services/bar_tab_service.dart';
|
||||||
import 'package:kooltab2/services/product_service.dart';
|
import 'package:kooltab2/services/product_service.dart';
|
||||||
@@ -27,17 +26,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('propagates an insufficient-stock error from the transaction', () async {
|
||||||
'does not increase a tab item when its product is out of stock',
|
|
||||||
() async {
|
|
||||||
const product = Product(
|
|
||||||
id: 'prod-1',
|
|
||||||
name: 'Chips',
|
|
||||||
category: 'Snacks',
|
|
||||||
stockQuantity: 0,
|
|
||||||
lowStockThreshold: 5,
|
|
||||||
priceInCents: 150,
|
|
||||||
);
|
|
||||||
const item = TabItem(
|
const item = TabItem(
|
||||||
id: 'item-1',
|
id: 'item-1',
|
||||||
tabId: 'tab-1',
|
tabId: 'tab-1',
|
||||||
@@ -48,17 +37,17 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
when(
|
when(
|
||||||
() => productService.getProducts(),
|
() => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1),
|
||||||
).thenAnswer((_) async => [product]);
|
).thenAnswer((_) async => throw const InsufficientStockException());
|
||||||
await inventory.load();
|
|
||||||
|
|
||||||
await expectLater(
|
await expectLater(
|
||||||
viewModel.changeItemQuantity(item, 21),
|
viewModel.changeItemQuantity(item, 1),
|
||||||
throwsA(isA<InsufficientStockException>()),
|
throwsA(isA<InsufficientStockException>()),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(viewModel.errorMessage, isNull);
|
expect(viewModel.errorMessage, isNull);
|
||||||
verifyNoMoreInteractions(barTabService);
|
verify(
|
||||||
},
|
() => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1),
|
||||||
);
|
).called(1);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,28 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:kooltab2/database/app_database.dart';
|
import 'package:kooltab2/database/app_database.dart';
|
||||||
import 'package:kooltab2/models/product.dart';
|
import 'package:kooltab2/models/product.dart';
|
||||||
import 'package:kooltab2/services/bar_tab_service.dart';
|
import 'package:kooltab2/services/bar_tab_service.dart';
|
||||||
|
import 'package:kooltab2/services/product_service.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late AppDatabase database;
|
late AppDatabase database;
|
||||||
late DriftBarTabService service;
|
late DriftBarTabService service;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() async {
|
||||||
database = AppDatabase(NativeDatabase.memory());
|
database = AppDatabase(NativeDatabase.memory());
|
||||||
service = DriftBarTabService(database: database);
|
service = DriftBarTabService(database: database);
|
||||||
|
|
||||||
|
await database
|
||||||
|
.into(database.products)
|
||||||
|
.insert(
|
||||||
|
ProductsCompanion.insert(
|
||||||
|
id: 'prod-1',
|
||||||
|
name: 'Test Beer',
|
||||||
|
category: 'Drinks',
|
||||||
|
stockQuantity: 100,
|
||||||
|
lowStockThreshold: 10,
|
||||||
|
priceInCents: 400,
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
@@ -59,7 +73,9 @@ void main() {
|
|||||||
|
|
||||||
test('returns only open tabs', () async {
|
test('returns only open tabs', () async {
|
||||||
await service.createTab(customerName: 'Open Tab');
|
await service.createTab(customerName: 'Open Tab');
|
||||||
await database.into(database.barTabs).insert(
|
await database
|
||||||
|
.into(database.barTabs)
|
||||||
|
.insert(
|
||||||
BarTabsCompanion.insert(
|
BarTabsCompanion.insert(
|
||||||
id: 'closed-tab',
|
id: 'closed-tab',
|
||||||
customerName: 'Closed Tab',
|
customerName: 'Closed Tab',
|
||||||
@@ -76,7 +92,9 @@ void main() {
|
|||||||
|
|
||||||
test('tabs sorted by openedAt descending', () async {
|
test('tabs sorted by openedAt descending', () async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
await database.into(database.barTabs).insert(
|
await database
|
||||||
|
.into(database.barTabs)
|
||||||
|
.insert(
|
||||||
BarTabsCompanion.insert(
|
BarTabsCompanion.insert(
|
||||||
id: 'tab-first',
|
id: 'tab-first',
|
||||||
customerName: 'First',
|
customerName: 'First',
|
||||||
@@ -84,7 +102,9 @@ void main() {
|
|||||||
openedAt: now.subtract(const Duration(hours: 1)),
|
openedAt: now.subtract(const Duration(hours: 1)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await database.into(database.barTabs).insert(
|
await database
|
||||||
|
.into(database.barTabs)
|
||||||
|
.insert(
|
||||||
BarTabsCompanion.insert(
|
BarTabsCompanion.insert(
|
||||||
id: 'tab-second',
|
id: 'tab-second',
|
||||||
customerName: 'Second',
|
customerName: 'Second',
|
||||||
@@ -152,9 +172,38 @@ void main() {
|
|||||||
final updatedTab = await service.getTabById(tab.id);
|
final updatedTab = await service.getTabById(tab.id);
|
||||||
expect(updatedTab!.items.first.lineTotalInCents, 900);
|
expect(updatedTab!.items.first.lineTotalInCents, 900);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('decreases stock as part of adding the item', () async {
|
||||||
|
final tab = await service.createTab(customerName: 'John');
|
||||||
|
final product = createTestProduct();
|
||||||
|
|
||||||
|
await service.addProductToTab(tabId: tab.id, product: product);
|
||||||
|
|
||||||
|
final storedProduct = await (database.select(
|
||||||
|
database.products,
|
||||||
|
)..where((row) => row.id.equals(product.id))).getSingle();
|
||||||
|
expect(storedProduct.stockQuantity, 99);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('updateTabItemQuantity', () {
|
test('rolls back the tab insert when stock is unavailable', () async {
|
||||||
|
final tab = await service.createTab(customerName: 'John');
|
||||||
|
final product = createTestProduct();
|
||||||
|
|
||||||
|
await (database.update(database.products)
|
||||||
|
..where((row) => row.id.equals(product.id)))
|
||||||
|
.write(const ProductsCompanion(stockQuantity: Value(0)));
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
service.addProductToTab(tabId: tab.id, product: product),
|
||||||
|
throwsA(isA<InsufficientStockException>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedTab = await service.getTabById(tab.id);
|
||||||
|
expect(updatedTab!.items, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('adjustTabItemQuantity', () {
|
||||||
test('updates item quantity', () async {
|
test('updates item quantity', () async {
|
||||||
final tab = await service.createTab(customerName: 'John');
|
final tab = await service.createTab(customerName: 'John');
|
||||||
final product = createTestProduct();
|
final product = createTestProduct();
|
||||||
@@ -162,40 +211,67 @@ void main() {
|
|||||||
|
|
||||||
final tabItemId = (await service.getTabById(tab.id))!.items.first.id;
|
final tabItemId = (await service.getTabById(tab.id))!.items.first.id;
|
||||||
|
|
||||||
await service.updateTabItemQuantity(
|
await service.adjustTabItemQuantity(tabItemId: tabItemId, delta: 4);
|
||||||
tabItemId: tabItemId,
|
|
||||||
quantity: 5,
|
|
||||||
);
|
|
||||||
|
|
||||||
final updatedTab = await service.getTabById(tab.id);
|
final updatedTab = await service.getTabById(tab.id);
|
||||||
expect(updatedTab!.items.first.quantity, 5);
|
expect(updatedTab!.items.first.quantity, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deletes item when quantity is zero', () async {
|
test('deletes item when the delta removes all quantity', () async {
|
||||||
final tab = await service.createTab(customerName: 'John');
|
final tab = await service.createTab(customerName: 'John');
|
||||||
final product = createTestProduct();
|
final product = createTestProduct();
|
||||||
await service.addProductToTab(tabId: tab.id, product: product);
|
await service.addProductToTab(tabId: tab.id, product: product);
|
||||||
|
|
||||||
final tabItemId = (await service.getTabById(tab.id))!.items.first.id;
|
final tabItemId = (await service.getTabById(tab.id))!.items.first.id;
|
||||||
|
|
||||||
await service.updateTabItemQuantity(tabItemId: tabItemId, quantity: 0);
|
await service.adjustTabItemQuantity(tabItemId: tabItemId, delta: -1);
|
||||||
|
|
||||||
final updatedTab = await service.getTabById(tab.id);
|
final updatedTab = await service.getTabById(tab.id);
|
||||||
expect(updatedTab!.items, isEmpty);
|
expect(updatedTab!.items, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deletes item when quantity is negative', () async {
|
test('restores stock when quantity decreases', () async {
|
||||||
final tab = await service.createTab(customerName: 'John');
|
final tab = await service.createTab(customerName: 'John');
|
||||||
final product = createTestProduct();
|
final product = createTestProduct();
|
||||||
await service.addProductToTab(tabId: tab.id, product: product);
|
await service.addProductToTab(tabId: tab.id, product: product);
|
||||||
|
|
||||||
final tabItemId = (await service.getTabById(tab.id))!.items.first.id;
|
final tabItemId = (await service.getTabById(tab.id))!.items.first.id;
|
||||||
|
|
||||||
await service.updateTabItemQuantity(tabItemId: tabItemId, quantity: -1);
|
await service.adjustTabItemQuantity(tabItemId: tabItemId, delta: -1);
|
||||||
|
|
||||||
final updatedTab = await service.getTabById(tab.id);
|
final updatedTab = await service.getTabById(tab.id);
|
||||||
expect(updatedTab!.items, isEmpty);
|
expect(updatedTab!.items, isEmpty);
|
||||||
|
|
||||||
|
final updatedProduct = await (database.select(
|
||||||
|
database.products,
|
||||||
|
)..where((row) => row.id.equals(product.id))).getSingle();
|
||||||
|
expect(updatedProduct.stockQuantity, 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'applies concurrent deltas without losing stock consistency',
|
||||||
|
() async {
|
||||||
|
final tab = await service.createTab(customerName: 'John');
|
||||||
|
final product = createTestProduct();
|
||||||
|
await service.addProductToTab(tabId: tab.id, product: product);
|
||||||
|
|
||||||
|
final tabItemId = (await service.getTabById(tab.id))!.items.first.id;
|
||||||
|
|
||||||
|
final deltas = await Future.wait([
|
||||||
|
service.adjustTabItemQuantity(tabItemId: tabItemId, delta: 1),
|
||||||
|
service.adjustTabItemQuantity(tabItemId: tabItemId, delta: 1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(deltas, [1, 1]);
|
||||||
|
final updatedTab = await service.getTabById(tab.id);
|
||||||
|
expect(updatedTab!.items.first.quantity, 3);
|
||||||
|
|
||||||
|
final updatedProduct = await (database.select(
|
||||||
|
database.products,
|
||||||
|
)..where((row) => row.id.equals(product.id))).getSingle();
|
||||||
|
expect(updatedProduct.stockQuantity, 97);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('closeTab', () {
|
group('closeTab', () {
|
||||||
@@ -257,7 +333,9 @@ void main() {
|
|||||||
|
|
||||||
test('returns closed tabs sorted by closedAt descending', () async {
|
test('returns closed tabs sorted by closedAt descending', () async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
await database.into(database.closedTabs).insert(
|
await database
|
||||||
|
.into(database.closedTabs)
|
||||||
|
.insert(
|
||||||
ClosedTabsCompanion.insert(
|
ClosedTabsCompanion.insert(
|
||||||
id: 'closed-first',
|
id: 'closed-first',
|
||||||
originalTabId: 'original-1',
|
originalTabId: 'original-1',
|
||||||
@@ -265,7 +343,9 @@ void main() {
|
|||||||
closedAt: now.subtract(const Duration(hours: 1)),
|
closedAt: now.subtract(const Duration(hours: 1)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await database.into(database.closedTabs).insert(
|
await database
|
||||||
|
.into(database.closedTabs)
|
||||||
|
.insert(
|
||||||
ClosedTabsCompanion.insert(
|
ClosedTabsCompanion.insert(
|
||||||
id: 'closed-second',
|
id: 'closed-second',
|
||||||
originalTabId: 'original-2',
|
originalTabId: 'original-2',
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('returns only active products', () async {
|
test('returns only active products', () async {
|
||||||
await database.into(database.products).insert(
|
await database
|
||||||
|
.into(database.products)
|
||||||
|
.insert(
|
||||||
ProductsCompanion.insert(
|
ProductsCompanion.insert(
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'Active Product',
|
name: 'Active Product',
|
||||||
@@ -36,7 +38,9 @@ void main() {
|
|||||||
priceInCents: 500,
|
priceInCents: 500,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await database.into(database.products).insert(
|
await database
|
||||||
|
.into(database.products)
|
||||||
|
.insert(
|
||||||
ProductsCompanion.insert(
|
ProductsCompanion.insert(
|
||||||
id: '2',
|
id: '2',
|
||||||
name: 'Inactive Product',
|
name: 'Inactive Product',
|
||||||
@@ -55,7 +59,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('returns products sorted by name', () async {
|
test('returns products sorted by name', () async {
|
||||||
await database.into(database.products).insert(
|
await database
|
||||||
|
.into(database.products)
|
||||||
|
.insert(
|
||||||
ProductsCompanion.insert(
|
ProductsCompanion.insert(
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'Zebra',
|
name: 'Zebra',
|
||||||
@@ -65,7 +71,9 @@ void main() {
|
|||||||
priceInCents: 500,
|
priceInCents: 500,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await database.into(database.products).insert(
|
await database
|
||||||
|
.into(database.products)
|
||||||
|
.insert(
|
||||||
ProductsCompanion.insert(
|
ProductsCompanion.insert(
|
||||||
id: '2',
|
id: '2',
|
||||||
name: 'Apple',
|
name: 'Apple',
|
||||||
@@ -85,7 +93,9 @@ void main() {
|
|||||||
|
|
||||||
group('getProductById', () {
|
group('getProductById', () {
|
||||||
test('returns product when exists', () async {
|
test('returns product when exists', () async {
|
||||||
await database.into(database.products).insert(
|
await database
|
||||||
|
.into(database.products)
|
||||||
|
.insert(
|
||||||
ProductsCompanion.insert(
|
ProductsCompanion.insert(
|
||||||
id: 'prod-123',
|
id: 'prod-123',
|
||||||
name: 'Test Product',
|
name: 'Test Product',
|
||||||
@@ -128,7 +138,9 @@ void main() {
|
|||||||
|
|
||||||
group('updateProduct', () {
|
group('updateProduct', () {
|
||||||
test('updates product fields', () async {
|
test('updates product fields', () async {
|
||||||
await database.into(database.products).insert(
|
await database
|
||||||
|
.into(database.products)
|
||||||
|
.insert(
|
||||||
ProductsCompanion.insert(
|
ProductsCompanion.insert(
|
||||||
id: 'update-test',
|
id: 'update-test',
|
||||||
name: 'Original',
|
name: 'Original',
|
||||||
@@ -160,7 +172,9 @@ void main() {
|
|||||||
|
|
||||||
group('deleteProduct', () {
|
group('deleteProduct', () {
|
||||||
test('removes product from database', () async {
|
test('removes product from database', () async {
|
||||||
await database.into(database.products).insert(
|
await database
|
||||||
|
.into(database.products)
|
||||||
|
.insert(
|
||||||
ProductsCompanion.insert(
|
ProductsCompanion.insert(
|
||||||
id: 'delete-test',
|
id: 'delete-test',
|
||||||
name: 'To Delete',
|
name: 'To Delete',
|
||||||
@@ -180,7 +194,9 @@ void main() {
|
|||||||
|
|
||||||
group('decreaseStock', () {
|
group('decreaseStock', () {
|
||||||
test('decreases stock quantity', () async {
|
test('decreases stock quantity', () async {
|
||||||
await database.into(database.products).insert(
|
await database
|
||||||
|
.into(database.products)
|
||||||
|
.insert(
|
||||||
ProductsCompanion.insert(
|
ProductsCompanion.insert(
|
||||||
id: 'stock-test',
|
id: 'stock-test',
|
||||||
name: 'Stock Test',
|
name: 'Stock Test',
|
||||||
@@ -205,7 +221,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('throws when not enough stock', () async {
|
test('throws when not enough stock', () async {
|
||||||
await database.into(database.products).insert(
|
await database
|
||||||
|
.into(database.products)
|
||||||
|
.insert(
|
||||||
ProductsCompanion.insert(
|
ProductsCompanion.insert(
|
||||||
id: 'low-stock',
|
id: 'low-stock',
|
||||||
name: 'Low Stock',
|
name: 'Low Stock',
|
||||||
@@ -225,7 +243,9 @@ void main() {
|
|||||||
|
|
||||||
group('increaseStock', () {
|
group('increaseStock', () {
|
||||||
test('increases stock quantity', () async {
|
test('increases stock quantity', () async {
|
||||||
await database.into(database.products).insert(
|
await database
|
||||||
|
.into(database.products)
|
||||||
|
.insert(
|
||||||
ProductsCompanion.insert(
|
ProductsCompanion.insert(
|
||||||
id: 'increase-test',
|
id: 'increase-test',
|
||||||
name: 'Increase Test',
|
name: 'Increase Test',
|
||||||
@@ -248,6 +268,56 @@ void main() {
|
|||||||
throwsA(isA<Exception>()),
|
throwsA(isA<Exception>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects non-positive stock adjustments', () async {
|
||||||
|
await database
|
||||||
|
.into(database.products)
|
||||||
|
.insert(
|
||||||
|
ProductsCompanion.insert(
|
||||||
|
id: 'invalid-adjustment',
|
||||||
|
name: 'Invalid Adjustment',
|
||||||
|
category: 'Drinks',
|
||||||
|
stockQuantity: 10,
|
||||||
|
lowStockThreshold: 2,
|
||||||
|
priceInCents: 500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.decreaseStock('invalid-adjustment', 0),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => service.increaseStock('invalid-adjustment', -1),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not allow concurrent decrements below zero', () async {
|
||||||
|
await database
|
||||||
|
.into(database.products)
|
||||||
|
.insert(
|
||||||
|
ProductsCompanion.insert(
|
||||||
|
id: 'concurrent-stock',
|
||||||
|
name: 'Concurrent Stock',
|
||||||
|
category: 'Drinks',
|
||||||
|
stockQuantity: 1,
|
||||||
|
lowStockThreshold: 0,
|
||||||
|
priceInCents: 500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
Future.wait([
|
||||||
|
service.decreaseStock('concurrent-stock', 1),
|
||||||
|
service.decreaseStock('concurrent-stock', 1),
|
||||||
|
]),
|
||||||
|
throwsA(isA<InsufficientStockException>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
final product = await service.getProductById('concurrent-stock');
|
||||||
|
expect(product!.stockQuantity, 0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user