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