feat: add more detailed history and group per day

This commit is contained in:
2026-08-07 05:07:41 +02:00
parent 1dd7e69aa8
commit 799c1c4045
23 changed files with 2506 additions and 346 deletions
+45 -4
View File
@@ -63,6 +63,21 @@ class TabItems extends Table {
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
@DataClassName('TabItemPurchaseRow')
class TabItemPurchases extends Table {
TextColumn get id => text()();
TextColumn get tabItemId =>
text().references(TabItems, #id, onDelete: KeyAction.cascade)();
DateTimeColumn get purchasedAt => dateTime()();
DateTimeColumn get removedAt => dateTime().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@DataClassName('ClosedTabsRow') @DataClassName('ClosedTabsRow')
class ClosedTabs extends Table { class ClosedTabs extends Table {
TextColumn get id => text()(); TextColumn get id => text()();
@@ -93,6 +108,10 @@ class ClosedTabItems extends Table {
IntColumn get unitPriceInCents => integer()(); IntColumn get unitPriceInCents => integer()();
DateTimeColumn get purchasedAt => dateTime().nullable()();
DateTimeColumn get removedAt => dateTime().nullable()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
@@ -124,6 +143,7 @@ class AppSettingsTable extends Table {
Products, Products,
BarTabs, BarTabs,
TabItems, TabItems,
TabItemPurchases,
ClosedTabs, ClosedTabs,
ClosedTabItems, ClosedTabItems,
@@ -135,7 +155,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 9; int get schemaVersion => 11;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@@ -144,14 +164,18 @@ class AppDatabase extends _$AppDatabase {
await migrator.createAll(); await migrator.createAll();
}, },
onUpgrade: (migrator, from, to) async { onUpgrade: (migrator, from, to) async {
Future<bool> hasSettingsColumn(String columnName) async { Future<bool> hasTableColumn(String tableName, String columnName) async {
final columns = await migrator.database final columns = await migrator.database
.customSelect('PRAGMA table_info(app_settings)') .customSelect('PRAGMA table_info($tableName)')
.get(); .get();
return columns.any((column) => column.data['name'] == columnName); return columns.any((column) => column.data['name'] == columnName);
} }
Future<bool> hasSettingsColumn(String columnName) {
return hasTableColumn('app_settings', columnName);
}
if (from < 2) { if (from < 2) {
await migrator.createTable(barTabs); await migrator.createTable(barTabs);
await migrator.createTable(tabItems); await migrator.createTable(tabItems);
@@ -170,7 +194,8 @@ class AppDatabase extends _$AppDatabase {
await migrator.createTable(appSettingsTable); await migrator.createTable(appSettingsTable);
} }
if (from < 6) { if (from < 6 &&
!await hasTableColumn('closed_tabs', 'payment_method')) {
await migrator.addColumn(closedTabs, closedTabs.paymentMethod); await migrator.addColumn(closedTabs, closedTabs.paymentMethod);
} }
@@ -191,6 +216,22 @@ class AppDatabase extends _$AppDatabase {
appSettingsTable.autoLockEnabled, appSettingsTable.autoLockEnabled,
); );
} }
if (from < 10) {
if (!await hasTableColumn('closed_tab_items', 'purchased_at')) {
await migrator.addColumn(
closedTabItems,
closedTabItems.purchasedAt,
);
}
if (!await hasTableColumn('closed_tab_items', 'removed_at')) {
await migrator.addColumn(closedTabItems, closedTabItems.removedAt);
}
}
if (from < 11) {
await migrator.createTable(tabItemPurchases);
}
}, },
); );
} }
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -83,6 +83,7 @@
"@version": {"placeholders": {"version": {"type": "String"}}}, "@version": {"placeholders": {"version": {"type": "String"}}},
"selectTabToAddItems": "Select a tab to add items", "selectTabToAddItems": "Select a tab to add items",
"openTabs": "Open tabs", "openTabs": "Open tabs",
"closedTabs": "Closed tabs",
"selectOrOpenTab": "Select or open a tab", "selectOrOpenTab": "Select or open a tab",
"noOpenTabs": "No open tabs", "noOpenTabs": "No open tabs",
"edit": "Edit", "edit": "Edit",
@@ -113,6 +114,7 @@
"pinsDidNotMatch": "PINs didnt match. Try again.", "pinsDidNotMatch": "PINs didnt match. Try again.",
"somethingWentWrong": "Something went wrong.", "somethingWentWrong": "Something went wrong.",
"incorrectPin": "Incorrect PIN.", "incorrectPin": "Incorrect PIN.",
"removed": "Removed",
"enterPrice": "Enter a price.", "enterPrice": "Enter a price.",
"enterValidPrice": "Enter a valid price.", "enterValidPrice": "Enter a valid price.",
"enterValidStock": "Enter a valid stock amount.", "enterValidStock": "Enter a valid stock amount.",
@@ -213,8 +215,8 @@
"stressTestGrid": "Stress test product grid", "stressTestGrid": "Stress test product grid",
"simulateLowStock": "Simulate low stock", "simulateLowStock": "Simulate low stock",
"setProductsBelowThreshold": "Set all products below threshold", "setProductsBelowThreshold": "Set all products below threshold",
"generateMockOrders": "Generate 100 mock orders", "generateMockOrders": "Generate mock order history",
"randomCustomersItemsAmounts": "Random customers, items, and amounts", "randomCustomersItemsAmounts": "100 orders across days with random products and purchase times",
"performance": "Performance", "performance": "Performance",
"clearImageCache": "Clear image cache", "clearImageCache": "Clear image cache",
"reloadProductImages": "Reload product images", "reloadProductImages": "Reload product images",
+14 -2
View File
@@ -578,6 +578,12 @@ abstract class AppLocalizations {
/// **'Open tabs'** /// **'Open tabs'**
String get openTabs; String get openTabs;
/// No description provided for @closedTabs.
///
/// In en, this message translates to:
/// **'Closed tabs'**
String get closedTabs;
/// No description provided for @selectOrOpenTab. /// No description provided for @selectOrOpenTab.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -740,6 +746,12 @@ abstract class AppLocalizations {
/// **'Incorrect PIN.'** /// **'Incorrect PIN.'**
String get incorrectPin; String get incorrectPin;
/// No description provided for @removed.
///
/// In en, this message translates to:
/// **'Removed'**
String get removed;
/// No description provided for @enterPrice. /// No description provided for @enterPrice.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1319,13 +1331,13 @@ abstract class AppLocalizations {
/// No description provided for @generateMockOrders. /// No description provided for @generateMockOrders.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Generate 100 mock orders'** /// **'Generate mock order history'**
String get generateMockOrders; String get generateMockOrders;
/// No description provided for @randomCustomersItemsAmounts. /// No description provided for @randomCustomersItemsAmounts.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Random customers, items, and amounts'** /// **'100 orders across days with random products and purchase times'**
String get randomCustomersItemsAmounts; String get randomCustomersItemsAmounts;
/// No description provided for @performance. /// No description provided for @performance.
+8 -2
View File
@@ -258,6 +258,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get openTabs => 'Open tabs'; String get openTabs => 'Open tabs';
@override
String get closedTabs => 'Closed tabs';
@override @override
String get selectOrOpenTab => 'Select or open a tab'; String get selectOrOpenTab => 'Select or open a tab';
@@ -358,6 +361,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get incorrectPin => 'Incorrect PIN.'; String get incorrectPin => 'Incorrect PIN.';
@override
String get removed => 'Removed';
@override @override
String get enterPrice => 'Enter a price.'; String get enterPrice => 'Enter a price.';
@@ -660,11 +666,11 @@ class AppLocalizationsEn extends AppLocalizations {
String get setProductsBelowThreshold => 'Set all products below threshold'; String get setProductsBelowThreshold => 'Set all products below threshold';
@override @override
String get generateMockOrders => 'Generate 100 mock orders'; String get generateMockOrders => 'Generate mock order history';
@override @override
String get randomCustomersItemsAmounts => String get randomCustomersItemsAmounts =>
'Random customers, items, and amounts'; '100 orders across days with random products and purchase times';
@override @override
String get performance => 'Performance'; String get performance => 'Performance';
+8 -2
View File
@@ -258,6 +258,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get openTabs => 'Open poefs'; String get openTabs => 'Open poefs';
@override
String get closedTabs => 'Gesloten poefs';
@override @override
String get selectOrOpenTab => 'Selecteer of open een poef'; String get selectOrOpenTab => 'Selecteer of open een poef';
@@ -359,6 +362,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get incorrectPin => 'Onjuiste PIN.'; String get incorrectPin => 'Onjuiste PIN.';
@override
String get removed => 'Verwijderd';
@override @override
String get enterPrice => 'Voer een prijs in.'; String get enterPrice => 'Voer een prijs in.';
@@ -668,11 +674,11 @@ class AppLocalizationsNl extends AppLocalizations {
'Alle producten onder de drempel instellen'; 'Alle producten onder de drempel instellen';
@override @override
String get generateMockOrders => '100 testbestellingen genereren'; String get generateMockOrders => 'Mock-bestelgeschiedenis genereren';
@override @override
String get randomCustomersItemsAmounts => String get randomCustomersItemsAmounts =>
'Willekeurige klanten, items en bedragen'; '100 bestellingen over meerdere dagen met willekeurige producten en tijden';
@override @override
String get performance => 'Prestaties'; String get performance => 'Prestaties';
+4 -2
View File
@@ -83,6 +83,7 @@
"@version": {"placeholders": {"version": {"type": "String"}}}, "@version": {"placeholders": {"version": {"type": "String"}}},
"selectTabToAddItems": "Selecteer een poef om items toe te voegen", "selectTabToAddItems": "Selecteer een poef om items toe te voegen",
"openTabs": "Open poefs", "openTabs": "Open poefs",
"closedTabs": "Gesloten poefs",
"selectOrOpenTab": "Selecteer of open een poef", "selectOrOpenTab": "Selecteer of open een poef",
"noOpenTabs": "Geen open poefs", "noOpenTabs": "Geen open poefs",
"edit": "Bewerken", "edit": "Bewerken",
@@ -113,6 +114,7 @@
"pinsDidNotMatch": "PINs kwamen niet overeen. Probeer het opnieuw.", "pinsDidNotMatch": "PINs kwamen niet overeen. Probeer het opnieuw.",
"somethingWentWrong": "Er is iets misgegaan.", "somethingWentWrong": "Er is iets misgegaan.",
"incorrectPin": "Onjuiste PIN.", "incorrectPin": "Onjuiste PIN.",
"removed": "Verwijderd",
"enterPrice": "Voer een prijs in.", "enterPrice": "Voer een prijs in.",
"enterValidPrice": "Voer een geldige prijs in.", "enterValidPrice": "Voer een geldige prijs in.",
"enterValidStock": "Voer een geldige voorraad in.", "enterValidStock": "Voer een geldige voorraad in.",
@@ -213,8 +215,8 @@
"stressTestGrid": "Productraster stresstesten", "stressTestGrid": "Productraster stresstesten",
"simulateLowStock": "Lage voorraad simuleren", "simulateLowStock": "Lage voorraad simuleren",
"setProductsBelowThreshold": "Alle producten onder de drempel instellen", "setProductsBelowThreshold": "Alle producten onder de drempel instellen",
"generateMockOrders": "100 testbestellingen genereren", "generateMockOrders": "Mock-bestelgeschiedenis genereren",
"randomCustomersItemsAmounts": "Willekeurige klanten, items en bedragen", "randomCustomersItemsAmounts": "100 bestellingen over meerdere dagen met willekeurige producten en tijden",
"performance": "Prestaties", "performance": "Prestaties",
"clearImageCache": "Afbeeldingencache wissen", "clearImageCache": "Afbeeldingencache wissen",
"reloadProductImages": "Productafbeeldingen opnieuw laden", "reloadProductImages": "Productafbeeldingen opnieuw laden",
+11 -10
View File
@@ -9,7 +9,7 @@ class ClosedTab {
final String originalTabId; final String originalTabId;
final String customerName; final String customerName;
final DateTime closedAt; final DateTime closedAt;
final PaymentMethod paymentMethod; final PaymentMethod paymentMethod;
final List<ClosedTabItem> items; final List<ClosedTabItem> items;
const ClosedTab({ const ClosedTab({
@@ -21,10 +21,11 @@ final PaymentMethod paymentMethod;
required this.items, required this.items,
}); });
int get itemCount => items.fold(0, (sum, item) => sum + item.quantity); int get itemCount =>
items.fold(0, (sum, item) => sum + (item.isRemoved ? 0 : item.quantity));
int get totalInCents => int get totalInCents =>
items.fold(0, (sum, item) => sum + item.quantity * item.unitPriceInCents); items.fold(0, (sum, item) => sum + item.lineTotalInCents);
String get formattedTotal => String get formattedTotal =>
NumberFormat.simpleCurrency().format(totalInCents / 100); NumberFormat.simpleCurrency().format(totalInCents / 100);
@@ -44,13 +45,13 @@ final PaymentMethod paymentMethod;
@override @override
int get hashCode => Object.hash( int get hashCode => Object.hash(
id, id,
originalTabId, originalTabId,
customerName, customerName,
closedAt, closedAt,
paymentMethod, paymentMethod,
Object.hashAll(items), Object.hashAll(items),
); );
} }
// _listEquals moved to utils/collection_utils.dart // _listEquals moved to utils/collection_utils.dart
+19 -9
View File
@@ -5,6 +5,8 @@ class ClosedTabItem {
final String productName; final String productName;
final int quantity; final int quantity;
final int unitPriceInCents; final int unitPriceInCents;
final DateTime? purchasedAt;
final DateTime? removedAt;
ClosedTabItem({ ClosedTabItem({
required this.id, required this.id,
@@ -13,9 +15,13 @@ class ClosedTabItem {
required this.productName, required this.productName,
required this.quantity, required this.quantity,
required this.unitPriceInCents, required this.unitPriceInCents,
this.purchasedAt,
this.removedAt,
}); });
int get lineTotalInCents => quantity * unitPriceInCents; bool get isRemoved => removedAt != null;
int get lineTotalInCents => isRemoved ? 0 : quantity * unitPriceInCents;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -26,15 +32,19 @@ class ClosedTabItem {
productId == other.productId && productId == other.productId &&
productName == other.productName && productName == other.productName &&
quantity == other.quantity && quantity == other.quantity &&
unitPriceInCents == other.unitPriceInCents; unitPriceInCents == other.unitPriceInCents &&
purchasedAt == other.purchasedAt &&
removedAt == other.removedAt;
@override @override
int get hashCode => Object.hash( int get hashCode => Object.hash(
id, id,
closedTabId, closedTabId,
productId, productId,
productName, productName,
quantity, quantity,
unitPriceInCents, unitPriceInCents,
); purchasedAt,
removedAt,
);
} }
+15 -8
View File
@@ -1,3 +1,6 @@
import 'tab_item_purchase.dart';
import '../utils/collection_utils.dart';
class TabItem { class TabItem {
final String id; final String id;
final String tabId; final String tabId;
@@ -5,6 +8,7 @@ class TabItem {
final String productName; final String productName;
final int quantity; final int quantity;
final int unitPriceInCents; final int unitPriceInCents;
final List<TabItemPurchase> purchases;
const TabItem({ const TabItem({
required this.id, required this.id,
@@ -13,6 +17,7 @@ class TabItem {
required this.productName, required this.productName,
required this.quantity, required this.quantity,
required this.unitPriceInCents, required this.unitPriceInCents,
this.purchases = const [],
}); });
int get lineTotalInCents => quantity * unitPriceInCents; int get lineTotalInCents => quantity * unitPriceInCents;
@@ -34,15 +39,17 @@ class TabItem {
productId == other.productId && productId == other.productId &&
productName == other.productName && productName == other.productName &&
quantity == other.quantity && quantity == other.quantity &&
unitPriceInCents == other.unitPriceInCents; unitPriceInCents == other.unitPriceInCents &&
listEquals(purchases, other.purchases);
@override @override
int get hashCode => Object.hash( int get hashCode => Object.hash(
id, id,
tabId, tabId,
productId, productId,
productName, productName,
quantity, quantity,
unitPriceInCents, unitPriceInCents,
); Object.hashAll(purchases),
);
} }
+27
View File
@@ -0,0 +1,27 @@
class TabItemPurchase {
final String id;
final String tabItemId;
final DateTime purchasedAt;
final DateTime? removedAt;
const TabItemPurchase({
required this.id,
required this.tabItemId,
required this.purchasedAt,
this.removedAt,
});
bool get isRemoved => removedAt != null;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TabItemPurchase &&
id == other.id &&
tabItemId == other.tabItemId &&
purchasedAt == other.purchasedAt &&
removedAt == other.removedAt;
@override
int get hashCode => Object.hash(id, tabItemId, purchasedAt, removedAt);
}
+167 -26
View File
@@ -8,10 +8,11 @@ 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 '../models/tab_item_purchase.dart';
import 'product_service.dart'; import 'product_service.dart';
abstract class BarTabService { abstract class BarTabService {
Future<List<BarTab>> getOpenTabs(); Future<List<BarTab>> getOpenTabs({bool includeRemoved = false});
Future<BarTab?> getTabById(String id); Future<BarTab?> getTabById(String id);
@@ -58,7 +59,16 @@ class DriftBarTabService implements BarTabService {
DriftBarTabService({required this.database}); DriftBarTabService({required this.database});
TabItem _mapItemRow(TabItemRow row) { TabItemPurchase _mapPurchaseRow(TabItemPurchaseRow row) {
return TabItemPurchase(
id: row.id,
tabItemId: row.tabItemId,
purchasedAt: row.purchasedAt,
removedAt: row.removedAt,
);
}
TabItem _mapItemRow(TabItemRow row, List<TabItemPurchase> purchases) {
return TabItem( return TabItem(
id: row.id, id: row.id,
tabId: row.tabId, tabId: row.tabId,
@@ -66,6 +76,7 @@ class DriftBarTabService implements BarTabService {
productName: row.productName, productName: row.productName,
quantity: row.quantity, quantity: row.quantity,
unitPriceInCents: row.unitPriceInCents, unitPriceInCents: row.unitPriceInCents,
purchases: purchases,
); );
} }
@@ -88,6 +99,8 @@ class DriftBarTabService implements BarTabService {
productName: row.productName, productName: row.productName,
quantity: row.quantity, quantity: row.quantity,
unitPriceInCents: row.unitPriceInCents, unitPriceInCents: row.unitPriceInCents,
purchasedAt: row.purchasedAt,
removedAt: row.removedAt,
); );
} }
@@ -98,7 +111,97 @@ class DriftBarTabService implements BarTabService {
final rows = await query.get(); final rows = await query.get();
return rows.map(_mapItemRow).toList(); final items = <TabItem>[];
for (final row in rows) {
if (row.quantity <= 0) continue;
final purchases = await _getPurchaseRecords(row);
items.add(_mapItemRow(row, purchases));
}
return items;
}
Future<List<TabItem>> _getItemsForHistory(String tabId) async {
final query = database.select(database.tabItems)
..where((item) => item.tabId.equals(tabId))
..orderBy([(item) => OrderingTerm.asc(item.createdAt)]);
final rows = await query.get();
final items = <TabItem>[];
for (final row in rows) {
final purchases = await _getPurchaseRecords(row);
if (purchases.isEmpty) continue;
items.add(_mapItemRow(row, purchases));
}
return items;
}
Future<List<TabItemPurchaseRow>> _getPurchaseRows(String tabItemId) async {
final query = database.select(database.tabItemPurchases)
..where((purchase) => purchase.tabItemId.equals(tabItemId))
..orderBy([
(purchase) => OrderingTerm.asc(purchase.purchasedAt),
(purchase) => OrderingTerm.asc(purchase.id),
]);
return query.get();
}
Future<List<TabItemPurchase>> _getPurchaseRecords(TabItemRow item) async {
final rows = await _getPurchaseRows(item.id);
final purchases = rows.map(_mapPurchaseRow).toList();
final activeCount = purchases
.where((purchase) => !purchase.isRemoved)
.length;
final missingActivePurchases = item.quantity - activeCount;
if (missingActivePurchases <= 0) return purchases;
return [
...purchases,
for (var index = 0; index < missingActivePurchases; index++)
TabItemPurchase(
id: 'legacy-${item.id}-$index',
tabItemId: item.id,
purchasedAt: item.createdAt,
),
];
}
Future<List<TabItemPurchaseRow>> _ensurePurchaseRows(TabItemRow item) async {
final rows = await _getPurchaseRows(item.id);
final activeCount = rows
.where((purchase) => purchase.removedAt == null)
.length;
final missingActivePurchases = item.quantity - activeCount;
for (var index = 0; index < missingActivePurchases; index++) {
await _recordPurchase(tabItemId: item.id, purchasedAt: item.createdAt);
}
if (missingActivePurchases <= 0) return rows;
return _getPurchaseRows(item.id);
}
Future<void> _recordPurchase({
required String tabItemId,
DateTime? purchasedAt,
}) async {
await database
.into(database.tabItemPurchases)
.insert(
TabItemPurchasesCompanion.insert(
id: _uuid.v4(),
tabItemId: tabItemId,
purchasedAt: purchasedAt ?? DateTime.now(),
),
);
} }
Future<void> _decreaseProductStock(String productId, int amount) async { Future<void> _decreaseProductStock(String productId, int amount) async {
@@ -150,7 +253,7 @@ class DriftBarTabService implements BarTabService {
} }
@override @override
Future<List<BarTab>> getOpenTabs() async { Future<List<BarTab>> getOpenTabs({bool includeRemoved = false}) async {
final query = database.select(database.barTabs) final query = database.select(database.barTabs)
..where((tab) => tab.status.equals('open')) ..where((tab) => tab.status.equals('open'))
..orderBy([(tab) => OrderingTerm.desc(tab.openedAt)]); ..orderBy([(tab) => OrderingTerm.desc(tab.openedAt)]);
@@ -160,7 +263,9 @@ class DriftBarTabService implements BarTabService {
final tabs = <BarTab>[]; final tabs = <BarTab>[];
for (final tabRow in tabRows) { for (final tabRow in tabRows) {
final items = await _getItemsForTab(tabRow.id); final items = includeRemoved
? await _getItemsForHistory(tabRow.id)
: await _getItemsForTab(tabRow.id);
tabs.add(_mapTabRow(tabRow, items)); tabs.add(_mapTabRow(tabRow, items));
} }
@@ -286,15 +391,18 @@ class DriftBarTabService implements BarTabService {
await updateQuery.write( await updateQuery.write(
TabItemsCompanion(quantity: Value(existingItem.quantity + 1)), TabItemsCompanion(quantity: Value(existingItem.quantity + 1)),
); );
await _recordPurchase(tabItemId: existingItem.id);
return; return;
} }
final tabItemId = _uuid.v4();
await database await database
.into(database.tabItems) .into(database.tabItems)
.insert( .insert(
TabItemsCompanion.insert( TabItemsCompanion.insert(
id: _uuid.v4(), id: tabItemId,
tabId: tabId, tabId: tabId,
productId: product.id, productId: product.id,
productName: product.name, productName: product.name,
@@ -303,6 +411,8 @@ class DriftBarTabService implements BarTabService {
createdAt: DateTime.now(), createdAt: DateTime.now(),
), ),
); );
await _recordPurchase(tabItemId: tabItemId);
}); });
} }
@@ -316,24 +426,47 @@ class DriftBarTabService implements BarTabService {
database.tabItems, database.tabItems,
)..where((row) => row.id.equals(tabItemId))).getSingleOrNull(); )..where((row) => row.id.equals(tabItemId))).getSingleOrNull();
if (item == null || delta == 0) return 0; if (item == null || delta == 0 || (item.quantity == 0 && delta < 0)) {
return 0;
}
final requestedQuantity = item.quantity + delta; final requestedQuantity = item.quantity + delta;
final actualDelta = requestedQuantity <= 0 ? -item.quantity : delta; final actualDelta = requestedQuantity <= 0 ? -item.quantity : delta;
if (actualDelta > 0) { if (actualDelta > 0) {
await _decreaseProductStock(item.productId, actualDelta); await _decreaseProductStock(item.productId, actualDelta);
for (var index = 0; index < actualDelta; index++) {
await _recordPurchase(tabItemId: item.id);
}
} else { } else {
await _increaseProductStock(item.productId, -actualDelta); await _increaseProductStock(item.productId, -actualDelta);
final purchaseRows = await _ensurePurchaseRows(item);
final activePurchaseRows =
purchaseRows
.where((purchase) => purchase.removedAt == null)
.toList()
..sort((a, b) => b.purchasedAt.compareTo(a.purchasedAt));
final removedAt = DateTime.now();
for (final purchase in activePurchaseRows.take(-actualDelta)) {
await (database.update(database.tabItemPurchases)
..where((row) => row.id.equals(purchase.id)))
.write(TabItemPurchasesCompanion(removedAt: Value(removedAt)));
}
} }
if (requestedQuantity <= 0) { if (requestedQuantity <= 0) {
final deletedRows = await (database.delete( final updatedRows =
database.tabItems, await (database.update(database.tabItems)
)..where((row) => row.id.equals(tabItemId))).go(); ..where((row) => row.id.equals(tabItemId)))
.write(const TabItemsCompanion(quantity: Value(0)));
if (deletedRows != 1) { if (updatedRows != 1) {
throw StateError('Tab item was changed before it could be deleted'); throw StateError(
'Tab item was changed before its quantity was updated',
);
} }
} else { } else {
final updatedRows = final updatedRows =
@@ -367,7 +500,7 @@ class DriftBarTabService implements BarTabService {
return; return;
} }
final items = await _getItemsForTab(tabId); final items = await _getItemsForHistory(tabId);
if (items.isEmpty) { if (items.isEmpty) {
return; return;
@@ -388,18 +521,22 @@ class DriftBarTabService implements BarTabService {
); );
for (final item in items) { for (final item in items) {
await database for (final purchase in item.purchases) {
.into(database.closedTabItems) await database
.insert( .into(database.closedTabItems)
ClosedTabItemsCompanion.insert( .insert(
id: _uuid.v4(), ClosedTabItemsCompanion.insert(
closedTabId: closedTabId, id: _uuid.v4(),
productId: item.productId, closedTabId: closedTabId,
productName: item.productName, productId: item.productId,
quantity: item.quantity, productName: item.productName,
unitPriceInCents: item.unitPriceInCents, quantity: 1,
), unitPriceInCents: item.unitPriceInCents,
); purchasedAt: Value(purchase.purchasedAt),
removedAt: Value(purchase.removedAt),
),
);
}
} }
final deleteQuery = database.delete(database.tabItems) final deleteQuery = database.delete(database.tabItems)
@@ -437,7 +574,11 @@ class DriftBarTabService implements BarTabService {
for (final row in closedTabRows) { for (final row in closedTabRows) {
final itemsQuery = database.select(database.closedTabItems) final itemsQuery = database.select(database.closedTabItems)
..where((item) => item.closedTabId.equals(row.id)); ..where((item) => item.closedTabId.equals(row.id))
..orderBy([
(item) => OrderingTerm.desc(item.purchasedAt),
(item) => OrderingTerm.desc(item.id),
]);
final itemRows = await itemsQuery.get(); final itemRows = await itemsQuery.get();
+62 -29
View File
@@ -49,19 +49,27 @@ class DefaultExportService implements ExportService {
final itemRows = await itemsQuery.get(); final itemRows = await itemsQuery.get();
result.add(_ClosedTabExport( result.add(
id: tabRow.id, _ClosedTabExport(
originalTabId: tabRow.originalTabId, id: tabRow.id,
customerName: tabRow.customerName, originalTabId: tabRow.originalTabId,
closedAt: tabRow.closedAt.toIso8601String(), customerName: tabRow.customerName,
paymentMethod: tabRow.paymentMethod, closedAt: tabRow.closedAt.toIso8601String(),
items: itemRows.map((row) => _ClosedTabItemExport( paymentMethod: tabRow.paymentMethod,
productId: row.productId, items: itemRows
productName: row.productName, .map(
quantity: row.quantity, (row) => _ClosedTabItemExport(
unitPriceInCents: row.unitPriceInCents, productId: row.productId,
)).toList(), productName: row.productName,
)); quantity: row.quantity,
unitPriceInCents: row.unitPriceInCents,
purchasedAt: row.purchasedAt?.toIso8601String(),
removedAt: row.removedAt?.toIso8601String(),
),
)
.toList(),
),
);
} }
return result; return result;
@@ -71,22 +79,39 @@ class DefaultExportService implements ExportService {
return { return {
'exportedAt': DateTime.now().toIso8601String(), 'exportedAt': DateTime.now().toIso8601String(),
'appVersion': '1.0.7', 'appVersion': '1.0.7',
'closedTabs': tabs.map((tab) => { 'closedTabs': tabs
'id': tab.id, .map(
'originalTabId': tab.originalTabId, (tab) => {
'customerName': tab.customerName, 'id': tab.id,
'closedAt': tab.closedAt, 'originalTabId': tab.originalTabId,
'paymentMethod': tab.paymentMethod, 'customerName': tab.customerName,
'items': tab.items.map((item) => { 'closedAt': tab.closedAt,
'productId': item.productId, 'paymentMethod': tab.paymentMethod,
'productName': item.productName, 'items': tab.items
'quantity': item.quantity, .map(
'unitPriceInCents': item.unitPriceInCents, (item) => {
'lineTotalInCents': item.quantity * item.unitPriceInCents, 'productId': item.productId,
}).toList(), 'productName': item.productName,
'itemCount': tab.items.fold(0, (sum, item) => sum + item.quantity), 'quantity': item.quantity,
'totalInCents': tab.items.fold(0, (sum, item) => sum + item.quantity * item.unitPriceInCents), 'unitPriceInCents': item.unitPriceInCents,
}).toList(), 'purchasedAt': item.purchasedAt,
'removedAt': item.removedAt,
'isRemoved': item.isRemoved,
'lineTotalInCents': item.lineTotalInCents,
},
)
.toList(),
'itemCount': tab.items.fold(
0,
(sum, item) => sum + (item.isRemoved ? 0 : item.quantity),
),
'totalInCents': tab.items.fold(
0,
(sum, item) => sum + item.lineTotalInCents,
),
},
)
.toList(),
}; };
} }
} }
@@ -96,13 +121,21 @@ class _ClosedTabItemExport {
final String productName; final String productName;
final int quantity; final int quantity;
final int unitPriceInCents; final int unitPriceInCents;
final String? purchasedAt;
final String? removedAt;
_ClosedTabItemExport({ _ClosedTabItemExport({
required this.productId, required this.productId,
required this.productName, required this.productName,
required this.quantity, required this.quantity,
required this.unitPriceInCents, required this.unitPriceInCents,
required this.purchasedAt,
required this.removedAt,
}); });
bool get isRemoved => removedAt != null;
int get lineTotalInCents => isRemoved ? 0 : quantity * unitPriceInCents;
} }
class _ClosedTabExport { class _ClosedTabExport {
+48
View File
@@ -0,0 +1,48 @@
import '../models/closed_tab_item.dart';
class ClosedTabItemDayGroup {
final DateTime day;
final List<ClosedTabItem> items;
const ClosedTabItemDayGroup({required this.day, required this.items});
int get itemCount => items.fold(
0,
(total, item) => total + (item.isRemoved ? 0 : item.quantity),
);
}
List<ClosedTabItemDayGroup> groupClosedTabItemsByDay(
Iterable<ClosedTabItem> items, {
required DateTime fallbackDate,
}) {
final groups = <DateTime, List<ClosedTabItem>>{};
for (final item in items) {
final localPurchasedAt = (item.purchasedAt ?? fallbackDate).toLocal();
final day = DateTime(
localPurchasedAt.year,
localPurchasedAt.month,
localPurchasedAt.day,
);
groups.putIfAbsent(day, () => []).add(item);
}
for (final group in groups.values) {
group.sort((a, b) {
final aTime = (a.purchasedAt ?? fallbackDate).toLocal();
final bTime = (b.purchasedAt ?? fallbackDate).toLocal();
final byTime = bTime.compareTo(aTime);
if (byTime != 0) return byTime;
return b.id.compareTo(a.id);
});
}
final sortedGroups = groups.entries.toList()
..sort((a, b) => b.key.compareTo(a.key));
return sortedGroups
.map((entry) => ClosedTabItemDayGroup(day: entry.key, items: entry.value))
.toList();
}
+132 -59
View File
@@ -1,3 +1,4 @@
import 'dart:math';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -194,16 +195,18 @@ class DevMenuViewModel extends ChangeNotifier {
final (name, priceInCents, stock) = demoProducts[i]; final (name, priceInCents, stock) = demoProducts[i];
final category = categories[i ~/ 5]; final category = categories[i ~/ 5];
await database.into(database.products).insert( await database
ProductsCompanion.insert( .into(database.products)
id: uuid.v4(), .insert(
name: name, ProductsCompanion.insert(
category: category, id: uuid.v4(),
stockQuantity: stock, name: name,
lowStockThreshold: 10, category: category,
priceInCents: priceInCents, stockQuantity: stock,
), lowStockThreshold: 10,
); priceInCents: priceInCents,
),
);
} }
_lastAction = 'Seeded ${demoProducts.length} demo products'; _lastAction = 'Seeded ${demoProducts.length} demo products';
@@ -241,10 +244,7 @@ class DevMenuViewModel extends ChangeNotifier {
final randomProducts = products.take(3).toList(); final randomProducts = products.take(3).toList();
for (final product in randomProducts) { for (final product in randomProducts) {
await barTabService.addProductToTab( await barTabService.addProductToTab(tabId: tab.id, product: product);
tabId: tab.id,
product: product,
);
} }
_lastAction = 'Created test tab with ${randomProducts.length} items'; _lastAction = 'Created test tab with ${randomProducts.length} items';
@@ -262,16 +262,18 @@ class DevMenuViewModel extends ChangeNotifier {
final uuid = const Uuid(); final uuid = const Uuid();
for (var i = 0; i < count; i++) { for (var i = 0; i < count; i++) {
await database.into(database.products).insert( await database
ProductsCompanion.insert( .into(database.products)
id: uuid.v4(), .insert(
name: 'Test Product $i', ProductsCompanion.insert(
category: 'Test Category ${i % 5}', id: uuid.v4(),
stockQuantity: 50 + (i % 50), name: 'Test Product $i',
lowStockThreshold: 5, category: 'Test Category ${i % 5}',
priceInCents: 100 + (i * 10), stockQuantity: 50 + (i % 50),
), lowStockThreshold: 5,
); priceInCents: 100 + (i * 10),
),
);
} }
_lastAction = 'Added $count test products'; _lastAction = 'Added $count test products';
@@ -341,59 +343,130 @@ class DevMenuViewModel extends ChangeNotifier {
} }
final uuid = const Uuid(); final uuid = const Uuid();
final random = Random();
final today = DateTime.now();
final startOfToday = DateTime(today.year, today.month, today.day);
final customerNames = [ final customerNames = [
'Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Alice',
'Grace', 'Hank', 'Ivy', 'Jack', 'Kate', 'Leo', 'Bob',
'Mia', 'Noah', 'Olivia', 'Pete', 'Quinn', 'Rose', 'Charlie',
'Sam', 'Tina', 'Umar', 'Vera', 'Wes', 'Xena', 'Diana',
'Yves', 'Zara', 'Eve',
'Frank',
'Grace',
'Hank',
'Ivy',
'Jack',
'Kate',
'Leo',
'Mia',
'Noah',
'Olivia',
'Pete',
'Quinn',
'Rose',
'Sam',
'Tina',
'Umar',
'Vera',
'Wes',
'Xena',
'Yves',
'Zara',
]; ];
const paymentMethods = [PaymentMethod.cash, PaymentMethod.payconiq]; const paymentMethods = [PaymentMethod.cash, PaymentMethod.payconiq];
final usedCustomers = <String>{};
await database.transaction(() async { await database.transaction(() async {
for (var i = 0; i < count; i++) { for (var i = 0; i < count; i++) {
final customer = customerNames[i % customerNames.length]; final customer = customerNames[random.nextInt(customerNames.length)];
usedCustomers.add(customer);
final closedTabId = uuid.v4(); final closedTabId = uuid.v4();
final itemCount = 1 + (i % 5); final distinctProductCount = min(
products.length,
1 + random.nextInt(5),
);
final shuffledProducts = [...products]..shuffle(random);
final closedAt = DateTime.now().subtract( final orderDay = startOfToday.subtract(
Duration(days: random.nextInt(60)),
);
final closedAt = DateTime(
orderDay.year,
orderDay.month,
orderDay.day,
12 + random.nextInt(10),
random.nextInt(60),
);
// A few mock tabs span multiple days so the history day dropdowns
// also get realistic multi-day data.
final spanDays = random.nextInt(5) == 0 ? 1 + random.nextInt(2) : 0;
final firstPurchaseAt = closedAt.subtract(
Duration( Duration(
days: i % 90, days: spanDays,
hours: i % 24, hours: random.nextInt(4),
minutes: i % 60, minutes: random.nextInt(60),
), ),
); );
final purchaseWindowMinutes = closedAt
.difference(firstPurchaseAt)
.inMinutes;
await database.into(database.closedTabs).insert( await database
ClosedTabsCompanion.insert( .into(database.closedTabs)
id: closedTabId, .insert(
originalTabId: uuid.v4(), ClosedTabsCompanion.insert(
customerName: customer, id: closedTabId,
closedAt: closedAt, originalTabId: uuid.v4(),
paymentMethod: Value(paymentMethods[(i * 3) % paymentMethods.length].value), customerName: customer,
), closedAt: closedAt,
); paymentMethod: Value(
paymentMethods[random.nextInt(paymentMethods.length)].value,
),
),
);
for (var j = 0; j < itemCount; j++) { for (var j = 0; j < distinctProductCount; j++) {
final product = products[(i + j) % products.length]; final product = shuffledProducts[j];
final quantity = 1 + ((i * 7 + j * 13) % 6); final quantity = 1 + random.nextInt(4);
await database.into(database.closedTabItems).insert( for (var k = 0; k < quantity; k++) {
ClosedTabItemsCompanion.insert( var purchasedAt = firstPurchaseAt.add(
id: uuid.v4(), Duration(minutes: random.nextInt(purchaseWindowMinutes + 1)),
closedTabId: closedTabId, );
productId: product.id,
productName: product.name, if (spanDays > 0 && j == 0 && k == 0) {
quantity: quantity, purchasedAt = firstPurchaseAt;
unitPriceInCents: product.priceInCents, } else if (spanDays > 0 &&
), j == distinctProductCount - 1 &&
); k == quantity - 1) {
purchasedAt = closedAt.subtract(
Duration(minutes: random.nextInt(30)),
);
}
await database
.into(database.closedTabItems)
.insert(
ClosedTabItemsCompanion.insert(
id: uuid.v4(),
closedTabId: closedTabId,
productId: product.id,
productName: product.name,
quantity: 1,
unitPriceInCents: product.priceInCents,
purchasedAt: Value(purchasedAt),
),
);
}
} }
} }
}); });
_lastAction = 'Generated $count mock orders across ' _lastAction =
'${customerNames.length} customers'; 'Generated $count mock orders across '
'${usedCustomers.length} customers';
} finally { } finally {
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
+43 -13
View File
@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../models/bar_tab.dart';
import '../models/closed_tab.dart'; import '../models/closed_tab.dart';
import '../services/bar_tab_service.dart'; import '../services/bar_tab_service.dart';
@@ -11,6 +12,7 @@ class HistoryViewModel extends ChangeNotifier {
static const int _pageSize = 20; static const int _pageSize = 20;
List<ClosedTab> _closedTabs = []; List<ClosedTab> _closedTabs = [];
List<BarTab> _openTabs = [];
List<String> _customerNames = []; List<String> _customerNames = [];
String? _selectedCustomer; String? _selectedCustomer;
int _offset = 0; int _offset = 0;
@@ -22,14 +24,14 @@ class HistoryViewModel extends ChangeNotifier {
String _searchQuery = ''; String _searchQuery = '';
List<ClosedTab> get closedTabs { List<ClosedTab> get closedTabs {
if (_searchQuery.isEmpty) return _closedTabs;
return _closedTabs return _closedTabs
.where( .where((tab) => _matchesCustomer(tab.customerName))
(tab) => tab.customerName.toLowerCase().contains( .toList();
_searchQuery.toLowerCase(), }
),
) List<BarTab> get openTabs {
return _openTabs
.where((tab) => _matchesCustomer(tab.customerName))
.toList(); .toList();
} }
@@ -63,15 +65,19 @@ class HistoryViewModel extends ChangeNotifier {
try { try {
final results = await Future.wait([ final results = await Future.wait([
_fetchPage(offset: 0), _fetchPage(offset: 0),
barTabService.getClosedTabCount( barTabService.getClosedTabCount(customerName: _selectedCustomer),
customerName: _selectedCustomer,
),
barTabService.getDistinctCustomerNames(), barTabService.getDistinctCustomerNames(),
barTabService.getOpenTabs(includeRemoved: true),
]); ]);
_closedTabs = results[0] as List<ClosedTab>; _closedTabs = (results[0] as List<ClosedTab>).toList()
..sort(_compareClosedTabs);
_totalCount = results[1] as int; _totalCount = results[1] as int;
_customerNames = results[2] as List<String>; _openTabs = (results[3] as List<BarTab>).toList()..sort(_compareOpenTabs);
_customerNames = {
...(results[2] as List<String>),
..._openTabs.map((tab) => tab.customerName),
}.toList()..sort();
_offset = _closedTabs.length; _offset = _closedTabs.length;
} catch (e) { } catch (e) {
debugPrint('HistoryViewModel: load error: $e'); debugPrint('HistoryViewModel: load error: $e');
@@ -91,7 +97,7 @@ class HistoryViewModel extends ChangeNotifier {
try { try {
final more = await _fetchPage(offset: _offset); final more = await _fetchPage(offset: _offset);
_closedTabs = [..._closedTabs, ...more]; _closedTabs = [..._closedTabs, ...more]..sort(_compareClosedTabs);
_offset = _closedTabs.length; _offset = _closedTabs.length;
} catch (e) { } catch (e) {
debugPrint('HistoryViewModel: loadMore error: $e'); debugPrint('HistoryViewModel: loadMore error: $e');
@@ -110,11 +116,35 @@ class HistoryViewModel extends ChangeNotifier {
); );
} }
int _compareClosedTabs(ClosedTab a, ClosedTab b) {
final byClosedAt = b.closedAt.compareTo(a.closedAt);
if (byClosedAt != 0) return byClosedAt;
return b.id.compareTo(a.id);
}
int _compareOpenTabs(BarTab a, BarTab b) {
final byOpenedAt = b.openedAt.compareTo(a.openedAt);
if (byOpenedAt != 0) return byOpenedAt;
return b.id.compareTo(a.id);
}
void search(String query) { void search(String query) {
_searchQuery = query; _searchQuery = query;
notifyListeners(); notifyListeners();
} }
bool _matchesCustomer(String customerName) {
if (_selectedCustomer != null && customerName != _selectedCustomer) {
return false;
}
if (_searchQuery.isEmpty) return true;
return customerName.toLowerCase().contains(_searchQuery.toLowerCase());
}
Future<void> filterByCustomer(String? customerName) async { Future<void> filterByCustomer(String? customerName) async {
_selectedCustomer = customerName; _selectedCustomer = customerName;
_searchQuery = ''; _searchQuery = '';
+193 -90
View File
@@ -1,8 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:kooltab2/views/widgets/closed_tab_card.dart'; import 'package:kooltab2/views/widgets/closed_tab_card.dart';
import 'package:kooltab2/views/widgets/open_tab_history_card.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../app/router.dart'; import '../app/router.dart';
import '../models/bar_tab.dart';
import '../models/closed_tab.dart';
import '../utils/navigation.dart'; import '../utils/navigation.dart';
import '../viewmodels/history_view_model.dart'; import '../viewmodels/history_view_model.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -134,33 +137,15 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
), ),
), ),
Expanded( Expanded(
child: viewModel.closedTabs.isEmpty child:
viewModel.openTabs.isEmpty && viewModel.closedTabs.isEmpty
? _EmptyState() ? _EmptyState()
: ListView.separated( : _HistoryList(
controller: _scrollController, openTabs: viewModel.openTabs,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), closedTabs: viewModel.closedTabs,
itemCount: hasMore: viewModel.hasMore,
viewModel.closedTabs.length + isLoadingMore: viewModel.isLoadingMore,
(viewModel.hasMore || viewModel.isLoadingMore scrollController: _scrollController,
? 1
: 0),
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
if (index == viewModel.closedTabs.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2.5,
),
),
);
}
final closedTab = viewModel.closedTabs[index];
return ClosedTabCard(closedTab: closedTab);
},
), ),
), ),
], ],
@@ -171,6 +156,85 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
} }
} }
class _HistoryList extends StatelessWidget {
final List<BarTab> openTabs;
final List<ClosedTab> closedTabs;
final bool hasMore;
final bool isLoadingMore;
final ScrollController scrollController;
const _HistoryList({
required this.openTabs,
required this.closedTabs,
required this.hasMore,
required this.isLoadingMore,
required this.scrollController,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final openSectionCount = openTabs.isEmpty ? 0 : openTabs.length + 1;
final closedSectionCount = closedTabs.isEmpty ? 0 : closedTabs.length + 1;
final loadingCount = hasMore || isLoadingMore ? 1 : 0;
return ListView.separated(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
itemCount: openSectionCount + closedSectionCount + loadingCount,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
if (openTabs.isNotEmpty) {
if (index == 0) {
return _HistorySectionHeader(title: l10n.openTabs);
}
if (index <= openTabs.length) {
return OpenTabHistoryCard(tab: openTabs[index - 1]);
}
}
final closedIndex = index - openSectionCount;
if (closedTabs.isNotEmpty) {
if (closedIndex == 0) {
return _HistorySectionHeader(title: l10n.closedTabs);
}
if (closedIndex <= closedTabs.length) {
return ClosedTabCard(closedTab: closedTabs[closedIndex - 1]);
}
}
return const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator(strokeWidth: 2.5)),
);
},
);
}
}
class _HistorySectionHeader extends StatelessWidget {
final String title;
const _HistorySectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 2),
child: Text(
title.toUpperCase(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.7,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
);
}
}
class _CustomerDropdown extends StatelessWidget { class _CustomerDropdown extends StatelessWidget {
static const _allSentinel = r'$__all__$'; static const _allSentinel = r'$__all__$';
@@ -194,95 +258,134 @@ class _CustomerDropdown extends StatelessWidget {
onSelected: (value) { onSelected: (value) {
onSelected(value == _allSentinel ? null : value); onSelected(value == _allSentinel ? null : value);
}, },
offset: const Offset(0, 44), tooltip: l10n.customer,
padding: EdgeInsets.zero,
offset: const Offset(0, 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
color: Theme.of(context).cardTheme.color ?? scheme.surface, color: Theme.of(context).cardTheme.color ?? scheme.surface,
itemBuilder: (context) => [ itemBuilder: (context) => [
PopupMenuItem<String>( PopupMenuItem<String>(
value: _allSentinel, value: _allSentinel,
child: Row( height: 50,
children: [ child: _CustomerMenuRow(
Icon( icon: Icons.people_outline_rounded,
isFiltered ? Icons.people_outline : Icons.people_rounded, label: l10n.allCustomers,
size: 18, selected: !isFiltered,
color: isFiltered ? null : scheme.primary,
),
const SizedBox(width: 10),
Text(
l10n.allCustomers,
style: TextStyle(
fontWeight: isFiltered ? FontWeight.w400 : FontWeight.w700,
color: isFiltered ? null : scheme.primary,
),
),
],
), ),
), ),
if (customerNames.isNotEmpty) const PopupMenuDivider(height: 1), if (customerNames.isNotEmpty) const PopupMenuDivider(height: 1),
...customerNames.map( ...customerNames.map(
(name) => PopupMenuItem<String>( (name) => PopupMenuItem<String>(
value: name, value: name,
child: Row( height: 50,
children: [ child: _CustomerMenuRow(
Icon( icon: name == selectedCustomer
name == selectedCustomer ? Icons.person_rounded
? Icons.person_rounded : Icons.person_outline_rounded,
: Icons.person_outline_rounded, label: name,
size: 18, selected: name == selectedCustomer,
),
const SizedBox(width: 10),
Expanded(child: Text(name, overflow: TextOverflow.ellipsis)),
],
), ),
), ),
), ),
], ],
child: Container( child: ConstrainedBox(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), constraints: const BoxConstraints(minWidth: 144, maxWidth: 220),
decoration: BoxDecoration( child: Container(
borderRadius: BorderRadius.circular(14), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: Border.all(color: scheme.onSurface.withValues(alpha: 0.12)), decoration: BoxDecoration(
color: isFiltered ? scheme.primary.withValues(alpha: 0.08) : null, borderRadius: BorderRadius.circular(12),
), border: Border.all(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.person_rounded,
size: 18,
color: isFiltered color: isFiltered
? scheme.primary ? scheme.primary.withValues(alpha: 0.35)
: scheme.onSurface.withValues(alpha: 0.5), : scheme.onSurface.withValues(alpha: 0.12),
), ),
const SizedBox(width: 6), color: isFiltered ? scheme.primary.withValues(alpha: 0.08) : null,
Flexible( ),
child: Text( child: Row(
selectedCustomer ?? l10n.customer, children: [
overflow: TextOverflow.ellipsis, Icon(
style: TextStyle( isFiltered
fontWeight: FontWeight.w600, ? Icons.filter_alt_rounded
fontSize: 13, : Icons.people_outline_rounded,
color: isFiltered size: 18,
? scheme.primary color: isFiltered
: scheme.onSurface.withValues(alpha: 0.5), ? scheme.primary
: scheme.onSurface.withValues(alpha: 0.5),
),
const SizedBox(width: 8),
Expanded(
child: Text(
selectedCustomer ?? l10n.allCustomers,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: isFiltered
? scheme.primary
: scheme.onSurface.withValues(alpha: 0.65),
),
), ),
), ),
), const SizedBox(width: 4),
const SizedBox(width: 4), Icon(
Icon( Icons.keyboard_arrow_down_rounded,
Icons.arrow_drop_down_rounded, size: 18,
size: 18, color: isFiltered
color: isFiltered ? scheme.primary
? scheme.primary : scheme.onSurface.withValues(alpha: 0.5),
: scheme.onSurface.withValues(alpha: 0.5), ),
), ],
], ),
), ),
), ),
); );
} }
} }
class _CustomerMenuRow extends StatelessWidget {
final IconData icon;
final String label;
final bool selected;
const _CustomerMenuRow({
required this.icon,
required this.label,
required this.selected,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Row(
children: [
Icon(
icon,
size: 18,
color: selected
? scheme.primary
: scheme.onSurface.withValues(alpha: 0.65),
),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
color: selected ? scheme.primary : scheme.onSurface,
),
),
),
if (selected) ...[
const SizedBox(width: 10),
Icon(Icons.check_rounded, size: 18, color: scheme.primary),
],
],
);
}
}
class _EmptyState extends StatelessWidget { class _EmptyState extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
+215 -47
View File
@@ -5,6 +5,7 @@ import 'package:kooltab2/models/closed_tab_item.dart';
import 'package:kooltab2/models/payment_method.dart'; import 'package:kooltab2/models/payment_method.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/history_grouping.dart';
class ClosedTabCard extends StatefulWidget { class ClosedTabCard extends StatefulWidget {
final ClosedTab closedTab; final ClosedTab closedTab;
@@ -31,6 +32,10 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag(); final locale = Localizations.localeOf(context).toLanguageTag();
final dateFormat = DateFormat.yMMMd(locale).add_jm(); final dateFormat = DateFormat.yMMMd(locale).add_jm();
final dayGroups = groupClosedTabItemsByDay(
closedTab.items,
fallbackDate: closedTab.closedAt,
);
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return Container( return Container(
@@ -40,17 +45,16 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
border: Border.all(color: scheme.onSurface.withValues(alpha: 0.06)), border: Border.all(color: scheme.onSurface.withValues(alpha: 0.06)),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Material( child: Column(
color: Colors.transparent, crossAxisAlignment: CrossAxisAlignment.start,
child: InkWell( children: [
onTap: () => setState(() => _expanded = !_expanded), Material(
enableFeedback: true, color: Colors.transparent,
splashFactory: NoSplash.splashFactory, child: InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Column( enableFeedback: true,
crossAxisAlignment: CrossAxisAlignment.start, splashFactory: NoSplash.splashFactory,
children: [ child: Padding(
Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
vertical: 14, vertical: 14,
@@ -137,27 +141,163 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
], ],
), ),
), ),
AnimatedCrossFade( ),
duration: const Duration(milliseconds: 150), ),
crossFadeState: _expanded AnimatedCrossFade(
? CrossFadeState.showFirst duration: const Duration(milliseconds: 150),
: CrossFadeState.showSecond, crossFadeState: _expanded
firstChild: Padding( ? CrossFadeState.showFirst
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), : CrossFadeState.showSecond,
child: Column( firstChild: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: Column(
children: [
const Divider(height: 1),
const SizedBox(height: 8),
for (var index = 0; index < dayGroups.length; index++)
_ClosedTabDayDropdown(
key: ValueKey(
'${dayGroups[index].items.first.closedTabId}-'
'${dayGroups[index].day.toIso8601String()}',
),
group: dayGroups[index],
fallbackDate: closedTab.closedAt,
initiallyExpanded: index == 0,
),
],
),
),
secondChild: const SizedBox(width: double.infinity),
),
],
),
);
}
}
class _ClosedTabDayDropdown extends StatefulWidget {
final ClosedTabItemDayGroup group;
final DateTime fallbackDate;
final bool initiallyExpanded;
const _ClosedTabDayDropdown({
super.key,
required this.group,
required this.fallbackDate,
required this.initiallyExpanded,
});
@override
State<_ClosedTabDayDropdown> createState() => _ClosedTabDayDropdownState();
}
class _ClosedTabDayDropdownState extends State<_ClosedTabDayDropdown> {
late bool _expanded = widget.initiallyExpanded;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final dayFormat = DateFormat.yMMMMd(locale);
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(top: 8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
decoration: BoxDecoration(
color: _expanded
? scheme.primary.withValues(alpha: 0.08)
: scheme.onSurface.withValues(alpha: 0.035),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _expanded
? scheme.primary.withValues(alpha: 0.28)
: scheme.onSurface.withValues(alpha: 0.08),
),
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
Material(
color: Colors.transparent,
child: InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Row(
children: [ children: [
const Divider(height: 1), Container(
const SizedBox(height: 8), width: 30,
...closedTab.items.map( height: 30,
(item) => _ClosedTabItemRow(item: item), decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(9),
),
child: Icon(
Icons.calendar_today_rounded,
size: 16,
color: scheme.primary,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dayFormat.format(widget.group.day),
style: const TextStyle(
fontWeight: FontWeight.w700,
),
),
Text(
l10n.tabItemCount(widget.group.itemCount),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Icon(
_expanded
? Icons.keyboard_arrow_up_rounded
: Icons.keyboard_arrow_down_rounded,
color: _expanded
? scheme.primary
: scheme.onSurface.withValues(alpha: 0.5),
), ),
], ],
), ),
), ),
secondChild: const SizedBox(width: double.infinity),
), ),
], ),
), AnimatedSize(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
child: _expanded
? Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Column(
children: [
Divider(
height: 1,
color: scheme.primary.withValues(alpha: 0.18),
),
for (final item in widget.group.items)
_ClosedTabItemRow(
item: item,
purchasedAt:
item.purchasedAt ?? widget.fallbackDate,
),
],
),
)
: const SizedBox.shrink(),
),
],
), ),
), ),
); );
@@ -166,12 +306,15 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
class _ClosedTabItemRow extends StatelessWidget { class _ClosedTabItemRow extends StatelessWidget {
final ClosedTabItem item; final ClosedTabItem item;
final DateTime purchasedAt;
const _ClosedTabItemRow({required this.item}); const _ClosedTabItemRow({required this.item, required this.purchasedAt});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag(); final locale = Localizations.localeOf(context).toLanguageTag();
final timeFormat = DateFormat.jm(locale);
final unitPrice = NumberFormat.simpleCurrency( final unitPrice = NumberFormat.simpleCurrency(
locale: locale, locale: locale,
).format(item.unitPriceInCents / 100); ).format(item.unitPriceInCents / 100);
@@ -185,32 +328,57 @@ class _ClosedTabItemRow extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Text( child: Column(
item.productName, crossAxisAlignment: CrossAxisAlignment.start,
maxLines: 1, children: [
overflow: TextOverflow.ellipsis, Text(
style: TextStyle( item.productName,
fontWeight: FontWeight.w600, maxLines: 1,
color: scheme.onSurface, overflow: TextOverflow.ellipsis,
), style: TextStyle(
fontWeight: FontWeight.w600,
color: item.isRemoved
? scheme.onSurface.withValues(alpha: 0.5)
: scheme.onSurface,
decoration: item.isRemoved
? TextDecoration.lineThrough
: null,
),
),
Text(
timeFormat.format(purchasedAt),
style: Theme.of(context).textTheme.bodySmall,
),
],
), ),
), ),
Text( if (item.isRemoved)
'${item.quantity} × $unitPrice', Text(
style: Theme.of(context).textTheme.bodySmall, l10n.removed,
),
const SizedBox(width: 12),
SizedBox(
width: 64,
child: Text(
lineTotal,
textAlign: TextAlign.end,
style: TextStyle( style: TextStyle(
color: scheme.error,
fontSize: 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: scheme.onSurface, ),
)
else ...[
Text(
'${item.quantity} × $unitPrice',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(width: 12),
SizedBox(
width: 64,
child: Text(
lineTotal,
textAlign: TextAlign.end,
style: TextStyle(
fontWeight: FontWeight.w700,
color: scheme.onSurface,
),
), ),
), ),
), ],
], ],
), ),
); );
@@ -0,0 +1,256 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../l10n/app_localizations.dart';
import '../../models/bar_tab.dart';
import '../../models/tab_item_purchase.dart';
class OpenTabHistoryCard extends StatefulWidget {
final BarTab tab;
const OpenTabHistoryCard({super.key, required this.tab});
@override
State<OpenTabHistoryCard> createState() => _OpenTabHistoryCardState();
}
class _OpenTabHistoryCardState extends State<OpenTabHistoryCard> {
bool _expanded = false;
List<_OpenPurchase> _purchases(BarTab tab) {
final purchases = <_OpenPurchase>[];
for (final item in tab.items) {
final records = item.purchases.isEmpty
? List<TabItemPurchase>.generate(
item.quantity,
(index) => TabItemPurchase(
id: '${item.id}-$index',
tabItemId: item.id,
purchasedAt: tab.openedAt,
),
)
: item.purchases;
purchases.addAll(
records.map(
(purchase) => _OpenPurchase(
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
purchasedAt: purchase.purchasedAt,
isRemoved: purchase.isRemoved,
),
),
);
}
purchases.sort((a, b) {
final byTime = b.purchasedAt.compareTo(a.purchasedAt);
if (byTime != 0) return byTime;
return b.productName.compareTo(a.productName);
});
return purchases;
}
@override
Widget build(BuildContext context) {
final tab = widget.tab;
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final dateFormat = DateFormat.yMMMd(locale).add_jm();
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: scheme.primary.withValues(alpha: 0.2)),
),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => setState(() => _expanded = !_expanded),
enableFeedback: true,
splashFactory: NoSplash.splashFactory,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
tab.customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: scheme.onSurface,
),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
l10n.openTabs,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: scheme.primary,
),
),
),
],
),
const SizedBox(height: 2),
Text(
'${dateFormat.format(tab.openedAt)} · ${l10n.tabItemCount(tab.itemCount)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const SizedBox(width: 8),
Text(
tab.formattedTotal,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: scheme.onSurface,
),
),
const SizedBox(width: 4),
Icon(
_expanded
? Icons.expand_less_rounded
: Icons.expand_more_rounded,
color: scheme.onSurface.withValues(alpha: 0.5),
),
],
),
),
AnimatedCrossFade(
duration: const Duration(milliseconds: 150),
crossFadeState: _expanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: Column(
children: [
const Divider(height: 1),
const SizedBox(height: 8),
..._purchases(
tab,
).map((purchase) => _OpenPurchaseRow(purchase: purchase)),
],
),
),
secondChild: const SizedBox(width: double.infinity),
),
],
),
),
),
);
}
}
class _OpenPurchase {
final String productName;
final int unitPriceInCents;
final DateTime purchasedAt;
final bool isRemoved;
const _OpenPurchase({
required this.productName,
required this.unitPriceInCents,
required this.purchasedAt,
required this.isRemoved,
});
}
class _OpenPurchaseRow extends StatelessWidget {
final _OpenPurchase purchase;
const _OpenPurchaseRow({required this.purchase});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final dateFormat = DateFormat.yMMMd(locale).add_jm();
final unitPrice = NumberFormat.simpleCurrency(
locale: locale,
).format(purchase.unitPriceInCents / 100);
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
purchase.productName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
color: purchase.isRemoved
? scheme.onSurface.withValues(alpha: 0.5)
: scheme.onSurface,
decoration: purchase.isRemoved
? TextDecoration.lineThrough
: null,
),
),
Text(
dateFormat.format(purchase.purchasedAt),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
if (purchase.isRemoved)
Text(
l10n.removed,
style: TextStyle(
color: scheme.error,
fontSize: 12,
fontWeight: FontWeight.w700,
),
)
else
Text(
'1 × $unitPrice',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}
+80 -3
View File
@@ -196,6 +196,29 @@ void main() {
expect(updatedTab.items.first.quantity, 3); expect(updatedTab.items.first.quantity, 3);
}); });
test('records each addition as a separate purchase event', () async {
final tab = await service.createTab(customerName: 'John');
final product = createTestProduct();
await service.addProductToTab(tabId: tab.id, product: product);
await service.addProductToTab(tabId: tab.id, product: product);
final barTab = (await service.getOpenTabs()).first;
final historyTab = (await service.getOpenTabs(
includeRemoved: true,
)).first;
expect(barTab.items.first.quantity, 2);
expect(barTab.items.first.purchases, hasLength(2));
expect(historyTab.items.first.purchases, hasLength(2));
expect(
historyTab.items.first.purchases.every(
(purchase) => !purchase.isRemoved,
),
isTrue,
);
});
test('adding product calculates correct line total', () async { test('adding product calculates correct line total', () async {
final tab = await service.createTab(customerName: 'John'); final tab = await service.createTab(customerName: 'John');
final product = createTestProduct(priceInCents: 450); final product = createTestProduct(priceInCents: 450);
@@ -251,7 +274,7 @@ void main() {
expect(updatedTab!.items.first.quantity, 5); expect(updatedTab!.items.first.quantity, 5);
}); });
test('deletes item when the delta removes all quantity', () async { test('hides 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);
@@ -282,6 +305,32 @@ void main() {
expect(updatedProduct.stockQuantity, 100); expect(updatedProduct.stockQuantity, 100);
}); });
test('keeps a removed purchase in the history audit trail', () async {
final tab = await service.createTab(customerName: 'John');
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;
await service.adjustTabItemQuantity(tabItemId: tabItemId, delta: -1);
final historyTab = (await service.getOpenTabs(
includeRemoved: true,
)).first;
final item = historyTab.items.first;
expect(item.quantity, 1);
expect(
item.purchases.where((purchase) => purchase.isRemoved),
hasLength(1),
);
expect(
item.purchases.where((purchase) => !purchase.isRemoved),
hasLength(1),
);
});
test( test(
'applies concurrent deltas without losing stock consistency', 'applies concurrent deltas without losing stock consistency',
() async { () async {
@@ -320,10 +369,38 @@ void main() {
final closedTabs = await service.getClosedTabs(); final closedTabs = await service.getClosedTabs();
expect(closedTabs.length, 1); expect(closedTabs.length, 1);
expect(closedTabs.first.customerName, 'John'); expect(closedTabs.first.customerName, 'John');
expect(closedTabs.first.items.length, 1); expect(closedTabs.first.items.length, 2);
expect(closedTabs.first.items.first.quantity, 2); expect(
closedTabs.first.items.every((item) => item.quantity == 1),
true,
);
expect(
closedTabs.first.items.every((item) => item.purchasedAt != null),
true,
);
}); });
test(
'keeps removed purchases visible but excludes them from totals',
() async {
final tab = await service.createTab(customerName: 'John');
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;
await service.adjustTabItemQuantity(tabItemId: tabItemId, delta: -1);
await service.closeTab(tab.id);
final closedTab = (await service.getClosedTabs()).first;
expect(closedTab.items, hasLength(2));
expect(closedTab.items.where((item) => item.isRemoved), hasLength(1));
expect(closedTab.itemCount, 1);
expect(closedTab.totalInCents, 400);
},
);
test('clears tab items after closing', () async { test('clears tab items after closing', () async {
final tab = await service.createTab(customerName: 'John'); final tab = await service.createTab(customerName: 'John');
final product = createTestProduct(); final product = createTestProduct();
+78
View File
@@ -0,0 +1,78 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/database/app_database.dart';
import 'package:kooltab2/models/product.dart';
import 'package:kooltab2/services/bar_tab_service.dart';
import 'package:kooltab2/services/pin_lock_service.dart';
import 'package:kooltab2/services/product_service.dart';
import 'package:kooltab2/services/settings_service.dart';
import 'package:kooltab2/viewmodels/dev_menu_view_model.dart';
import 'package:mocktail/mocktail.dart';
class MockBarTabService extends Mock implements BarTabService {}
class MockPinLockService extends Mock implements PinLockService {}
class MockProductService extends Mock implements ProductService {}
class MockSettingsService extends Mock implements SettingsService {}
void main() {
late AppDatabase database;
late MockProductService productService;
late DevMenuViewModel viewModel;
setUp(() {
database = AppDatabase(NativeDatabase.memory());
productService = MockProductService();
when(() => productService.getProducts()).thenAnswer(
(_) async => const [
Product(
id: 'product-1',
name: 'Cola',
category: 'Drinks',
stockQuantity: 100,
lowStockThreshold: 10,
priceInCents: 250,
),
Product(
id: 'product-2',
name: 'Chips',
category: 'Snacks',
stockQuantity: 100,
lowStockThreshold: 10,
priceInCents: 150,
),
],
);
viewModel = DevMenuViewModel(
database: database,
productService: productService,
barTabService: MockBarTabService(),
settingsService: MockSettingsService(),
pinLockService: MockPinLockService(),
);
});
tearDown(() async {
await database.close();
});
test('generates individual timestamped mock history items', () async {
await viewModel.generateMockOrders(count: 12);
final tabs = await database.select(database.closedTabs).get();
final items = await database.select(database.closedTabItems).get();
expect(tabs, hasLength(12));
expect(items, isNotEmpty);
expect(items.every((item) => item.quantity == 1), isTrue);
expect(items.every((item) => item.purchasedAt != null), isTrue);
expect(
viewModel.lastAction,
startsWith('Generated 12 mock orders across '),
);
});
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/models/closed_tab_item.dart';
import 'package:kooltab2/utils/history_grouping.dart';
void main() {
test('groups items by purchase day with newest day first', () {
final items = [
_closedItem('older', DateTime(2026, 8, 1, 22)),
_closedItem('newer-b', DateTime(2026, 8, 3, 18)),
_closedItem('newer-a', DateTime(2026, 8, 3, 10)),
];
final groups = groupClosedTabItemsByDay(
items,
fallbackDate: DateTime(2026, 8, 4),
);
expect(groups, hasLength(2));
expect(groups.first.day, DateTime(2026, 8, 3));
expect(groups.first.items.map((item) => item.id), ['newer-b', 'newer-a']);
expect(groups.last.day, DateTime(2026, 8, 1));
});
}
ClosedTabItem _closedItem(String id, DateTime purchasedAt) {
return ClosedTabItem(
id: id,
closedTabId: 'closed-tab',
productId: 'product-$id',
productName: id,
quantity: 1,
unitPriceInCents: 100,
purchasedAt: purchasedAt,
);
}
+108
View File
@@ -0,0 +1,108 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/models/bar_tab.dart';
import 'package:kooltab2/models/closed_tab.dart';
import 'package:kooltab2/models/payment_method.dart';
import 'package:kooltab2/services/bar_tab_service.dart';
import 'package:kooltab2/viewmodels/history_view_model.dart';
import 'package:mocktail/mocktail.dart';
class MockBarTabService extends Mock implements BarTabService {}
void main() {
late MockBarTabService service;
late HistoryViewModel viewModel;
setUp(() {
service = MockBarTabService();
viewModel = HistoryViewModel(barTabService: service);
when(
() => service.getClosedTabsPaginated(
limit: 20,
offset: 0,
customerName: null,
),
).thenAnswer((_) async => []);
when(
() => service.getClosedTabCount(customerName: null),
).thenAnswer((_) async => 0);
when(() => service.getDistinctCustomerNames()).thenAnswer((_) async => []);
when(
() => service.getOpenTabs(includeRemoved: true),
).thenAnswer((_) async => [_openTab]);
});
test('loads open tabs for the top of history', () async {
await viewModel.load();
expect(viewModel.openTabs, hasLength(1));
expect(viewModel.openTabs.first.customerName, 'Alice');
expect(viewModel.customerNames, ['Alice']);
});
test('search filters open tabs as well as closed tabs', () async {
await viewModel.load();
viewModel.search('bob');
expect(viewModel.openTabs, isEmpty);
});
test('sorts closed and open tabs newest first', () async {
final olderClosedTab = _closedTab('closed-old', DateTime(2026, 8, 1));
final newerClosedTab = _closedTab('closed-new', DateTime(2026, 8, 3));
final olderOpenTab = _barTab('open-old', DateTime(2026, 8, 1));
final newerOpenTab = _barTab('open-new', DateTime(2026, 8, 3));
when(
() => service.getClosedTabsPaginated(
limit: 20,
offset: 0,
customerName: null,
),
).thenAnswer((_) async => [olderClosedTab, newerClosedTab]);
when(
() => service.getClosedTabCount(customerName: null),
).thenAnswer((_) async => 2);
when(
() => service.getOpenTabs(includeRemoved: true),
).thenAnswer((_) async => [olderOpenTab, newerOpenTab]);
await viewModel.load();
expect(viewModel.closedTabs.map((tab) => tab.id), [
'closed-new',
'closed-old',
]);
expect(viewModel.openTabs.map((tab) => tab.id), ['open-new', 'open-old']);
});
}
ClosedTab _closedTab(String id, DateTime closedAt) {
return ClosedTab(
id: id,
originalTabId: id,
customerName: id,
closedAt: closedAt,
paymentMethod: PaymentMethod.cash,
items: const [],
);
}
BarTab _barTab(String id, DateTime openedAt) {
return BarTab(
id: id,
customerName: id,
status: 'open',
openedAt: openedAt,
items: const [],
);
}
final _openTab = BarTab(
id: 'tab-1',
customerName: 'Alice',
status: 'open',
openedAt: DateTime(2026, 8, 2, 12),
items: const [],
);