feat: add more detailed history and group per day

This commit is contained in:
2026-08-07 05:07:41 +02:00
parent 1dd7e69aa8
commit 799c1c4045
23 changed files with 2506 additions and 346 deletions
+45 -4
View File
@@ -63,6 +63,21 @@ class TabItems extends Table {
Set<Column> get primaryKey => {id};
}
@DataClassName('TabItemPurchaseRow')
class TabItemPurchases extends Table {
TextColumn get id => text()();
TextColumn get tabItemId =>
text().references(TabItems, #id, onDelete: KeyAction.cascade)();
DateTimeColumn get purchasedAt => dateTime()();
DateTimeColumn get removedAt => dateTime().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@DataClassName('ClosedTabsRow')
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<Column> 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<bool> hasSettingsColumn(String columnName) async {
Future<bool> 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<bool> 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);
}
},
);
}
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -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 didnt 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",
+14 -2
View File
@@ -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.
+8 -2
View File
@@ -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';
+8 -2
View File
@@ -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';
+4 -2
View File
@@ -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",
+4 -3
View File
@@ -9,7 +9,7 @@ class ClosedTab {
final String originalTabId;
final String customerName;
final DateTime closedAt;
final PaymentMethod paymentMethod;
final PaymentMethod paymentMethod;
final List<ClosedTabItem> 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);
+12 -2
View File
@@ -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,7 +32,9 @@ 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(
@@ -36,5 +44,7 @@ class ClosedTabItem {
productName,
quantity,
unitPriceInCents,
purchasedAt,
removedAt,
);
}
+8 -1
View File
@@ -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<TabItemPurchase> 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,7 +39,8 @@ 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(
@@ -44,5 +50,6 @@ class TabItem {
productName,
quantity,
unitPriceInCents,
Object.hashAll(purchases),
);
}
+27
View File
@@ -0,0 +1,27 @@
class TabItemPurchase {
final String id;
final String tabItemId;
final DateTime purchasedAt;
final DateTime? removedAt;
const TabItemPurchase({
required this.id,
required this.tabItemId,
required this.purchasedAt,
this.removedAt,
});
bool get isRemoved => removedAt != null;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TabItemPurchase &&
id == other.id &&
tabItemId == other.tabItemId &&
purchasedAt == other.purchasedAt &&
removedAt == other.removedAt;
@override
int get hashCode => Object.hash(id, tabItemId, purchasedAt, removedAt);
}
+156 -15
View File
@@ -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<List<BarTab>> getOpenTabs();
Future<List<BarTab>> getOpenTabs({bool includeRemoved = false});
Future<BarTab?> 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<TabItemPurchase> 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 = <TabItem>[];
for (final row in rows) {
if (row.quantity <= 0) continue;
final purchases = await _getPurchaseRecords(row);
items.add(_mapItemRow(row, purchases));
}
return items;
}
Future<List<TabItem>> _getItemsForHistory(String tabId) async {
final query = database.select(database.tabItems)
..where((item) => item.tabId.equals(tabId))
..orderBy([(item) => OrderingTerm.asc(item.createdAt)]);
final rows = await query.get();
final items = <TabItem>[];
for (final row in rows) {
final purchases = await _getPurchaseRecords(row);
if (purchases.isEmpty) continue;
items.add(_mapItemRow(row, purchases));
}
return items;
}
Future<List<TabItemPurchaseRow>> _getPurchaseRows(String tabItemId) async {
final query = database.select(database.tabItemPurchases)
..where((purchase) => purchase.tabItemId.equals(tabItemId))
..orderBy([
(purchase) => OrderingTerm.asc(purchase.purchasedAt),
(purchase) => OrderingTerm.asc(purchase.id),
]);
return query.get();
}
Future<List<TabItemPurchase>> _getPurchaseRecords(TabItemRow item) async {
final rows = await _getPurchaseRows(item.id);
final purchases = rows.map(_mapPurchaseRow).toList();
final activeCount = purchases
.where((purchase) => !purchase.isRemoved)
.length;
final missingActivePurchases = item.quantity - activeCount;
if (missingActivePurchases <= 0) return purchases;
return [
...purchases,
for (var index = 0; index < missingActivePurchases; index++)
TabItemPurchase(
id: 'legacy-${item.id}-$index',
tabItemId: item.id,
purchasedAt: item.createdAt,
),
];
}
Future<List<TabItemPurchaseRow>> _ensurePurchaseRows(TabItemRow item) async {
final rows = await _getPurchaseRows(item.id);
final activeCount = rows
.where((purchase) => purchase.removedAt == null)
.length;
final missingActivePurchases = item.quantity - activeCount;
for (var index = 0; index < missingActivePurchases; index++) {
await _recordPurchase(tabItemId: item.id, purchasedAt: item.createdAt);
}
if (missingActivePurchases <= 0) return rows;
return _getPurchaseRows(item.id);
}
Future<void> _recordPurchase({
required String tabItemId,
DateTime? purchasedAt,
}) async {
await database
.into(database.tabItemPurchases)
.insert(
TabItemPurchasesCompanion.insert(
id: _uuid.v4(),
tabItemId: tabItemId,
purchasedAt: purchasedAt ?? DateTime.now(),
),
);
}
Future<void> _decreaseProductStock(String productId, int amount) async {
@@ -150,7 +253,7 @@ class DriftBarTabService implements BarTabService {
}
@override
Future<List<BarTab>> getOpenTabs() async {
Future<List<BarTab>> 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 = <BarTab>[];
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,6 +521,7 @@ class DriftBarTabService implements BarTabService {
);
for (final item in items) {
for (final purchase in item.purchases) {
await database
.into(database.closedTabItems)
.insert(
@@ -396,11 +530,14 @@ class DriftBarTabService implements BarTabService {
closedTabId: closedTabId,
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
quantity: 1,
unitPriceInCents: item.unitPriceInCents,
purchasedAt: Value(purchase.purchasedAt),
removedAt: Value(purchase.removedAt),
),
);
}
}
final deleteQuery = database.delete(database.tabItems)
..where((item) => item.tabId.equals(tabId));
@@ -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();
+44 -11
View File
@@ -49,19 +49,27 @@ class DefaultExportService implements ExportService {
final itemRows = await itemsQuery.get();
result.add(_ClosedTabExport(
result.add(
_ClosedTabExport(
id: tabRow.id,
originalTabId: tabRow.originalTabId,
customerName: tabRow.customerName,
closedAt: tabRow.closedAt.toIso8601String(),
paymentMethod: tabRow.paymentMethod,
items: itemRows.map((row) => _ClosedTabItemExport(
items: itemRows
.map(
(row) => _ClosedTabItemExport(
productId: row.productId,
productName: row.productName,
quantity: row.quantity,
unitPriceInCents: row.unitPriceInCents,
)).toList(),
));
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) => {
'closedTabs': tabs
.map(
(tab) => {
'id': tab.id,
'originalTabId': tab.originalTabId,
'customerName': tab.customerName,
'closedAt': tab.closedAt,
'paymentMethod': tab.paymentMethod,
'items': tab.items.map((item) => {
'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(),
'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 {
+48
View File
@@ -0,0 +1,48 @@
import '../models/closed_tab_item.dart';
class ClosedTabItemDayGroup {
final DateTime day;
final List<ClosedTabItem> items;
const ClosedTabItemDayGroup({required this.day, required this.items});
int get itemCount => items.fold(
0,
(total, item) => total + (item.isRemoved ? 0 : item.quantity),
);
}
List<ClosedTabItemDayGroup> groupClosedTabItemsByDay(
Iterable<ClosedTabItem> items, {
required DateTime fallbackDate,
}) {
final groups = <DateTime, List<ClosedTabItem>>{};
for (final item in items) {
final localPurchasedAt = (item.purchasedAt ?? fallbackDate).toLocal();
final day = DateTime(
localPurchasedAt.year,
localPurchasedAt.month,
localPurchasedAt.day,
);
groups.putIfAbsent(day, () => []).add(item);
}
for (final group in groups.values) {
group.sort((a, b) {
final aTime = (a.purchasedAt ?? fallbackDate).toLocal();
final bTime = (b.purchasedAt ?? fallbackDate).toLocal();
final byTime = bTime.compareTo(aTime);
if (byTime != 0) return byTime;
return b.id.compareTo(a.id);
});
}
final sortedGroups = groups.entries.toList()
..sort((a, b) => b.key.compareTo(a.key));
return sortedGroups
.map((entry) => ClosedTabItemDayGroup(day: entry.key, items: entry.value))
.toList();
}
+101 -28
View File
@@ -1,3 +1,4 @@
import 'dart:math';
import 'dart:io';
import 'package:flutter/foundation.dart';
@@ -194,7 +195,9 @@ class DevMenuViewModel extends ChangeNotifier {
final (name, priceInCents, stock) = demoProducts[i];
final category = categories[i ~/ 5];
await database.into(database.products).insert(
await database
.into(database.products)
.insert(
ProductsCompanion.insert(
id: uuid.v4(),
name: name,
@@ -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,7 +262,9 @@ class DevMenuViewModel extends ChangeNotifier {
final uuid = const Uuid();
for (var i = 0; i < count; i++) {
await database.into(database.products).insert(
await database
.into(database.products)
.insert(
ProductsCompanion.insert(
id: uuid.v4(),
name: 'Test Product $i',
@@ -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 = <String>{};
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(
Duration(
days: i % 90,
hours: i % 24,
minutes: i % 60,
),
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),
);
await database.into(database.closedTabs).insert(
// 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: 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),
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(
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: quantity,
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();
+43 -13
View File
@@ -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<ClosedTab> _closedTabs = [];
List<BarTab> _openTabs = [];
List<String> _customerNames = [];
String? _selectedCustomer;
int _offset = 0;
@@ -22,14 +24,14 @@ class HistoryViewModel extends ChangeNotifier {
String _searchQuery = '';
List<ClosedTab> get closedTabs {
if (_searchQuery.isEmpty) return _closedTabs;
return _closedTabs
.where(
(tab) => tab.customerName.toLowerCase().contains(
_searchQuery.toLowerCase(),
),
)
.where((tab) => _matchesCustomer(tab.customerName))
.toList();
}
List<BarTab> 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<ClosedTab>;
_closedTabs = (results[0] as List<ClosedTab>).toList()
..sort(_compareClosedTabs);
_totalCount = results[1] as int;
_customerNames = results[2] as List<String>;
_openTabs = (results[3] as List<BarTab>).toList()..sort(_compareOpenTabs);
_customerNames = {
...(results[2] as List<String>),
..._openTabs.map((tab) => tab.customerName),
}.toList()..sort();
_offset = _closedTabs.length;
} 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<void> filterByCustomer(String? customerName) async {
_selectedCustomer = customerName;
_searchQuery = '';
+165 -62
View File
@@ -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<HistoryScreenView> 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<HistoryScreenView> with RouteAware {
}
}
class _HistoryList extends StatelessWidget {
final List<BarTab> openTabs;
final List<ClosedTab> closedTabs;
final bool hasMore;
final bool isLoadingMore;
final ScrollController scrollController;
const _HistoryList({
required this.openTabs,
required this.closedTabs,
required this.hasMore,
required this.isLoadingMore,
required this.scrollController,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final openSectionCount = openTabs.isEmpty ? 0 : openTabs.length + 1;
final closedSectionCount = closedTabs.isEmpty ? 0 : closedTabs.length + 1;
final loadingCount = hasMore || isLoadingMore ? 1 : 0;
return ListView.separated(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
itemCount: openSectionCount + closedSectionCount + loadingCount,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
if (openTabs.isNotEmpty) {
if (index == 0) {
return _HistorySectionHeader(title: l10n.openTabs);
}
if (index <= openTabs.length) {
return OpenTabHistoryCard(tab: openTabs[index - 1]);
}
}
final closedIndex = index - openSectionCount;
if (closedTabs.isNotEmpty) {
if (closedIndex == 0) {
return _HistorySectionHeader(title: l10n.closedTabs);
}
if (closedIndex <= closedTabs.length) {
return ClosedTabCard(closedTab: closedTabs[closedIndex - 1]);
}
}
return const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator(strokeWidth: 2.5)),
);
},
);
}
}
class _HistorySectionHeader extends StatelessWidget {
final String title;
const _HistorySectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 2),
child: Text(
title.toUpperCase(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.7,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
);
}
}
class _CustomerDropdown extends StatelessWidget {
static const _allSentinel = r'$__all__$';
@@ -194,83 +258,77 @@ 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<String>(
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<String>(
value: name,
child: Row(
children: [
Icon(
name == selectedCustomer
height: 50,
child: _CustomerMenuRow(
icon: name == selectedCustomer
? Icons.person_rounded
: Icons.person_outline_rounded,
size: 18,
),
const SizedBox(width: 10),
Expanded(child: Text(name, overflow: TextOverflow.ellipsis)),
],
label: name,
selected: name == selectedCustomer,
),
),
),
],
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 144, maxWidth: 220),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: scheme.onSurface.withValues(alpha: 0.12)),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isFiltered
? scheme.primary.withValues(alpha: 0.35)
: 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,
isFiltered
? Icons.filter_alt_rounded
: Icons.people_outline_rounded,
size: 18,
color: isFiltered
? scheme.primary
: scheme.onSurface.withValues(alpha: 0.5),
),
const SizedBox(width: 6),
Flexible(
const SizedBox(width: 8),
Expanded(
child: Text(
selectedCustomer ?? l10n.customer,
selectedCustomer ?? l10n.allCustomers,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: isFiltered
? scheme.primary
: scheme.onSurface.withValues(alpha: 0.5),
: scheme.onSurface.withValues(alpha: 0.65),
),
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_drop_down_rounded,
Icons.keyboard_arrow_down_rounded,
size: 18,
color: isFiltered
? scheme.primary
@@ -279,6 +337,51 @@ class _CustomerDropdown extends StatelessWidget {
],
),
),
),
);
}
}
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),
],
],
);
}
}
+179 -11
View File
@@ -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<ClosedTabCard> {
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<ClosedTabCard> {
border: Border.all(color: scheme.onSurface.withValues(alpha: 0.06)),
),
clipBehavior: Clip.antiAlias,
child: Material(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Material(
color: Colors.transparent,
child: InkWell(
onTap: () => setState(() => _expanded = !_expanded),
enableFeedback: true,
splashFactory: NoSplash.splashFactory,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
@@ -137,6 +141,8 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
],
),
),
),
),
AnimatedCrossFade(
duration: const Duration(milliseconds: 150),
crossFadeState: _expanded
@@ -148,8 +154,15 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
children: [
const Divider(height: 1),
const SizedBox(height: 8),
...closedTab.items.map(
(item) => _ClosedTabItemRow(item: item),
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,
),
],
),
@@ -158,6 +171,133 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
),
],
),
);
}
}
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: [
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),
),
],
),
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
child: _expanded
? Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Column(
children: [
Divider(
height: 1,
color: scheme.primary.withValues(alpha: 0.18),
),
for (final item in widget.group.items)
_ClosedTabItemRow(
item: item,
purchasedAt:
item.purchasedAt ?? widget.fallbackDate,
),
],
),
)
: const SizedBox.shrink(),
),
],
),
),
);
@@ -166,12 +306,15 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
class _ClosedTabItemRow extends StatelessWidget {
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,16 +328,40 @@ class _ClosedTabItemRow extends StatelessWidget {
child: Row(
children: [
Expanded(
child: Text(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.productName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
color: scheme.onSurface,
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,
),
],
),
),
if (item.isRemoved)
Text(
l10n.removed,
style: TextStyle(
color: scheme.error,
fontSize: 12,
fontWeight: FontWeight.w700,
),
)
else ...[
Text(
'${item.quantity} × $unitPrice',
style: Theme.of(context).textTheme.bodySmall,
@@ -212,6 +379,7 @@ class _ClosedTabItemRow extends StatelessWidget {
),
),
],
],
),
);
}
@@ -0,0 +1,256 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../l10n/app_localizations.dart';
import '../../models/bar_tab.dart';
import '../../models/tab_item_purchase.dart';
class OpenTabHistoryCard extends StatefulWidget {
final BarTab tab;
const OpenTabHistoryCard({super.key, required this.tab});
@override
State<OpenTabHistoryCard> createState() => _OpenTabHistoryCardState();
}
class _OpenTabHistoryCardState extends State<OpenTabHistoryCard> {
bool _expanded = false;
List<_OpenPurchase> _purchases(BarTab tab) {
final purchases = <_OpenPurchase>[];
for (final item in tab.items) {
final records = item.purchases.isEmpty
? List<TabItemPurchase>.generate(
item.quantity,
(index) => TabItemPurchase(
id: '${item.id}-$index',
tabItemId: item.id,
purchasedAt: tab.openedAt,
),
)
: item.purchases;
purchases.addAll(
records.map(
(purchase) => _OpenPurchase(
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
purchasedAt: purchase.purchasedAt,
isRemoved: purchase.isRemoved,
),
),
);
}
purchases.sort((a, b) {
final byTime = b.purchasedAt.compareTo(a.purchasedAt);
if (byTime != 0) return byTime;
return b.productName.compareTo(a.productName);
});
return purchases;
}
@override
Widget build(BuildContext context) {
final tab = widget.tab;
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final dateFormat = DateFormat.yMMMd(locale).add_jm();
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: scheme.primary.withValues(alpha: 0.2)),
),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => setState(() => _expanded = !_expanded),
enableFeedback: true,
splashFactory: NoSplash.splashFactory,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
tab.customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: scheme.onSurface,
),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
l10n.openTabs,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: scheme.primary,
),
),
),
],
),
const SizedBox(height: 2),
Text(
'${dateFormat.format(tab.openedAt)} · ${l10n.tabItemCount(tab.itemCount)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const SizedBox(width: 8),
Text(
tab.formattedTotal,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: scheme.onSurface,
),
),
const SizedBox(width: 4),
Icon(
_expanded
? Icons.expand_less_rounded
: Icons.expand_more_rounded,
color: scheme.onSurface.withValues(alpha: 0.5),
),
],
),
),
AnimatedCrossFade(
duration: const Duration(milliseconds: 150),
crossFadeState: _expanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: Column(
children: [
const Divider(height: 1),
const SizedBox(height: 8),
..._purchases(
tab,
).map((purchase) => _OpenPurchaseRow(purchase: purchase)),
],
),
),
secondChild: const SizedBox(width: double.infinity),
),
],
),
),
),
);
}
}
class _OpenPurchase {
final String productName;
final int unitPriceInCents;
final DateTime purchasedAt;
final bool isRemoved;
const _OpenPurchase({
required this.productName,
required this.unitPriceInCents,
required this.purchasedAt,
required this.isRemoved,
});
}
class _OpenPurchaseRow extends StatelessWidget {
final _OpenPurchase purchase;
const _OpenPurchaseRow({required this.purchase});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final dateFormat = DateFormat.yMMMd(locale).add_jm();
final unitPrice = NumberFormat.simpleCurrency(
locale: locale,
).format(purchase.unitPriceInCents / 100);
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
purchase.productName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
color: purchase.isRemoved
? scheme.onSurface.withValues(alpha: 0.5)
: scheme.onSurface,
decoration: purchase.isRemoved
? TextDecoration.lineThrough
: null,
),
),
Text(
dateFormat.format(purchase.purchasedAt),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
if (purchase.isRemoved)
Text(
l10n.removed,
style: TextStyle(
color: scheme.error,
fontSize: 12,
fontWeight: FontWeight.w700,
),
)
else
Text(
'1 × $unitPrice',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}
+80 -3
View File
@@ -196,6 +196,29 @@ void main() {
expect(updatedTab.items.first.quantity, 3);
});
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();
+78
View File
@@ -0,0 +1,78 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/database/app_database.dart';
import 'package:kooltab2/models/product.dart';
import 'package:kooltab2/services/bar_tab_service.dart';
import 'package:kooltab2/services/pin_lock_service.dart';
import 'package:kooltab2/services/product_service.dart';
import 'package:kooltab2/services/settings_service.dart';
import 'package:kooltab2/viewmodels/dev_menu_view_model.dart';
import 'package:mocktail/mocktail.dart';
class MockBarTabService extends Mock implements BarTabService {}
class MockPinLockService extends Mock implements PinLockService {}
class MockProductService extends Mock implements ProductService {}
class MockSettingsService extends Mock implements SettingsService {}
void main() {
late AppDatabase database;
late MockProductService productService;
late DevMenuViewModel viewModel;
setUp(() {
database = AppDatabase(NativeDatabase.memory());
productService = MockProductService();
when(() => productService.getProducts()).thenAnswer(
(_) async => const [
Product(
id: 'product-1',
name: 'Cola',
category: 'Drinks',
stockQuantity: 100,
lowStockThreshold: 10,
priceInCents: 250,
),
Product(
id: 'product-2',
name: 'Chips',
category: 'Snacks',
stockQuantity: 100,
lowStockThreshold: 10,
priceInCents: 150,
),
],
);
viewModel = DevMenuViewModel(
database: database,
productService: productService,
barTabService: MockBarTabService(),
settingsService: MockSettingsService(),
pinLockService: MockPinLockService(),
);
});
tearDown(() async {
await database.close();
});
test('generates individual timestamped mock history items', () async {
await viewModel.generateMockOrders(count: 12);
final tabs = await database.select(database.closedTabs).get();
final items = await database.select(database.closedTabItems).get();
expect(tabs, hasLength(12));
expect(items, isNotEmpty);
expect(items.every((item) => item.quantity == 1), isTrue);
expect(items.every((item) => item.purchasedAt != null), isTrue);
expect(
viewModel.lastAction,
startsWith('Generated 12 mock orders across '),
);
});
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/models/closed_tab_item.dart';
import 'package:kooltab2/utils/history_grouping.dart';
void main() {
test('groups items by purchase day with newest day first', () {
final items = [
_closedItem('older', DateTime(2026, 8, 1, 22)),
_closedItem('newer-b', DateTime(2026, 8, 3, 18)),
_closedItem('newer-a', DateTime(2026, 8, 3, 10)),
];
final groups = groupClosedTabItemsByDay(
items,
fallbackDate: DateTime(2026, 8, 4),
);
expect(groups, hasLength(2));
expect(groups.first.day, DateTime(2026, 8, 3));
expect(groups.first.items.map((item) => item.id), ['newer-b', 'newer-a']);
expect(groups.last.day, DateTime(2026, 8, 1));
});
}
ClosedTabItem _closedItem(String id, DateTime purchasedAt) {
return ClosedTabItem(
id: id,
closedTabId: 'closed-tab',
productId: 'product-$id',
productName: id,
quantity: 1,
unitPriceInCents: 100,
purchasedAt: purchasedAt,
);
}
+108
View File
@@ -0,0 +1,108 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/models/bar_tab.dart';
import 'package:kooltab2/models/closed_tab.dart';
import 'package:kooltab2/models/payment_method.dart';
import 'package:kooltab2/services/bar_tab_service.dart';
import 'package:kooltab2/viewmodels/history_view_model.dart';
import 'package:mocktail/mocktail.dart';
class MockBarTabService extends Mock implements BarTabService {}
void main() {
late MockBarTabService service;
late HistoryViewModel viewModel;
setUp(() {
service = MockBarTabService();
viewModel = HistoryViewModel(barTabService: service);
when(
() => service.getClosedTabsPaginated(
limit: 20,
offset: 0,
customerName: null,
),
).thenAnswer((_) async => []);
when(
() => service.getClosedTabCount(customerName: null),
).thenAnswer((_) async => 0);
when(() => service.getDistinctCustomerNames()).thenAnswer((_) async => []);
when(
() => service.getOpenTabs(includeRemoved: true),
).thenAnswer((_) async => [_openTab]);
});
test('loads open tabs for the top of history', () async {
await viewModel.load();
expect(viewModel.openTabs, hasLength(1));
expect(viewModel.openTabs.first.customerName, 'Alice');
expect(viewModel.customerNames, ['Alice']);
});
test('search filters open tabs as well as closed tabs', () async {
await viewModel.load();
viewModel.search('bob');
expect(viewModel.openTabs, isEmpty);
});
test('sorts closed and open tabs newest first', () async {
final olderClosedTab = _closedTab('closed-old', DateTime(2026, 8, 1));
final newerClosedTab = _closedTab('closed-new', DateTime(2026, 8, 3));
final olderOpenTab = _barTab('open-old', DateTime(2026, 8, 1));
final newerOpenTab = _barTab('open-new', DateTime(2026, 8, 3));
when(
() => service.getClosedTabsPaginated(
limit: 20,
offset: 0,
customerName: null,
),
).thenAnswer((_) async => [olderClosedTab, newerClosedTab]);
when(
() => service.getClosedTabCount(customerName: null),
).thenAnswer((_) async => 2);
when(
() => service.getOpenTabs(includeRemoved: true),
).thenAnswer((_) async => [olderOpenTab, newerOpenTab]);
await viewModel.load();
expect(viewModel.closedTabs.map((tab) => tab.id), [
'closed-new',
'closed-old',
]);
expect(viewModel.openTabs.map((tab) => tab.id), ['open-new', 'open-old']);
});
}
ClosedTab _closedTab(String id, DateTime closedAt) {
return ClosedTab(
id: id,
originalTabId: id,
customerName: id,
closedAt: closedAt,
paymentMethod: PaymentMethod.cash,
items: const [],
);
}
BarTab _barTab(String id, DateTime openedAt) {
return BarTab(
id: id,
customerName: id,
status: 'open',
openedAt: openedAt,
items: const [],
);
}
final _openTab = BarTab(
id: 'tab-1',
customerName: 'Alice',
status: 'open',
openedAt: DateTime(2026, 8, 2, 12),
items: const [],
);