feat: add more detailed history and group per day
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
+12
-11
@@ -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);
|
||||
@@ -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
|
||||
// _listEquals moved to utils/collection_utils.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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 = <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(
|
||||
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();
|
||||
|
||||
@@ -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 = '';
|
||||
@@ -123,4 +153,4 @@ class HistoryViewModel extends ChangeNotifier {
|
||||
|
||||
await load();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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<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
|
||||
? 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) {
|
||||
|
||||
@@ -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(
|
||||
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<ClosedTabCard> {
|
||||
],
|
||||
),
|
||||
),
|
||||
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<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,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 '),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 [],
|
||||
);
|
||||
Reference in New Issue
Block a user