import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; import '../models/payment_method.dart'; import '../models/remote_command.dart'; part 'app_database.g.dart'; class PaymentMethodConverter extends TypeConverter { const PaymentMethodConverter(); @override PaymentMethod fromSql(String fromDb) => PaymentMethod.fromValue(fromDb); @override String toSql(PaymentMethod value) => value.value; } @DataClassName('ProductRow') class Products extends Table { TextColumn get id => text()(); TextColumn get name => text()(); TextColumn get category => text()(); IntColumn get stockQuantity => integer()(); IntColumn get lowStockThreshold => integer()(); /// Products like tap water that are never counted; their stock is ignored. BoolColumn get infiniteStock => boolean().withDefault(const Constant(false))(); IntColumn get priceInCents => integer()(); TextColumn get imagePath => text().nullable()(); BoolColumn get active => boolean().withDefault(const Constant(true))(); @override Set get primaryKey => {id}; } @DataClassName('BarTabRow') class BarTabs extends Table { TextColumn get id => text()(); TextColumn get customerName => text()(); DateTimeColumn get openedAt => dateTime()(); IntColumn get version => integer().withDefault(const Constant(0))(); @override Set get primaryKey => {id}; } @DataClassName('TabItemRow') class TabItems extends Table { TextColumn get id => text()(); TextColumn get tabId => text().references(BarTabs, #id, onDelete: KeyAction.cascade)(); TextColumn get productId => text().references(Products, #id)(); TextColumn get productName => text()(); IntColumn get quantity => integer()(); IntColumn get unitPriceInCents => integer()(); DateTimeColumn get createdAt => dateTime()(); @override Set get primaryKey => {id}; } @DataClassName('TabItemPurchaseRow') class TabItemPurchases extends Table { TextColumn get id => text()(); TextColumn get tabItemId => text().references(TabItems, #id, onDelete: KeyAction.cascade)(); DateTimeColumn get purchasedAt => dateTime()(); DateTimeColumn get removedAt => dateTime().nullable()(); @override Set get primaryKey => {id}; } @DataClassName('ClosedTabsRow') class ClosedTabs extends Table { TextColumn get id => text()(); TextColumn get originalTabId => text()(); TextColumn get customerName => text()(); DateTimeColumn get closedAt => dateTime()(); TextColumn get paymentMethod => text() .map(const PaymentMethodConverter()) .withDefault(const Constant('cash'))(); @override Set get primaryKey => {id}; } @DataClassName('ClosedTabItemRow') class ClosedTabItems extends Table { TextColumn get id => text()(); TextColumn get closedTabId => text()(); TextColumn get productId => text()(); TextColumn get productName => text()(); IntColumn get quantity => integer()(); IntColumn get unitPriceInCents => integer()(); DateTimeColumn get purchasedAt => dateTime().nullable()(); DateTimeColumn get removedAt => dateTime().nullable()(); @override Set get primaryKey => {id}; } @DataClassName('AppSettingsRow') class AppSettingsTable extends Table { @override String get tableName => 'app_settings'; TextColumn get id => text()(); BoolColumn get pinRequired => boolean().withDefault(const Constant(false))(); BoolColumn get autoLockEnabled => boolean().withDefault(const Constant(true))(); TextColumn get themeMode => text().withDefault(const Constant('system'))(); TextColumn get language => text().withDefault(const Constant('system'))(); IntColumn get barGridRows => integer().withDefault(const Constant(3))(); @override Set get primaryKey => {id}; } @DataClassName('RemoteSyncStateRow') class RemoteSyncState extends Table { TextColumn get deviceId => text()(); TextColumn get serverCursor => text().nullable()(); DateTimeColumn get lastSuccessfulSyncAt => dateTime().nullable()(); DateTimeColumn get lastAttemptedSyncAt => dateTime().nullable()(); IntColumn get consecutiveFailureCount => integer().withDefault(const Constant(0))(); @override Set get primaryKey => {deviceId}; } @DataClassName('ProcessedRemoteCommandRow') class ProcessedRemoteCommands extends Table { TextColumn get commandId => text()(); TextColumn get commandType => text()(); DateTimeColumn get processedAt => dateTime()(); TextColumn get result => textEnum()(); @override Set get primaryKey => {commandId}; } @DriftDatabase( tables: [ Products, BarTabs, TabItems, TabItemPurchases, ClosedTabs, ClosedTabItems, AppSettingsTable, RemoteSyncState, ProcessedRemoteCommands, ], ) class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override int get schemaVersion => 14; @override MigrationStrategy get migration { return MigrationStrategy( onCreate: (migrator) async { await migrator.createAll(); }, onUpgrade: (migrator, from, to) async { Future hasTableColumn(String tableName, String columnName) async { final columns = await migrator.database .customSelect('PRAGMA table_info($tableName)') .get(); return columns.any((column) => column.data['name'] == columnName); } Future hasSettingsColumn(String columnName) { return hasTableColumn('app_settings', columnName); } if (from < 2) { await migrator.createTable(barTabs); await migrator.createTable(tabItems); } if (from < 3) { await migrator.addColumn(products, products.imagePath); } if (from < 4) { await migrator.createTable(closedTabs); await migrator.createTable(closedTabItems); } if (from < 5) { await migrator.createTable(appSettingsTable); } if (from < 6 && !await hasTableColumn('closed_tabs', 'payment_method')) { await migrator.addColumn(closedTabs, closedTabs.paymentMethod); } if (from < 7 && !await hasSettingsColumn('language')) { await migrator.addColumn(appSettingsTable, appSettingsTable.language); } if (from < 8 && !await hasSettingsColumn('bar_grid_rows')) { await migrator.addColumn( appSettingsTable, appSettingsTable.barGridRows, ); } if (from < 9 && !await hasSettingsColumn('auto_lock_enabled')) { await migrator.addColumn( appSettingsTable, 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); } if (from < 12) { if (!await hasTableColumn('bar_tabs', 'version')) { await migrator.addColumn(barTabs, barTabs.version); } await migrator.createTable(remoteSyncState); await migrator.createTable(processedRemoteCommands); } if (from < 13) { // Tabs never close (only their items are archived), so the status // and closed_at columns were never anything but 'open' and null. if (await hasTableColumn('bar_tabs', 'status')) { await migrator.alterTable(TableMigration(barTabs)); } await _backfillPurchaseRows(); await _deleteOrphanedPurchaseRows(); } if (from < 14 && !await hasTableColumn('products', 'infinite_stock')) { await migrator.addColumn(products, products.infiniteStock); } }, ); } /// Items added before purchases were tracked have fewer active purchase /// rows than their quantity. Insert the missing ones once, here, so the /// services can rely on `quantity == active purchases`. Future _backfillPurchaseRows() async { const uuid = Uuid(); final rows = await customSelect( 'SELECT i.id, i.quantity, i.created_at, ' '(SELECT COUNT(*) FROM tab_item_purchases p ' 'WHERE p.tab_item_id = i.id AND p.removed_at IS NULL) AS active_count ' 'FROM tab_items i', readsFrom: {tabItems, tabItemPurchases}, ).get(); for (final row in rows) { final missing = row.read('quantity') - row.read('active_count'); if (missing <= 0) continue; final itemId = row.read('id'); final createdAt = row.read('created_at'); for (var index = 0; index < missing; index++) { await into(tabItemPurchases).insert( TabItemPurchasesCompanion.insert( id: uuid.v4(), tabItemId: itemId, purchasedAt: createdAt, ), ); } } } /// Foreign keys aren't enforced, so deleting tab items used to leave their /// purchase rows behind. Future _deleteOrphanedPurchaseRows() async { await customStatement( 'DELETE FROM tab_item_purchases ' 'WHERE tab_item_id NOT IN (SELECT id FROM tab_items)', ); } static QueryExecutor _openConnection() { return driftDatabase( name: 'kooltap', native: const DriftNativeOptions( databaseDirectory: getApplicationSupportDirectory, ), ); } }