From 799c1c40457ec1aae961f130ae0086b6de3f58e6 Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Fri, 7 Aug 2026 05:07:41 +0200 Subject: [PATCH] feat: add more detailed history and group per day --- lib/database/app_database.dart | 49 +- lib/database/app_database.g.dart | 966 ++++++++++++++++++- lib/l10n/app_en.arb | 6 +- lib/l10n/app_localizations.dart | 16 +- lib/l10n/app_localizations_en.dart | 10 +- lib/l10n/app_localizations_nl.dart | 10 +- lib/l10n/app_nl.arb | 6 +- lib/models/closed_tab.dart | 23 +- lib/models/closed_tab_item.dart | 28 +- lib/models/tab_item.dart | 23 +- lib/models/tab_item_purchase.dart | 27 + lib/services/bar_tab_service.dart | 193 +++- lib/services/export_service.dart | 93 +- lib/utils/history_grouping.dart | 48 + lib/viewmodels/dev_menu_view_model.dart | 191 ++-- lib/viewmodels/history_view_model.dart | 58 +- lib/views/history_screen_view.dart | 283 ++++-- lib/views/widgets/closed_tab_card.dart | 262 ++++- lib/views/widgets/open_tab_history_card.dart | 256 +++++ test/bar_tab_service_test.dart | 83 +- test/dev_menu_view_model_test.dart | 78 ++ test/history_grouping_test.dart | 35 + test/history_view_model_test.dart | 108 +++ 23 files changed, 2506 insertions(+), 346 deletions(-) create mode 100644 lib/models/tab_item_purchase.dart create mode 100644 lib/utils/history_grouping.dart create mode 100644 lib/views/widgets/open_tab_history_card.dart create mode 100644 test/dev_menu_view_model_test.dart create mode 100644 test/history_grouping_test.dart create mode 100644 test/history_view_model_test.dart diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index e0b8925..9d11194 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -63,6 +63,21 @@ class TabItems extends Table { Set 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 get primaryKey => {id}; +} + @DataClassName('ClosedTabsRow') class ClosedTabs extends Table { TextColumn get id => text()(); @@ -93,6 +108,10 @@ class ClosedTabItems extends Table { IntColumn get unitPriceInCents => integer()(); + DateTimeColumn get purchasedAt => dateTime().nullable()(); + + DateTimeColumn get removedAt => dateTime().nullable()(); + @override Set get primaryKey => {id}; } @@ -124,6 +143,7 @@ class AppSettingsTable extends Table { Products, BarTabs, TabItems, + TabItemPurchases, ClosedTabs, ClosedTabItems, @@ -135,7 +155,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 9; + int get schemaVersion => 11; @override MigrationStrategy get migration { @@ -144,14 +164,18 @@ class AppDatabase extends _$AppDatabase { await migrator.createAll(); }, onUpgrade: (migrator, from, to) async { - Future hasSettingsColumn(String columnName) async { + Future hasTableColumn(String tableName, String columnName) async { final columns = await migrator.database - .customSelect('PRAGMA table_info(app_settings)') + .customSelect('PRAGMA table_info($tableName)') .get(); return columns.any((column) => column.data['name'] == columnName); } + Future hasSettingsColumn(String columnName) { + return hasTableColumn('app_settings', columnName); + } + if (from < 2) { await migrator.createTable(barTabs); await migrator.createTable(tabItems); @@ -170,7 +194,8 @@ class AppDatabase extends _$AppDatabase { await migrator.createTable(appSettingsTable); } - if (from < 6) { + if (from < 6 && + !await hasTableColumn('closed_tabs', 'payment_method')) { await migrator.addColumn(closedTabs, closedTabs.paymentMethod); } @@ -191,6 +216,22 @@ class AppDatabase extends _$AppDatabase { 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); + } }, ); } diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 6d8c248..2ec32e9 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -1369,6 +1369,326 @@ class TabItemsCompanion extends UpdateCompanion { } } +class $TabItemPurchasesTable extends TabItemPurchases + with TableInfo<$TabItemPurchasesTable, TabItemPurchaseRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $TabItemPurchasesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _tabItemIdMeta = const VerificationMeta( + 'tabItemId', + ); + @override + late final GeneratedColumn tabItemId = GeneratedColumn( + 'tab_item_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES tab_items (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _purchasedAtMeta = const VerificationMeta( + 'purchasedAt', + ); + @override + late final GeneratedColumn purchasedAt = GeneratedColumn( + 'purchased_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _removedAtMeta = const VerificationMeta( + 'removedAt', + ); + @override + late final GeneratedColumn removedAt = GeneratedColumn( + 'removed_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [id, tabItemId, purchasedAt, removedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab_item_purchases'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('tab_item_id')) { + context.handle( + _tabItemIdMeta, + tabItemId.isAcceptableOrUnknown(data['tab_item_id']!, _tabItemIdMeta), + ); + } else if (isInserting) { + context.missing(_tabItemIdMeta); + } + if (data.containsKey('purchased_at')) { + context.handle( + _purchasedAtMeta, + purchasedAt.isAcceptableOrUnknown( + data['purchased_at']!, + _purchasedAtMeta, + ), + ); + } else if (isInserting) { + context.missing(_purchasedAtMeta); + } + if (data.containsKey('removed_at')) { + context.handle( + _removedAtMeta, + removedAt.isAcceptableOrUnknown(data['removed_at']!, _removedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + TabItemPurchaseRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabItemPurchaseRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + tabItemId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tab_item_id'], + )!, + purchasedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}purchased_at'], + )!, + removedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}removed_at'], + ), + ); + } + + @override + $TabItemPurchasesTable createAlias(String alias) { + return $TabItemPurchasesTable(attachedDatabase, alias); + } +} + +class TabItemPurchaseRow extends DataClass + implements Insertable { + final String id; + final String tabItemId; + final DateTime purchasedAt; + final DateTime? removedAt; + const TabItemPurchaseRow({ + required this.id, + required this.tabItemId, + required this.purchasedAt, + this.removedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['tab_item_id'] = Variable(tabItemId); + map['purchased_at'] = Variable(purchasedAt); + if (!nullToAbsent || removedAt != null) { + map['removed_at'] = Variable(removedAt); + } + return map; + } + + TabItemPurchasesCompanion toCompanion(bool nullToAbsent) { + return TabItemPurchasesCompanion( + id: Value(id), + tabItemId: Value(tabItemId), + purchasedAt: Value(purchasedAt), + removedAt: removedAt == null && nullToAbsent + ? const Value.absent() + : Value(removedAt), + ); + } + + factory TabItemPurchaseRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabItemPurchaseRow( + id: serializer.fromJson(json['id']), + tabItemId: serializer.fromJson(json['tabItemId']), + purchasedAt: serializer.fromJson(json['purchasedAt']), + removedAt: serializer.fromJson(json['removedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'tabItemId': serializer.toJson(tabItemId), + 'purchasedAt': serializer.toJson(purchasedAt), + 'removedAt': serializer.toJson(removedAt), + }; + } + + TabItemPurchaseRow copyWith({ + String? id, + String? tabItemId, + DateTime? purchasedAt, + Value removedAt = const Value.absent(), + }) => TabItemPurchaseRow( + id: id ?? this.id, + tabItemId: tabItemId ?? this.tabItemId, + purchasedAt: purchasedAt ?? this.purchasedAt, + removedAt: removedAt.present ? removedAt.value : this.removedAt, + ); + TabItemPurchaseRow copyWithCompanion(TabItemPurchasesCompanion data) { + return TabItemPurchaseRow( + id: data.id.present ? data.id.value : this.id, + tabItemId: data.tabItemId.present ? data.tabItemId.value : this.tabItemId, + purchasedAt: data.purchasedAt.present + ? data.purchasedAt.value + : this.purchasedAt, + removedAt: data.removedAt.present ? data.removedAt.value : this.removedAt, + ); + } + + @override + String toString() { + return (StringBuffer('TabItemPurchaseRow(') + ..write('id: $id, ') + ..write('tabItemId: $tabItemId, ') + ..write('purchasedAt: $purchasedAt, ') + ..write('removedAt: $removedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, tabItemId, purchasedAt, removedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabItemPurchaseRow && + other.id == this.id && + other.tabItemId == this.tabItemId && + other.purchasedAt == this.purchasedAt && + other.removedAt == this.removedAt); +} + +class TabItemPurchasesCompanion extends UpdateCompanion { + final Value id; + final Value tabItemId; + final Value purchasedAt; + final Value removedAt; + final Value rowid; + const TabItemPurchasesCompanion({ + this.id = const Value.absent(), + this.tabItemId = const Value.absent(), + this.purchasedAt = const Value.absent(), + this.removedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabItemPurchasesCompanion.insert({ + required String id, + required String tabItemId, + required DateTime purchasedAt, + this.removedAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + tabItemId = Value(tabItemId), + purchasedAt = Value(purchasedAt); + static Insertable custom({ + Expression? id, + Expression? tabItemId, + Expression? purchasedAt, + Expression? removedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (tabItemId != null) 'tab_item_id': tabItemId, + if (purchasedAt != null) 'purchased_at': purchasedAt, + if (removedAt != null) 'removed_at': removedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + TabItemPurchasesCompanion copyWith({ + Value? id, + Value? tabItemId, + Value? purchasedAt, + Value? removedAt, + Value? rowid, + }) { + return TabItemPurchasesCompanion( + id: id ?? this.id, + tabItemId: tabItemId ?? this.tabItemId, + purchasedAt: purchasedAt ?? this.purchasedAt, + removedAt: removedAt ?? this.removedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (tabItemId.present) { + map['tab_item_id'] = Variable(tabItemId.value); + } + if (purchasedAt.present) { + map['purchased_at'] = Variable(purchasedAt.value); + } + if (removedAt.present) { + map['removed_at'] = Variable(removedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabItemPurchasesCompanion(') + ..write('id: $id, ') + ..write('tabItemId: $tabItemId, ') + ..write('purchasedAt: $purchasedAt, ') + ..write('removedAt: $removedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $ClosedTabsTable extends ClosedTabs with TableInfo<$ClosedTabsTable, ClosedTabsRow> { @override @@ -1815,6 +2135,28 @@ class $ClosedTabItemsTable extends ClosedTabItems type: DriftSqlType.int, requiredDuringInsert: true, ); + static const VerificationMeta _purchasedAtMeta = const VerificationMeta( + 'purchasedAt', + ); + @override + late final GeneratedColumn purchasedAt = GeneratedColumn( + 'purchased_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _removedAtMeta = const VerificationMeta( + 'removedAt', + ); + @override + late final GeneratedColumn removedAt = GeneratedColumn( + 'removed_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -1823,6 +2165,8 @@ class $ClosedTabItemsTable extends ClosedTabItems productName, quantity, unitPriceInCents, + purchasedAt, + removedAt, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -1890,6 +2234,21 @@ class $ClosedTabItemsTable extends ClosedTabItems } else if (isInserting) { context.missing(_unitPriceInCentsMeta); } + if (data.containsKey('purchased_at')) { + context.handle( + _purchasedAtMeta, + purchasedAt.isAcceptableOrUnknown( + data['purchased_at']!, + _purchasedAtMeta, + ), + ); + } + if (data.containsKey('removed_at')) { + context.handle( + _removedAtMeta, + removedAt.isAcceptableOrUnknown(data['removed_at']!, _removedAtMeta), + ); + } return context; } @@ -1923,6 +2282,14 @@ class $ClosedTabItemsTable extends ClosedTabItems DriftSqlType.int, data['${effectivePrefix}unit_price_in_cents'], )!, + purchasedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}purchased_at'], + ), + removedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}removed_at'], + ), ); } @@ -1940,6 +2307,8 @@ class ClosedTabItemRow extends DataClass final String productName; final int quantity; final int unitPriceInCents; + final DateTime? purchasedAt; + final DateTime? removedAt; const ClosedTabItemRow({ required this.id, required this.closedTabId, @@ -1947,6 +2316,8 @@ class ClosedTabItemRow extends DataClass required this.productName, required this.quantity, required this.unitPriceInCents, + this.purchasedAt, + this.removedAt, }); @override Map toColumns(bool nullToAbsent) { @@ -1957,6 +2328,12 @@ class ClosedTabItemRow extends DataClass map['product_name'] = Variable(productName); map['quantity'] = Variable(quantity); map['unit_price_in_cents'] = Variable(unitPriceInCents); + if (!nullToAbsent || purchasedAt != null) { + map['purchased_at'] = Variable(purchasedAt); + } + if (!nullToAbsent || removedAt != null) { + map['removed_at'] = Variable(removedAt); + } return map; } @@ -1968,6 +2345,12 @@ class ClosedTabItemRow extends DataClass productName: Value(productName), quantity: Value(quantity), unitPriceInCents: Value(unitPriceInCents), + purchasedAt: purchasedAt == null && nullToAbsent + ? const Value.absent() + : Value(purchasedAt), + removedAt: removedAt == null && nullToAbsent + ? const Value.absent() + : Value(removedAt), ); } @@ -1983,6 +2366,8 @@ class ClosedTabItemRow extends DataClass productName: serializer.fromJson(json['productName']), quantity: serializer.fromJson(json['quantity']), unitPriceInCents: serializer.fromJson(json['unitPriceInCents']), + purchasedAt: serializer.fromJson(json['purchasedAt']), + removedAt: serializer.fromJson(json['removedAt']), ); } @override @@ -1995,6 +2380,8 @@ class ClosedTabItemRow extends DataClass 'productName': serializer.toJson(productName), 'quantity': serializer.toJson(quantity), 'unitPriceInCents': serializer.toJson(unitPriceInCents), + 'purchasedAt': serializer.toJson(purchasedAt), + 'removedAt': serializer.toJson(removedAt), }; } @@ -2005,6 +2392,8 @@ class ClosedTabItemRow extends DataClass String? productName, int? quantity, int? unitPriceInCents, + Value purchasedAt = const Value.absent(), + Value removedAt = const Value.absent(), }) => ClosedTabItemRow( id: id ?? this.id, closedTabId: closedTabId ?? this.closedTabId, @@ -2012,6 +2401,8 @@ class ClosedTabItemRow extends DataClass productName: productName ?? this.productName, quantity: quantity ?? this.quantity, unitPriceInCents: unitPriceInCents ?? this.unitPriceInCents, + purchasedAt: purchasedAt.present ? purchasedAt.value : this.purchasedAt, + removedAt: removedAt.present ? removedAt.value : this.removedAt, ); ClosedTabItemRow copyWithCompanion(ClosedTabItemsCompanion data) { return ClosedTabItemRow( @@ -2027,6 +2418,10 @@ class ClosedTabItemRow extends DataClass unitPriceInCents: data.unitPriceInCents.present ? data.unitPriceInCents.value : this.unitPriceInCents, + purchasedAt: data.purchasedAt.present + ? data.purchasedAt.value + : this.purchasedAt, + removedAt: data.removedAt.present ? data.removedAt.value : this.removedAt, ); } @@ -2038,7 +2433,9 @@ class ClosedTabItemRow extends DataClass ..write('productId: $productId, ') ..write('productName: $productName, ') ..write('quantity: $quantity, ') - ..write('unitPriceInCents: $unitPriceInCents') + ..write('unitPriceInCents: $unitPriceInCents, ') + ..write('purchasedAt: $purchasedAt, ') + ..write('removedAt: $removedAt') ..write(')')) .toString(); } @@ -2051,6 +2448,8 @@ class ClosedTabItemRow extends DataClass productName, quantity, unitPriceInCents, + purchasedAt, + removedAt, ); @override bool operator ==(Object other) => @@ -2061,7 +2460,9 @@ class ClosedTabItemRow extends DataClass other.productId == this.productId && other.productName == this.productName && other.quantity == this.quantity && - other.unitPriceInCents == this.unitPriceInCents); + other.unitPriceInCents == this.unitPriceInCents && + other.purchasedAt == this.purchasedAt && + other.removedAt == this.removedAt); } class ClosedTabItemsCompanion extends UpdateCompanion { @@ -2071,6 +2472,8 @@ class ClosedTabItemsCompanion extends UpdateCompanion { final Value productName; final Value quantity; final Value unitPriceInCents; + final Value purchasedAt; + final Value removedAt; final Value rowid; const ClosedTabItemsCompanion({ this.id = const Value.absent(), @@ -2079,6 +2482,8 @@ class ClosedTabItemsCompanion extends UpdateCompanion { this.productName = const Value.absent(), this.quantity = const Value.absent(), this.unitPriceInCents = const Value.absent(), + this.purchasedAt = const Value.absent(), + this.removedAt = const Value.absent(), this.rowid = const Value.absent(), }); ClosedTabItemsCompanion.insert({ @@ -2088,6 +2493,8 @@ class ClosedTabItemsCompanion extends UpdateCompanion { required String productName, required int quantity, required int unitPriceInCents, + this.purchasedAt = const Value.absent(), + this.removedAt = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), closedTabId = Value(closedTabId), @@ -2102,6 +2509,8 @@ class ClosedTabItemsCompanion extends UpdateCompanion { Expression? productName, Expression? quantity, Expression? unitPriceInCents, + Expression? purchasedAt, + Expression? removedAt, Expression? rowid, }) { return RawValuesInsertable({ @@ -2111,6 +2520,8 @@ class ClosedTabItemsCompanion extends UpdateCompanion { if (productName != null) 'product_name': productName, if (quantity != null) 'quantity': quantity, if (unitPriceInCents != null) 'unit_price_in_cents': unitPriceInCents, + if (purchasedAt != null) 'purchased_at': purchasedAt, + if (removedAt != null) 'removed_at': removedAt, if (rowid != null) 'rowid': rowid, }); } @@ -2122,6 +2533,8 @@ class ClosedTabItemsCompanion extends UpdateCompanion { Value? productName, Value? quantity, Value? unitPriceInCents, + Value? purchasedAt, + Value? removedAt, Value? rowid, }) { return ClosedTabItemsCompanion( @@ -2131,6 +2544,8 @@ class ClosedTabItemsCompanion extends UpdateCompanion { productName: productName ?? this.productName, quantity: quantity ?? this.quantity, unitPriceInCents: unitPriceInCents ?? this.unitPriceInCents, + purchasedAt: purchasedAt ?? this.purchasedAt, + removedAt: removedAt ?? this.removedAt, rowid: rowid ?? this.rowid, ); } @@ -2156,6 +2571,12 @@ class ClosedTabItemsCompanion extends UpdateCompanion { if (unitPriceInCents.present) { map['unit_price_in_cents'] = Variable(unitPriceInCents.value); } + if (purchasedAt.present) { + map['purchased_at'] = Variable(purchasedAt.value); + } + if (removedAt.present) { + map['removed_at'] = Variable(removedAt.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -2171,6 +2592,8 @@ class ClosedTabItemsCompanion extends UpdateCompanion { ..write('productName: $productName, ') ..write('quantity: $quantity, ') ..write('unitPriceInCents: $unitPriceInCents, ') + ..write('purchasedAt: $purchasedAt, ') + ..write('removedAt: $removedAt, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -2610,6 +3033,9 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $ProductsTable products = $ProductsTable(this); late final $BarTabsTable barTabs = $BarTabsTable(this); late final $TabItemsTable tabItems = $TabItemsTable(this); + late final $TabItemPurchasesTable tabItemPurchases = $TabItemPurchasesTable( + this, + ); late final $ClosedTabsTable closedTabs = $ClosedTabsTable(this); late final $ClosedTabItemsTable closedTabItems = $ClosedTabItemsTable(this); late final $AppSettingsTableTable appSettingsTable = $AppSettingsTableTable( @@ -2623,6 +3049,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { products, barTabs, tabItems, + tabItemPurchases, closedTabs, closedTabItems, appSettingsTable, @@ -2636,6 +3063,13 @@ abstract class _$AppDatabase extends GeneratedDatabase { ), result: [TableUpdate('tab_items', kind: UpdateKind.delete)], ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab_items', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab_item_purchases', kind: UpdateKind.delete)], + ), ]); } @@ -3358,6 +3792,26 @@ final class $$TabItemsTableReferences manager.$state.copyWith(prefetchedData: [item]), ); } + + static MultiTypedResultKey<$TabItemPurchasesTable, List> + _tabItemPurchasesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.tabItemPurchases, + aliasName: 'tab_items__id__tab_item_purchases__tab_item_id', + ); + + $$TabItemPurchasesTableProcessedTableManager get tabItemPurchasesRefs { + final manager = $$TabItemPurchasesTableTableManager( + $_db, + $_db.tabItemPurchases, + ).filter((f) => f.tabItemId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _tabItemPurchasesRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } } class $$TabItemsTableFilterComposer @@ -3439,6 +3893,31 @@ class $$TabItemsTableFilterComposer ); return composer; } + + Expression tabItemPurchasesRefs( + Expression Function($$TabItemPurchasesTableFilterComposer f) f, + ) { + final $$TabItemPurchasesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.tabItemPurchases, + getReferencedColumn: (t) => t.tabItemId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TabItemPurchasesTableFilterComposer( + $db: $db, + $table: $db.tabItemPurchases, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } class $$TabItemsTableOrderingComposer @@ -3595,6 +4074,31 @@ class $$TabItemsTableAnnotationComposer ); return composer; } + + Expression tabItemPurchasesRefs( + Expression Function($$TabItemPurchasesTableAnnotationComposer a) f, + ) { + final $$TabItemPurchasesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.tabItemPurchases, + getReferencedColumn: (t) => t.tabItemId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TabItemPurchasesTableAnnotationComposer( + $db: $db, + $table: $db.tabItemPurchases, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } class $$TabItemsTableTableManager @@ -3610,7 +4114,11 @@ class $$TabItemsTableTableManager $$TabItemsTableUpdateCompanionBuilder, (TabItemRow, $$TabItemsTableReferences), TabItemRow, - PrefetchHooks Function({bool tabId, bool productId}) + PrefetchHooks Function({ + bool tabId, + bool productId, + bool tabItemPurchasesRefs, + }) > { $$TabItemsTableTableManager(_$AppDatabase db, $TabItemsTable table) : super( @@ -3671,7 +4179,364 @@ class $$TabItemsTableTableManager ), ) .toList(), - prefetchHooksCallback: ({tabId = false, productId = false}) { + prefetchHooksCallback: + ({ + tabId = false, + productId = false, + tabItemPurchasesRefs = false, + }) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (tabItemPurchasesRefs) db.tabItemPurchases, + ], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (tabId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.tabId, + referencedTable: $$TabItemsTableReferences + ._tabIdTable(db), + referencedColumn: $$TabItemsTableReferences + ._tabIdTable(db) + .id, + ) + as T; + } + if (productId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.productId, + referencedTable: $$TabItemsTableReferences + ._productIdTable(db), + referencedColumn: $$TabItemsTableReferences + ._productIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return [ + if (tabItemPurchasesRefs) + await $_getPrefetchedData< + TabItemRow, + $TabItemsTable, + TabItemPurchaseRow + >( + currentTable: table, + referencedTable: $$TabItemsTableReferences + ._tabItemPurchasesRefsTable(db), + managerFromTypedResult: (p0) => + $$TabItemsTableReferences( + db, + table, + p0, + ).tabItemPurchasesRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.tabItemId == item.id, + ), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$TabItemsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $TabItemsTable, + TabItemRow, + $$TabItemsTableFilterComposer, + $$TabItemsTableOrderingComposer, + $$TabItemsTableAnnotationComposer, + $$TabItemsTableCreateCompanionBuilder, + $$TabItemsTableUpdateCompanionBuilder, + (TabItemRow, $$TabItemsTableReferences), + TabItemRow, + PrefetchHooks Function({ + bool tabId, + bool productId, + bool tabItemPurchasesRefs, + }) + >; +typedef $$TabItemPurchasesTableCreateCompanionBuilder = + TabItemPurchasesCompanion Function({ + required String id, + required String tabItemId, + required DateTime purchasedAt, + Value removedAt, + Value rowid, + }); +typedef $$TabItemPurchasesTableUpdateCompanionBuilder = + TabItemPurchasesCompanion Function({ + Value id, + Value tabItemId, + Value purchasedAt, + Value removedAt, + Value rowid, + }); + +final class $$TabItemPurchasesTableReferences + extends + BaseReferences< + _$AppDatabase, + $TabItemPurchasesTable, + TabItemPurchaseRow + > { + $$TabItemPurchasesTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $TabItemsTable _tabItemIdTable(_$AppDatabase db) => + db.tabItems.createAlias('tab_item_purchases__tab_item_id__tab_items__id'); + + $$TabItemsTableProcessedTableManager get tabItemId { + final $_column = $_itemColumn('tab_item_id')!; + + final manager = $$TabItemsTableTableManager( + $_db, + $_db.tabItems, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_tabItemIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$TabItemPurchasesTableFilterComposer + extends Composer<_$AppDatabase, $TabItemPurchasesTable> { + $$TabItemPurchasesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get purchasedAt => $composableBuilder( + column: $table.purchasedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get removedAt => $composableBuilder( + column: $table.removedAt, + builder: (column) => ColumnFilters(column), + ); + + $$TabItemsTableFilterComposer get tabItemId { + final $$TabItemsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.tabItemId, + referencedTable: $db.tabItems, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TabItemsTableFilterComposer( + $db: $db, + $table: $db.tabItems, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$TabItemPurchasesTableOrderingComposer + extends Composer<_$AppDatabase, $TabItemPurchasesTable> { + $$TabItemPurchasesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get purchasedAt => $composableBuilder( + column: $table.purchasedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get removedAt => $composableBuilder( + column: $table.removedAt, + builder: (column) => ColumnOrderings(column), + ); + + $$TabItemsTableOrderingComposer get tabItemId { + final $$TabItemsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.tabItemId, + referencedTable: $db.tabItems, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TabItemsTableOrderingComposer( + $db: $db, + $table: $db.tabItems, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$TabItemPurchasesTableAnnotationComposer + extends Composer<_$AppDatabase, $TabItemPurchasesTable> { + $$TabItemPurchasesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get purchasedAt => $composableBuilder( + column: $table.purchasedAt, + builder: (column) => column, + ); + + GeneratedColumn get removedAt => + $composableBuilder(column: $table.removedAt, builder: (column) => column); + + $$TabItemsTableAnnotationComposer get tabItemId { + final $$TabItemsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.tabItemId, + referencedTable: $db.tabItems, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TabItemsTableAnnotationComposer( + $db: $db, + $table: $db.tabItems, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$TabItemPurchasesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $TabItemPurchasesTable, + TabItemPurchaseRow, + $$TabItemPurchasesTableFilterComposer, + $$TabItemPurchasesTableOrderingComposer, + $$TabItemPurchasesTableAnnotationComposer, + $$TabItemPurchasesTableCreateCompanionBuilder, + $$TabItemPurchasesTableUpdateCompanionBuilder, + (TabItemPurchaseRow, $$TabItemPurchasesTableReferences), + TabItemPurchaseRow, + PrefetchHooks Function({bool tabItemId}) + > { + $$TabItemPurchasesTableTableManager( + _$AppDatabase db, + $TabItemPurchasesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$TabItemPurchasesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$TabItemPurchasesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$TabItemPurchasesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value tabItemId = const Value.absent(), + Value purchasedAt = const Value.absent(), + Value removedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => TabItemPurchasesCompanion( + id: id, + tabItemId: tabItemId, + purchasedAt: purchasedAt, + removedAt: removedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String tabItemId, + required DateTime purchasedAt, + Value removedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => TabItemPurchasesCompanion.insert( + id: id, + tabItemId: tabItemId, + purchasedAt: purchasedAt, + removedAt: removedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$TabItemPurchasesTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({tabItemId = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [], @@ -3691,29 +4556,18 @@ class $$TabItemsTableTableManager dynamic > >(state) { - if (tabId) { + if (tabItemId) { state = state.withJoin( currentTable: table, - currentColumn: table.tabId, - referencedTable: $$TabItemsTableReferences - ._tabIdTable(db), - referencedColumn: $$TabItemsTableReferences - ._tabIdTable(db) - .id, - ) - as T; - } - if (productId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.productId, - referencedTable: $$TabItemsTableReferences - ._productIdTable(db), - referencedColumn: $$TabItemsTableReferences - ._productIdTable(db) - .id, + currentColumn: table.tabItemId, + referencedTable: + $$TabItemPurchasesTableReferences + ._tabItemIdTable(db), + referencedColumn: + $$TabItemPurchasesTableReferences + ._tabItemIdTable(db) + .id, ) as T; } @@ -3729,19 +4583,19 @@ class $$TabItemsTableTableManager ); } -typedef $$TabItemsTableProcessedTableManager = +typedef $$TabItemPurchasesTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $TabItemsTable, - TabItemRow, - $$TabItemsTableFilterComposer, - $$TabItemsTableOrderingComposer, - $$TabItemsTableAnnotationComposer, - $$TabItemsTableCreateCompanionBuilder, - $$TabItemsTableUpdateCompanionBuilder, - (TabItemRow, $$TabItemsTableReferences), - TabItemRow, - PrefetchHooks Function({bool tabId, bool productId}) + $TabItemPurchasesTable, + TabItemPurchaseRow, + $$TabItemPurchasesTableFilterComposer, + $$TabItemPurchasesTableOrderingComposer, + $$TabItemPurchasesTableAnnotationComposer, + $$TabItemPurchasesTableCreateCompanionBuilder, + $$TabItemPurchasesTableUpdateCompanionBuilder, + (TabItemPurchaseRow, $$TabItemPurchasesTableReferences), + TabItemPurchaseRow, + PrefetchHooks Function({bool tabItemId}) >; typedef $$ClosedTabsTableCreateCompanionBuilder = ClosedTabsCompanion Function({ @@ -3957,6 +4811,8 @@ typedef $$ClosedTabItemsTableCreateCompanionBuilder = required String productName, required int quantity, required int unitPriceInCents, + Value purchasedAt, + Value removedAt, Value rowid, }); typedef $$ClosedTabItemsTableUpdateCompanionBuilder = @@ -3967,6 +4823,8 @@ typedef $$ClosedTabItemsTableUpdateCompanionBuilder = Value productName, Value quantity, Value unitPriceInCents, + Value purchasedAt, + Value removedAt, Value rowid, }); @@ -4008,6 +4866,16 @@ class $$ClosedTabItemsTableFilterComposer column: $table.unitPriceInCents, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get purchasedAt => $composableBuilder( + column: $table.purchasedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get removedAt => $composableBuilder( + column: $table.removedAt, + builder: (column) => ColumnFilters(column), + ); } class $$ClosedTabItemsTableOrderingComposer @@ -4048,6 +4916,16 @@ class $$ClosedTabItemsTableOrderingComposer column: $table.unitPriceInCents, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get purchasedAt => $composableBuilder( + column: $table.purchasedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get removedAt => $composableBuilder( + column: $table.removedAt, + builder: (column) => ColumnOrderings(column), + ); } class $$ClosedTabItemsTableAnnotationComposer @@ -4082,6 +4960,14 @@ class $$ClosedTabItemsTableAnnotationComposer column: $table.unitPriceInCents, builder: (column) => column, ); + + GeneratedColumn get purchasedAt => $composableBuilder( + column: $table.purchasedAt, + builder: (column) => column, + ); + + GeneratedColumn get removedAt => + $composableBuilder(column: $table.removedAt, builder: (column) => column); } class $$ClosedTabItemsTableTableManager @@ -4127,6 +5013,8 @@ class $$ClosedTabItemsTableTableManager Value productName = const Value.absent(), Value quantity = const Value.absent(), Value unitPriceInCents = const Value.absent(), + Value purchasedAt = const Value.absent(), + Value removedAt = const Value.absent(), Value rowid = const Value.absent(), }) => ClosedTabItemsCompanion( id: id, @@ -4135,6 +5023,8 @@ class $$ClosedTabItemsTableTableManager productName: productName, quantity: quantity, unitPriceInCents: unitPriceInCents, + purchasedAt: purchasedAt, + removedAt: removedAt, rowid: rowid, ), createCompanionCallback: @@ -4145,6 +5035,8 @@ class $$ClosedTabItemsTableTableManager required String productName, required int quantity, required int unitPriceInCents, + Value purchasedAt = const Value.absent(), + Value removedAt = const Value.absent(), Value rowid = const Value.absent(), }) => ClosedTabItemsCompanion.insert( id: id, @@ -4153,6 +5045,8 @@ class $$ClosedTabItemsTableTableManager productName: productName, quantity: quantity, unitPriceInCents: unitPriceInCents, + purchasedAt: purchasedAt, + removedAt: removedAt, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -4421,6 +5315,8 @@ class $AppDatabaseManager { $$BarTabsTableTableManager(_db, _db.barTabs); $$TabItemsTableTableManager get tabItems => $$TabItemsTableTableManager(_db, _db.tabItems); + $$TabItemPurchasesTableTableManager get tabItemPurchases => + $$TabItemPurchasesTableTableManager(_db, _db.tabItemPurchases); $$ClosedTabsTableTableManager get closedTabs => $$ClosedTabsTableTableManager(_db, _db.closedTabs); $$ClosedTabItemsTableTableManager get closedTabItems => diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fc592a9..ed22793 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -83,6 +83,7 @@ "@version": {"placeholders": {"version": {"type": "String"}}}, "selectTabToAddItems": "Select a tab to add items", "openTabs": "Open tabs", + "closedTabs": "Closed tabs", "selectOrOpenTab": "Select or open a tab", "noOpenTabs": "No open tabs", "edit": "Edit", @@ -113,6 +114,7 @@ "pinsDidNotMatch": "PINs didn’t match. Try again.", "somethingWentWrong": "Something went wrong.", "incorrectPin": "Incorrect PIN.", + "removed": "Removed", "enterPrice": "Enter a price.", "enterValidPrice": "Enter a valid price.", "enterValidStock": "Enter a valid stock amount.", @@ -213,8 +215,8 @@ "stressTestGrid": "Stress test product grid", "simulateLowStock": "Simulate low stock", "setProductsBelowThreshold": "Set all products below threshold", - "generateMockOrders": "Generate 100 mock orders", - "randomCustomersItemsAmounts": "Random customers, items, and amounts", + "generateMockOrders": "Generate mock order history", + "randomCustomersItemsAmounts": "100 orders across days with random products and purchase times", "performance": "Performance", "clearImageCache": "Clear image cache", "reloadProductImages": "Reload product images", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 53c4059..5e6cda0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -578,6 +578,12 @@ abstract class AppLocalizations { /// **'Open tabs'** String get openTabs; + /// No description provided for @closedTabs. + /// + /// In en, this message translates to: + /// **'Closed tabs'** + String get closedTabs; + /// No description provided for @selectOrOpenTab. /// /// In en, this message translates to: @@ -740,6 +746,12 @@ abstract class AppLocalizations { /// **'Incorrect PIN.'** String get incorrectPin; + /// No description provided for @removed. + /// + /// In en, this message translates to: + /// **'Removed'** + String get removed; + /// No description provided for @enterPrice. /// /// In en, this message translates to: @@ -1319,13 +1331,13 @@ abstract class AppLocalizations { /// No description provided for @generateMockOrders. /// /// In en, this message translates to: - /// **'Generate 100 mock orders'** + /// **'Generate mock order history'** String get generateMockOrders; /// No description provided for @randomCustomersItemsAmounts. /// /// In en, this message translates to: - /// **'Random customers, items, and amounts'** + /// **'100 orders across days with random products and purchase times'** String get randomCustomersItemsAmounts; /// No description provided for @performance. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 931d9be..87b8aa8 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -258,6 +258,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get openTabs => 'Open tabs'; + @override + String get closedTabs => 'Closed tabs'; + @override String get selectOrOpenTab => 'Select or open a tab'; @@ -358,6 +361,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get incorrectPin => 'Incorrect PIN.'; + @override + String get removed => 'Removed'; + @override String get enterPrice => 'Enter a price.'; @@ -660,11 +666,11 @@ class AppLocalizationsEn extends AppLocalizations { String get setProductsBelowThreshold => 'Set all products below threshold'; @override - String get generateMockOrders => 'Generate 100 mock orders'; + String get generateMockOrders => 'Generate mock order history'; @override String get randomCustomersItemsAmounts => - 'Random customers, items, and amounts'; + '100 orders across days with random products and purchase times'; @override String get performance => 'Performance'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 796097b..0e1eefb 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -258,6 +258,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get openTabs => 'Open poefs'; + @override + String get closedTabs => 'Gesloten poefs'; + @override String get selectOrOpenTab => 'Selecteer of open een poef'; @@ -359,6 +362,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get incorrectPin => 'Onjuiste PIN.'; + @override + String get removed => 'Verwijderd'; + @override String get enterPrice => 'Voer een prijs in.'; @@ -668,11 +674,11 @@ class AppLocalizationsNl extends AppLocalizations { 'Alle producten onder de drempel instellen'; @override - String get generateMockOrders => '100 testbestellingen genereren'; + String get generateMockOrders => 'Mock-bestelgeschiedenis genereren'; @override String get randomCustomersItemsAmounts => - 'Willekeurige klanten, items en bedragen'; + '100 bestellingen over meerdere dagen met willekeurige producten en tijden'; @override String get performance => 'Prestaties'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 0e03cd4..d3e4bfe 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -83,6 +83,7 @@ "@version": {"placeholders": {"version": {"type": "String"}}}, "selectTabToAddItems": "Selecteer een poef om items toe te voegen", "openTabs": "Open poefs", + "closedTabs": "Gesloten poefs", "selectOrOpenTab": "Selecteer of open een poef", "noOpenTabs": "Geen open poefs", "edit": "Bewerken", @@ -113,6 +114,7 @@ "pinsDidNotMatch": "PINs kwamen niet overeen. Probeer het opnieuw.", "somethingWentWrong": "Er is iets misgegaan.", "incorrectPin": "Onjuiste PIN.", + "removed": "Verwijderd", "enterPrice": "Voer een prijs in.", "enterValidPrice": "Voer een geldige prijs in.", "enterValidStock": "Voer een geldige voorraad in.", @@ -213,8 +215,8 @@ "stressTestGrid": "Productraster stresstesten", "simulateLowStock": "Lage voorraad simuleren", "setProductsBelowThreshold": "Alle producten onder de drempel instellen", - "generateMockOrders": "100 testbestellingen genereren", - "randomCustomersItemsAmounts": "Willekeurige klanten, items en bedragen", + "generateMockOrders": "Mock-bestelgeschiedenis genereren", + "randomCustomersItemsAmounts": "100 bestellingen over meerdere dagen met willekeurige producten en tijden", "performance": "Prestaties", "clearImageCache": "Afbeeldingencache wissen", "reloadProductImages": "Productafbeeldingen opnieuw laden", diff --git a/lib/models/closed_tab.dart b/lib/models/closed_tab.dart index 7e7a4fe..e7b101a 100644 --- a/lib/models/closed_tab.dart +++ b/lib/models/closed_tab.dart @@ -9,7 +9,7 @@ class ClosedTab { final String originalTabId; final String customerName; final DateTime closedAt; -final PaymentMethod paymentMethod; + final PaymentMethod paymentMethod; final List items; const ClosedTab({ @@ -21,10 +21,11 @@ final PaymentMethod paymentMethod; 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 => - items.fold(0, (sum, item) => sum + item.quantity * item.unitPriceInCents); + items.fold(0, (sum, item) => sum + item.lineTotalInCents); String get formattedTotal => NumberFormat.simpleCurrency().format(totalInCents / 100); @@ -44,13 +45,13 @@ final PaymentMethod paymentMethod; @override int get hashCode => Object.hash( - id, - originalTabId, - customerName, - closedAt, - paymentMethod, - Object.hashAll(items), - ); + id, + originalTabId, + customerName, + closedAt, + paymentMethod, + Object.hashAll(items), + ); } -// _listEquals moved to utils/collection_utils.dart \ No newline at end of file +// _listEquals moved to utils/collection_utils.dart diff --git a/lib/models/closed_tab_item.dart b/lib/models/closed_tab_item.dart index 3446cad..eea896f 100644 --- a/lib/models/closed_tab_item.dart +++ b/lib/models/closed_tab_item.dart @@ -5,6 +5,8 @@ class ClosedTabItem { final String productName; final int quantity; final int unitPriceInCents; + final DateTime? purchasedAt; + final DateTime? removedAt; ClosedTabItem({ required this.id, @@ -13,9 +15,13 @@ class ClosedTabItem { required this.productName, required this.quantity, 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 bool operator ==(Object other) => @@ -26,15 +32,19 @@ class ClosedTabItem { productId == other.productId && productName == other.productName && quantity == other.quantity && - unitPriceInCents == other.unitPriceInCents; + unitPriceInCents == other.unitPriceInCents && + purchasedAt == other.purchasedAt && + removedAt == other.removedAt; @override int get hashCode => Object.hash( - id, - closedTabId, - productId, - productName, - quantity, - unitPriceInCents, - ); + id, + closedTabId, + productId, + productName, + quantity, + unitPriceInCents, + purchasedAt, + removedAt, + ); } diff --git a/lib/models/tab_item.dart b/lib/models/tab_item.dart index 1e4b2e8..be36f63 100644 --- a/lib/models/tab_item.dart +++ b/lib/models/tab_item.dart @@ -1,3 +1,6 @@ +import 'tab_item_purchase.dart'; +import '../utils/collection_utils.dart'; + class TabItem { final String id; final String tabId; @@ -5,6 +8,7 @@ class TabItem { final String productName; final int quantity; final int unitPriceInCents; + final List purchases; const TabItem({ required this.id, @@ -13,6 +17,7 @@ class TabItem { required this.productName, required this.quantity, required this.unitPriceInCents, + this.purchases = const [], }); int get lineTotalInCents => quantity * unitPriceInCents; @@ -34,15 +39,17 @@ class TabItem { productId == other.productId && productName == other.productName && quantity == other.quantity && - unitPriceInCents == other.unitPriceInCents; + unitPriceInCents == other.unitPriceInCents && + listEquals(purchases, other.purchases); @override int get hashCode => Object.hash( - id, - tabId, - productId, - productName, - quantity, - unitPriceInCents, - ); + id, + tabId, + productId, + productName, + quantity, + unitPriceInCents, + Object.hashAll(purchases), + ); } diff --git a/lib/models/tab_item_purchase.dart b/lib/models/tab_item_purchase.dart new file mode 100644 index 0000000..f1a39b4 --- /dev/null +++ b/lib/models/tab_item_purchase.dart @@ -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); +} diff --git a/lib/services/bar_tab_service.dart b/lib/services/bar_tab_service.dart index 344868b..d7ca62f 100644 --- a/lib/services/bar_tab_service.dart +++ b/lib/services/bar_tab_service.dart @@ -8,10 +8,11 @@ import '../models/closed_tab_item.dart'; import '../models/payment_method.dart'; import '../models/product.dart'; import '../models/tab_item.dart'; +import '../models/tab_item_purchase.dart'; import 'product_service.dart'; abstract class BarTabService { - Future> getOpenTabs(); + Future> getOpenTabs({bool includeRemoved = false}); Future getTabById(String id); @@ -58,7 +59,16 @@ class DriftBarTabService implements BarTabService { 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 purchases) { return TabItem( id: row.id, tabId: row.tabId, @@ -66,6 +76,7 @@ class DriftBarTabService implements BarTabService { productName: row.productName, quantity: row.quantity, unitPriceInCents: row.unitPriceInCents, + purchases: purchases, ); } @@ -88,6 +99,8 @@ class DriftBarTabService implements BarTabService { productName: row.productName, quantity: row.quantity, unitPriceInCents: row.unitPriceInCents, + purchasedAt: row.purchasedAt, + removedAt: row.removedAt, ); } @@ -98,7 +111,97 @@ class DriftBarTabService implements BarTabService { final rows = await query.get(); - return rows.map(_mapItemRow).toList(); + final items = []; + + for (final row in rows) { + if (row.quantity <= 0) continue; + + final purchases = await _getPurchaseRecords(row); + items.add(_mapItemRow(row, purchases)); + } + + return items; + } + + Future> _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 = []; + + for (final row in rows) { + final purchases = await _getPurchaseRecords(row); + if (purchases.isEmpty) continue; + + items.add(_mapItemRow(row, purchases)); + } + + return items; + } + + Future> _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> _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> _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 _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 _decreaseProductStock(String productId, int amount) async { @@ -150,7 +253,7 @@ class DriftBarTabService implements BarTabService { } @override - Future> getOpenTabs() async { + Future> getOpenTabs({bool includeRemoved = false}) async { final query = database.select(database.barTabs) ..where((tab) => tab.status.equals('open')) ..orderBy([(tab) => OrderingTerm.desc(tab.openedAt)]); @@ -160,7 +263,9 @@ class DriftBarTabService implements BarTabService { final tabs = []; 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)); } @@ -286,15 +391,18 @@ class DriftBarTabService implements BarTabService { await updateQuery.write( TabItemsCompanion(quantity: Value(existingItem.quantity + 1)), ); + await _recordPurchase(tabItemId: existingItem.id); return; } + final tabItemId = _uuid.v4(); + await database .into(database.tabItems) .insert( TabItemsCompanion.insert( - id: _uuid.v4(), + id: tabItemId, tabId: tabId, productId: product.id, productName: product.name, @@ -303,6 +411,8 @@ class DriftBarTabService implements BarTabService { createdAt: DateTime.now(), ), ); + + await _recordPurchase(tabItemId: tabItemId); }); } @@ -316,24 +426,47 @@ class DriftBarTabService implements BarTabService { database.tabItems, )..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 actualDelta = requestedQuantity <= 0 ? -item.quantity : delta; if (actualDelta > 0) { await _decreaseProductStock(item.productId, actualDelta); + + for (var index = 0; index < actualDelta; index++) { + await _recordPurchase(tabItemId: item.id); + } } else { 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) { - final deletedRows = await (database.delete( - database.tabItems, - )..where((row) => row.id.equals(tabItemId))).go(); + final updatedRows = + await (database.update(database.tabItems) + ..where((row) => row.id.equals(tabItemId))) + .write(const TabItemsCompanion(quantity: Value(0))); - if (deletedRows != 1) { - throw StateError('Tab item was changed before it could be deleted'); + if (updatedRows != 1) { + throw StateError( + 'Tab item was changed before its quantity was updated', + ); } } else { final updatedRows = @@ -367,7 +500,7 @@ class DriftBarTabService implements BarTabService { return; } - final items = await _getItemsForTab(tabId); + final items = await _getItemsForHistory(tabId); if (items.isEmpty) { return; @@ -388,18 +521,22 @@ class DriftBarTabService implements BarTabService { ); for (final item in items) { - await database - .into(database.closedTabItems) - .insert( - ClosedTabItemsCompanion.insert( - id: _uuid.v4(), - closedTabId: closedTabId, - productId: item.productId, - productName: item.productName, - quantity: item.quantity, - unitPriceInCents: item.unitPriceInCents, - ), - ); + for (final purchase in item.purchases) { + await database + .into(database.closedTabItems) + .insert( + ClosedTabItemsCompanion.insert( + id: _uuid.v4(), + closedTabId: closedTabId, + productId: item.productId, + productName: item.productName, + quantity: 1, + unitPriceInCents: item.unitPriceInCents, + purchasedAt: Value(purchase.purchasedAt), + removedAt: Value(purchase.removedAt), + ), + ); + } } final deleteQuery = database.delete(database.tabItems) @@ -437,7 +574,11 @@ class DriftBarTabService implements BarTabService { for (final row in closedTabRows) { 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(); diff --git a/lib/services/export_service.dart b/lib/services/export_service.dart index 551d68f..2df9b62 100644 --- a/lib/services/export_service.dart +++ b/lib/services/export_service.dart @@ -49,19 +49,27 @@ class DefaultExportService implements ExportService { final itemRows = await itemsQuery.get(); - result.add(_ClosedTabExport( - id: tabRow.id, - originalTabId: tabRow.originalTabId, - customerName: tabRow.customerName, - closedAt: tabRow.closedAt.toIso8601String(), - paymentMethod: tabRow.paymentMethod, - items: itemRows.map((row) => _ClosedTabItemExport( - productId: row.productId, - productName: row.productName, - quantity: row.quantity, - unitPriceInCents: row.unitPriceInCents, - )).toList(), - )); + result.add( + _ClosedTabExport( + id: tabRow.id, + originalTabId: tabRow.originalTabId, + customerName: tabRow.customerName, + closedAt: tabRow.closedAt.toIso8601String(), + paymentMethod: tabRow.paymentMethod, + items: itemRows + .map( + (row) => _ClosedTabItemExport( + productId: row.productId, + productName: row.productName, + quantity: row.quantity, + unitPriceInCents: row.unitPriceInCents, + purchasedAt: row.purchasedAt?.toIso8601String(), + removedAt: row.removedAt?.toIso8601String(), + ), + ) + .toList(), + ), + ); } return result; @@ -71,22 +79,39 @@ class DefaultExportService implements ExportService { return { 'exportedAt': DateTime.now().toIso8601String(), 'appVersion': '1.0.7', - 'closedTabs': tabs.map((tab) => { - 'id': tab.id, - 'originalTabId': tab.originalTabId, - 'customerName': tab.customerName, - 'closedAt': tab.closedAt, - 'paymentMethod': tab.paymentMethod, - 'items': tab.items.map((item) => { - 'productId': item.productId, - 'productName': item.productName, - 'quantity': item.quantity, - 'unitPriceInCents': item.unitPriceInCents, - 'lineTotalInCents': item.quantity * item.unitPriceInCents, - }).toList(), - 'itemCount': tab.items.fold(0, (sum, item) => sum + item.quantity), - 'totalInCents': tab.items.fold(0, (sum, item) => sum + item.quantity * item.unitPriceInCents), - }).toList(), + 'closedTabs': tabs + .map( + (tab) => { + 'id': tab.id, + 'originalTabId': tab.originalTabId, + 'customerName': tab.customerName, + 'closedAt': tab.closedAt, + 'paymentMethod': tab.paymentMethod, + 'items': tab.items + .map( + (item) => { + 'productId': item.productId, + 'productName': item.productName, + 'quantity': item.quantity, + 'unitPriceInCents': item.unitPriceInCents, + '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 int quantity; final int unitPriceInCents; + final String? purchasedAt; + final String? removedAt; _ClosedTabItemExport({ required this.productId, required this.productName, required this.quantity, required this.unitPriceInCents, + required this.purchasedAt, + required this.removedAt, }); + + bool get isRemoved => removedAt != null; + + int get lineTotalInCents => isRemoved ? 0 : quantity * unitPriceInCents; } class _ClosedTabExport { @@ -121,4 +154,4 @@ class _ClosedTabExport { required this.paymentMethod, required this.items, }); -} \ No newline at end of file +} diff --git a/lib/utils/history_grouping.dart b/lib/utils/history_grouping.dart new file mode 100644 index 0000000..77b9d7b --- /dev/null +++ b/lib/utils/history_grouping.dart @@ -0,0 +1,48 @@ +import '../models/closed_tab_item.dart'; + +class ClosedTabItemDayGroup { + final DateTime day; + final List items; + + const ClosedTabItemDayGroup({required this.day, required this.items}); + + int get itemCount => items.fold( + 0, + (total, item) => total + (item.isRemoved ? 0 : item.quantity), + ); +} + +List groupClosedTabItemsByDay( + Iterable items, { + required DateTime fallbackDate, +}) { + final groups = >{}; + + 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(); +} diff --git a/lib/viewmodels/dev_menu_view_model.dart b/lib/viewmodels/dev_menu_view_model.dart index 67af232..3a8ffb6 100644 --- a/lib/viewmodels/dev_menu_view_model.dart +++ b/lib/viewmodels/dev_menu_view_model.dart @@ -1,3 +1,4 @@ +import 'dart:math'; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -194,16 +195,18 @@ class DevMenuViewModel extends ChangeNotifier { final (name, priceInCents, stock) = demoProducts[i]; final category = categories[i ~/ 5]; - await database.into(database.products).insert( - ProductsCompanion.insert( - id: uuid.v4(), - name: name, - category: category, - stockQuantity: stock, - lowStockThreshold: 10, - priceInCents: priceInCents, - ), - ); + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: uuid.v4(), + name: name, + category: category, + stockQuantity: stock, + lowStockThreshold: 10, + priceInCents: priceInCents, + ), + ); } _lastAction = 'Seeded ${demoProducts.length} demo products'; @@ -241,10 +244,7 @@ class DevMenuViewModel extends ChangeNotifier { final randomProducts = products.take(3).toList(); for (final product in randomProducts) { - await barTabService.addProductToTab( - tabId: tab.id, - product: product, - ); + await barTabService.addProductToTab(tabId: tab.id, product: product); } _lastAction = 'Created test tab with ${randomProducts.length} items'; @@ -262,16 +262,18 @@ class DevMenuViewModel extends ChangeNotifier { final uuid = const Uuid(); for (var i = 0; i < count; i++) { - await database.into(database.products).insert( - ProductsCompanion.insert( - id: uuid.v4(), - name: 'Test Product $i', - category: 'Test Category ${i % 5}', - stockQuantity: 50 + (i % 50), - lowStockThreshold: 5, - priceInCents: 100 + (i * 10), - ), - ); + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: uuid.v4(), + name: 'Test Product $i', + category: 'Test Category ${i % 5}', + stockQuantity: 50 + (i % 50), + lowStockThreshold: 5, + priceInCents: 100 + (i * 10), + ), + ); } _lastAction = 'Added $count test products'; @@ -341,59 +343,130 @@ class DevMenuViewModel extends ChangeNotifier { } final uuid = const Uuid(); + final random = Random(); + final today = DateTime.now(); + final startOfToday = DateTime(today.year, today.month, today.day); final customerNames = [ - 'Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', - 'Grace', 'Hank', 'Ivy', 'Jack', 'Kate', 'Leo', - 'Mia', 'Noah', 'Olivia', 'Pete', 'Quinn', 'Rose', - 'Sam', 'Tina', 'Umar', 'Vera', 'Wes', 'Xena', - 'Yves', 'Zara', + 'Alice', + 'Bob', + 'Charlie', + 'Diana', + '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]; + final usedCustomers = {}; await database.transaction(() async { 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 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( - days: i % 90, - hours: i % 24, - minutes: i % 60, + days: spanDays, + hours: random.nextInt(4), + minutes: random.nextInt(60), ), ); + final purchaseWindowMinutes = closedAt + .difference(firstPurchaseAt) + .inMinutes; - await database.into(database.closedTabs).insert( - ClosedTabsCompanion.insert( - id: closedTabId, - originalTabId: uuid.v4(), - customerName: customer, - closedAt: closedAt, - paymentMethod: Value(paymentMethods[(i * 3) % paymentMethods.length].value), - ), - ); + await database + .into(database.closedTabs) + .insert( + ClosedTabsCompanion.insert( + id: closedTabId, + originalTabId: uuid.v4(), + customerName: customer, + closedAt: closedAt, + paymentMethod: Value( + paymentMethods[random.nextInt(paymentMethods.length)].value, + ), + ), + ); - for (var j = 0; j < itemCount; j++) { - final product = products[(i + j) % products.length]; - final quantity = 1 + ((i * 7 + j * 13) % 6); + for (var j = 0; j < distinctProductCount; j++) { + final product = shuffledProducts[j]; + final quantity = 1 + random.nextInt(4); - await database.into(database.closedTabItems).insert( - ClosedTabItemsCompanion.insert( - id: uuid.v4(), - closedTabId: closedTabId, - productId: product.id, - productName: product.name, - quantity: quantity, - unitPriceInCents: product.priceInCents, - ), - ); + for (var k = 0; k < quantity; k++) { + var purchasedAt = firstPurchaseAt.add( + Duration(minutes: random.nextInt(purchaseWindowMinutes + 1)), + ); + + if (spanDays > 0 && j == 0 && k == 0) { + purchasedAt = firstPurchaseAt; + } 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 ' - '${customerNames.length} customers'; + _lastAction = + 'Generated $count mock orders across ' + '${usedCustomers.length} customers'; } finally { _isLoading = false; notifyListeners(); diff --git a/lib/viewmodels/history_view_model.dart b/lib/viewmodels/history_view_model.dart index 1db9596..b6f3a50 100644 --- a/lib/viewmodels/history_view_model.dart +++ b/lib/viewmodels/history_view_model.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; +import '../models/bar_tab.dart'; import '../models/closed_tab.dart'; import '../services/bar_tab_service.dart'; @@ -11,6 +12,7 @@ class HistoryViewModel extends ChangeNotifier { static const int _pageSize = 20; List _closedTabs = []; + List _openTabs = []; List _customerNames = []; String? _selectedCustomer; int _offset = 0; @@ -22,14 +24,14 @@ class HistoryViewModel extends ChangeNotifier { String _searchQuery = ''; List get closedTabs { - if (_searchQuery.isEmpty) return _closedTabs; - return _closedTabs - .where( - (tab) => tab.customerName.toLowerCase().contains( - _searchQuery.toLowerCase(), - ), - ) + .where((tab) => _matchesCustomer(tab.customerName)) + .toList(); + } + + List get openTabs { + return _openTabs + .where((tab) => _matchesCustomer(tab.customerName)) .toList(); } @@ -63,15 +65,19 @@ class HistoryViewModel extends ChangeNotifier { try { final results = await Future.wait([ _fetchPage(offset: 0), - barTabService.getClosedTabCount( - customerName: _selectedCustomer, - ), + barTabService.getClosedTabCount(customerName: _selectedCustomer), barTabService.getDistinctCustomerNames(), + barTabService.getOpenTabs(includeRemoved: true), ]); - _closedTabs = results[0] as List; + _closedTabs = (results[0] as List).toList() + ..sort(_compareClosedTabs); _totalCount = results[1] as int; - _customerNames = results[2] as List; + _openTabs = (results[3] as List).toList()..sort(_compareOpenTabs); + _customerNames = { + ...(results[2] as List), + ..._openTabs.map((tab) => tab.customerName), + }.toList()..sort(); _offset = _closedTabs.length; } catch (e) { debugPrint('HistoryViewModel: load error: $e'); @@ -91,7 +97,7 @@ class HistoryViewModel extends ChangeNotifier { try { final more = await _fetchPage(offset: _offset); - _closedTabs = [..._closedTabs, ...more]; + _closedTabs = [..._closedTabs, ...more]..sort(_compareClosedTabs); _offset = _closedTabs.length; } catch (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) { _searchQuery = query; notifyListeners(); } + bool _matchesCustomer(String customerName) { + if (_selectedCustomer != null && customerName != _selectedCustomer) { + return false; + } + + if (_searchQuery.isEmpty) return true; + + return customerName.toLowerCase().contains(_searchQuery.toLowerCase()); + } + Future filterByCustomer(String? customerName) async { _selectedCustomer = customerName; _searchQuery = ''; @@ -123,4 +153,4 @@ class HistoryViewModel extends ChangeNotifier { await load(); } -} \ No newline at end of file +} diff --git a/lib/views/history_screen_view.dart b/lib/views/history_screen_view.dart index f82361d..22c2934 100644 --- a/lib/views/history_screen_view.dart +++ b/lib/views/history_screen_view.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.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 '../app/router.dart'; +import '../models/bar_tab.dart'; +import '../models/closed_tab.dart'; import '../utils/navigation.dart'; import '../viewmodels/history_view_model.dart'; import '../l10n/app_localizations.dart'; @@ -134,33 +137,15 @@ class _HistoryScreenViewState extends State with RouteAware { ), ), Expanded( - child: viewModel.closedTabs.isEmpty + child: + viewModel.openTabs.isEmpty && viewModel.closedTabs.isEmpty ? _EmptyState() - : ListView.separated( - controller: _scrollController, - padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), - itemCount: - viewModel.closedTabs.length + - (viewModel.hasMore || viewModel.isLoadingMore - ? 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); - }, + : _HistoryList( + openTabs: viewModel.openTabs, + closedTabs: viewModel.closedTabs, + hasMore: viewModel.hasMore, + isLoadingMore: viewModel.isLoadingMore, + scrollController: _scrollController, ), ), ], @@ -171,6 +156,85 @@ class _HistoryScreenViewState extends State with RouteAware { } } +class _HistoryList extends StatelessWidget { + final List openTabs; + final List 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 { static const _allSentinel = r'$__all__$'; @@ -194,95 +258,134 @@ class _CustomerDropdown extends StatelessWidget { onSelected: (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)), color: Theme.of(context).cardTheme.color ?? scheme.surface, itemBuilder: (context) => [ PopupMenuItem( value: _allSentinel, - child: Row( - children: [ - Icon( - isFiltered ? Icons.people_outline : Icons.people_rounded, - size: 18, - 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, - ), - ), - ], + height: 50, + child: _CustomerMenuRow( + icon: Icons.people_outline_rounded, + label: l10n.allCustomers, + selected: !isFiltered, ), ), if (customerNames.isNotEmpty) const PopupMenuDivider(height: 1), ...customerNames.map( (name) => PopupMenuItem( value: name, - child: Row( - children: [ - Icon( - name == selectedCustomer - ? Icons.person_rounded - : Icons.person_outline_rounded, - size: 18, - ), - const SizedBox(width: 10), - Expanded(child: Text(name, overflow: TextOverflow.ellipsis)), - ], + height: 50, + child: _CustomerMenuRow( + icon: name == selectedCustomer + ? Icons.person_rounded + : Icons.person_outline_rounded, + label: name, + selected: name == selectedCustomer, ), ), ), ], - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all(color: scheme.onSurface.withValues(alpha: 0.12)), - color: isFiltered ? scheme.primary.withValues(alpha: 0.08) : null, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.person_rounded, - size: 18, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 144, maxWidth: 220), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( color: isFiltered - ? scheme.primary - : scheme.onSurface.withValues(alpha: 0.5), + ? scheme.primary.withValues(alpha: 0.35) + : scheme.onSurface.withValues(alpha: 0.12), ), - const SizedBox(width: 6), - Flexible( - child: Text( - selectedCustomer ?? l10n.customer, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 13, - color: isFiltered - ? scheme.primary - : scheme.onSurface.withValues(alpha: 0.5), + color: isFiltered ? scheme.primary.withValues(alpha: 0.08) : null, + ), + child: Row( + children: [ + Icon( + isFiltered + ? Icons.filter_alt_rounded + : Icons.people_outline_rounded, + size: 18, + color: isFiltered + ? 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), - Icon( - Icons.arrow_drop_down_rounded, - size: 18, - color: isFiltered - ? scheme.primary - : scheme.onSurface.withValues(alpha: 0.5), - ), - ], + const SizedBox(width: 4), + Icon( + Icons.keyboard_arrow_down_rounded, + size: 18, + color: isFiltered + ? scheme.primary + : 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 { @override Widget build(BuildContext context) { diff --git a/lib/views/widgets/closed_tab_card.dart b/lib/views/widgets/closed_tab_card.dart index 9c56999..05481cf 100644 --- a/lib/views/widgets/closed_tab_card.dart +++ b/lib/views/widgets/closed_tab_card.dart @@ -5,6 +5,7 @@ import 'package:kooltab2/models/closed_tab_item.dart'; import 'package:kooltab2/models/payment_method.dart'; import '../../l10n/app_localizations.dart'; +import '../../utils/history_grouping.dart'; class ClosedTabCard extends StatefulWidget { final ClosedTab closedTab; @@ -31,6 +32,10 @@ class _ClosedTabCardState extends State { final l10n = AppLocalizations.of(context); final locale = Localizations.localeOf(context).toLanguageTag(); final dateFormat = DateFormat.yMMMd(locale).add_jm(); + final dayGroups = groupClosedTabItemsByDay( + closedTab.items, + fallbackDate: closedTab.closedAt, + ); final scheme = Theme.of(context).colorScheme; return Container( @@ -40,17 +45,16 @@ class _ClosedTabCardState extends State { border: Border.all(color: scheme.onSurface.withValues(alpha: 0.06)), ), 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( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => setState(() => _expanded = !_expanded), + enableFeedback: true, + splashFactory: NoSplash.splashFactory, + child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 14, @@ -137,27 +141,163 @@ class _ClosedTabCardState extends State { ], ), ), - AnimatedCrossFade( - duration: const Duration(milliseconds: 150), - crossFadeState: _expanded - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), - child: Column( + ), + ), + 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), + 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: [ - const Divider(height: 1), - const SizedBox(height: 8), - ...closedTab.items.map( - (item) => _ClosedTabItemRow(item: item), + Container( + width: 30, + height: 30, + 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 { class _ClosedTabItemRow extends StatelessWidget { final ClosedTabItem item; + final DateTime purchasedAt; - const _ClosedTabItemRow({required this.item}); + const _ClosedTabItemRow({required this.item, required this.purchasedAt}); @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); final locale = Localizations.localeOf(context).toLanguageTag(); + final timeFormat = DateFormat.jm(locale); final unitPrice = NumberFormat.simpleCurrency( locale: locale, ).format(item.unitPriceInCents / 100); @@ -185,32 +328,57 @@ class _ClosedTabItemRow extends StatelessWidget { child: Row( children: [ Expanded( - child: Text( - item.productName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w600, - color: scheme.onSurface, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.productName, + maxLines: 1, + 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( - '${item.quantity} × $unitPrice', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(width: 12), - SizedBox( - width: 64, - child: Text( - lineTotal, - textAlign: TextAlign.end, + if (item.isRemoved) + Text( + l10n.removed, style: TextStyle( + color: scheme.error, + fontSize: 12, 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, + ), ), ), - ), + ], ], ), ); diff --git a/lib/views/widgets/open_tab_history_card.dart b/lib/views/widgets/open_tab_history_card.dart new file mode 100644 index 0000000..c3fc47e --- /dev/null +++ b/lib/views/widgets/open_tab_history_card.dart @@ -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 createState() => _OpenTabHistoryCardState(); +} + +class _OpenTabHistoryCardState extends State { + bool _expanded = false; + + List<_OpenPurchase> _purchases(BarTab tab) { + final purchases = <_OpenPurchase>[]; + + for (final item in tab.items) { + final records = item.purchases.isEmpty + ? List.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, + ), + ], + ), + ); + } +} diff --git a/test/bar_tab_service_test.dart b/test/bar_tab_service_test.dart index 5e9a646..faf5ae7 100644 --- a/test/bar_tab_service_test.dart +++ b/test/bar_tab_service_test.dart @@ -196,6 +196,29 @@ void main() { 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 { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(priceInCents: 450); @@ -251,7 +274,7 @@ void main() { 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 product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); @@ -282,6 +305,32 @@ void main() { 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( 'applies concurrent deltas without losing stock consistency', () async { @@ -320,10 +369,38 @@ void main() { final closedTabs = await service.getClosedTabs(); expect(closedTabs.length, 1); expect(closedTabs.first.customerName, 'John'); - expect(closedTabs.first.items.length, 1); - expect(closedTabs.first.items.first.quantity, 2); + expect(closedTabs.first.items.length, 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 { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); diff --git a/test/dev_menu_view_model_test.dart b/test/dev_menu_view_model_test.dart new file mode 100644 index 0000000..f8aeee9 --- /dev/null +++ b/test/dev_menu_view_model_test.dart @@ -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 '), + ); + }); +} diff --git a/test/history_grouping_test.dart b/test/history_grouping_test.dart new file mode 100644 index 0000000..ec4ff8a --- /dev/null +++ b/test/history_grouping_test.dart @@ -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, + ); +} diff --git a/test/history_view_model_test.dart b/test/history_view_model_test.dart new file mode 100644 index 0000000..1f72dc9 --- /dev/null +++ b/test/history_view_model_test.dart @@ -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 [], +);