/// Local SQLite store of products and shopping trips. library; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; import '../models/app_overview.dart'; import '../models/line_item.dart'; import '../models/product.dart'; import '../models/purchase.dart'; import '../models/shop.dart'; import '../models/shopping_list.dart'; import '../models/supermarket.dart'; import '../utils/money.dart'; import '../utils/product_names.dart'; import '../utils/supermarkets.dart'; class ProductRepository extends ChangeNotifier { ProductRepository({this.databasePath}); final String? databasePath; Future? _opening; Future get _database { _opening ??= _open(); return _opening!; } Future _defaultDatabasePath() async { final dir = (await getApplicationDocumentsDirectory()).path; final groci = p.join(dir, 'groci.db'); final legacy = p.join(dir, 'receipity.db'); if (!File(groci).existsSync() && File(legacy).existsSync()) { await File(legacy).rename(groci); } return groci; } Future _open() async { final path = databasePath ?? await _defaultDatabasePath(); return openDatabase( path, version: 9, onConfigure: (db) async { await db.execute('PRAGMA foreign_keys = ON'); }, onCreate: (db, version) async { await _createProducts(db); await _createShopTables(db); await _createSettingsTables(db); await _createShoppingListTables(db); await _seedSupermarkets(db); }, onUpgrade: (db, oldVersion, newVersion) async { if (oldVersion < 2) { await db.execute( 'ALTER TABLE products ADD COLUMN favourite INTEGER NOT NULL DEFAULT 0', ); await db.execute('ALTER TABLE products ADD COLUMN image_path TEXT'); await db.execute('ALTER TABLE products ADD COLUMN supermarket TEXT'); await _createShopTables(db); } if (oldVersion < 3) { await db.execute('ALTER TABLE products ADD COLUMN notes TEXT'); await _createSettingsTables(db); await _seedSupermarkets(db); } if (oldVersion < 4) { await db.execute('ALTER TABLE products ADD COLUMN category TEXT'); } if (oldVersion < 5) { await _createShoppingListTables(db); } if (oldVersion < 6) { await _upgradeShopsToV6(db); } if (oldVersion < 7) { await _upgradeShopItemsProductFk(db); } if (oldVersion < 8) { await db.execute( 'ALTER TABLE supermarkets ADD COLUMN bonus_image_path TEXT', ); } if (oldVersion < 9) { await _upgradeProductNormalizedNamesV9(db); } }, ); } Future _createProducts(Database db) async { await db.execute(''' CREATE TABLE products ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, normalized_name TEXT NOT NULL UNIQUE, last_price REAL NOT NULL, times_seen INTEGER NOT NULL DEFAULT 1, favourite INTEGER NOT NULL DEFAULT 0, image_path TEXT, supermarket TEXT, notes TEXT, category TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) '''); } Future _createSettingsTables(DatabaseExecutor db) async { await db.execute(''' CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ) '''); await db.execute(''' CREATE TABLE IF NOT EXISTS supermarkets ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, color INTEGER NOT NULL, sort_order INTEGER NOT NULL, bonus_image_path TEXT ) '''); } Future _seedSupermarkets(DatabaseExecutor db) async { final existing = await db.query('supermarkets', limit: 1); if (existing.isNotEmpty) return; for (final store in kDefaultSupermarkets) { await db.insert('supermarkets', { 'name': store.name, 'color': store.colorValue, 'sort_order': store.sortOrder, }); } } Future _createShopTables(DatabaseExecutor db) async { await db.execute(''' CREATE TABLE shops ( id INTEGER PRIMARY KEY AUTOINCREMENT, supermarket TEXT NOT NULL, name TEXT, shopped_at TEXT NOT NULL, receipt_image_path TEXT, total REAL NOT NULL, created_at TEXT NOT NULL ) '''); await db.execute(''' CREATE TABLE shop_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, shop_id INTEGER NOT NULL, product_id INTEGER, name TEXT NOT NULL, price REAL NOT NULL, quantity INTEGER, unit_price REAL, FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE, FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE SET NULL ) '''); await _createShopTagTable(db); } Future _createShopTagTable(DatabaseExecutor db) async { await db.execute(''' CREATE TABLE IF NOT EXISTS shop_tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, shop_id INTEGER NOT NULL, tag TEXT NOT NULL, normalized_tag TEXT NOT NULL, FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE, UNIQUE (shop_id, normalized_tag) ) '''); } Future _upgradeShopsToV6(DatabaseExecutor db) async { final columns = await db.rawQuery('PRAGMA table_info(shops)'); final names = {for (final row in columns) row['name'] as String}; if (!names.contains('name')) { await db.execute('ALTER TABLE shops ADD COLUMN name TEXT'); } await _createShopTagTable(db); } Future _upgradeShopItemsProductFk(DatabaseExecutor db) async { await db.execute(''' CREATE TABLE shop_items_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, shop_id INTEGER NOT NULL, product_id INTEGER, name TEXT NOT NULL, price REAL NOT NULL, quantity INTEGER, unit_price REAL, FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE, FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE SET NULL ) '''); await db.execute(''' INSERT INTO shop_items_new ( id, shop_id, product_id, name, price, quantity, unit_price ) SELECT id, shop_id, product_id, name, price, quantity, unit_price FROM shop_items '''); await db.execute('DROP TABLE shop_items'); await db.execute('ALTER TABLE shop_items_new RENAME TO shop_items'); } Future _createShoppingListTables(DatabaseExecutor db) async { await db.execute(''' CREATE TABLE IF NOT EXISTS shopping_lists ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) '''); await db.execute(''' CREATE TABLE IF NOT EXISTS shopping_list_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, list_id INTEGER NOT NULL, product_id INTEGER, name TEXT NOT NULL, checked INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (list_id) REFERENCES shopping_lists(id) ON DELETE CASCADE, FOREIGN KEY (product_id) REFERENCES products(id) ) '''); } static String normalizeName(String name, {String? supermarket}) => productConflictKey(name: name, supermarket: supermarket); Future _upgradeProductNormalizedNamesV9(DatabaseExecutor db) async { final rows = await db.query('products', columns: ['id', 'name', 'supermarket']); final used = {}; for (final row in rows) { final id = row['id']! as int; final name = row['name']! as String; final supermarket = row['supermarket'] as String?; var key = normalizeName(name, supermarket: supermarket); if (!used.add(key)) { key = '$key#$id'; } await db.update( 'products', {'normalized_name': key}, where: 'id = ?', whereArgs: [id], ); } } Future _persistImage(String sourcePath, String relativePath) async { final dir = await getApplicationDocumentsDirectory(); final dest = File(p.join(dir.path, relativePath)); await dest.parent.create(recursive: true); if (p.normalize(sourcePath) == p.normalize(dest.path)) { return dest.path; } await File(sourcePath).copy(dest.path); return dest.path; } /// Saves a shopping trip and upserts each line into the product catalog. Future saveShop({ required String supermarket, required DateTime shoppedAt, required List items, String? receiptImagePath, String? name, List tags = const [], }) async { final db = await _database; final now = DateTime.now().toIso8601String(); final total = roundMoney( items.fold(0, (sum, item) => sum + item.price), ); final tripName = _optionalText(name); final shopId = await db.transaction((txn) async { final id = await txn.insert('shops', { 'supermarket': supermarket, 'name': tripName, 'shopped_at': shoppedAt.toIso8601String(), 'total': total, 'created_at': now, }); await _replaceShopTags(txn, shopId: id, tags: tags); for (final item in items) { final productId = await _upsertProduct( txn, item: item, supermarket: supermarket, now: now, ); await txn.insert('shop_items', { 'shop_id': id, 'product_id': productId, 'name': item.name.trim(), 'price': item.price, 'quantity': item.quantity, 'unit_price': item.unitPrice, }); } return id; }); if (receiptImagePath != null && receiptImagePath.isNotEmpty) { try { final stored = await _persistImage( receiptImagePath, 'receipts/shop_$shopId.jpg', ); await db.update( 'shops', {'receipt_image_path': stored}, where: 'id = ?', whereArgs: [shopId], ); } catch (_) { // Trip is still saved if the photo cannot be copied. } } notifyListeners(); return shopId; } Future _upsertProduct( Transaction txn, { required LineItem item, required String supermarket, required String now, }) async { final key = normalizeName(item.name, supermarket: supermarket); final existing = await txn.query( 'products', where: 'normalized_name = ?', whereArgs: [key], limit: 1, ); if (existing.isEmpty) { return txn.insert('products', { 'name': item.name.trim(), 'normalized_name': key, 'last_price': item.unitPrice ?? item.price, 'times_seen': 1, 'favourite': 0, 'supermarket': supermarket, 'created_at': now, 'updated_at': now, }); } final row = existing.first; final values = { 'last_price': item.unitPrice ?? item.price, 'times_seen': (row['times_seen']! as int) + 1, 'updated_at': now, }; if ((row['supermarket'] as String?) == null || (row['supermarket'] as String).isEmpty) { values['supermarket'] = supermarket; } await txn.update( 'products', values, where: 'id = ?', whereArgs: [row['id']], ); return row['id']! as int; } /// Kept for tests and older callers; saves items without a shop trip. Future upsertItems(List items) async { await saveShop( supermarket: 'Other', shoppedAt: DateTime.now(), items: items, ); } Future> getAll({ ProductSort sort = ProductSort.name, }) async { final db = await _database; final favouritesOnly = sort == ProductSort.favourite; final orderBy = switch (sort) { ProductSort.name => 'name COLLATE NOCASE ASC', ProductSort.preferred => 'times_seen DESC, name COLLATE NOCASE ASC', ProductSort.favourite => 'name COLLATE NOCASE ASC', }; final rows = await db.rawQuery(''' SELECT products.*, ( SELECT COALESCE(shop_items.unit_price, shop_items.price) FROM shop_items JOIN shops ON shops.id = shop_items.shop_id WHERE shop_items.product_id = products.id ORDER BY shops.shopped_at DESC, shop_items.id DESC LIMIT 1 ) AS display_price FROM products ${favouritesOnly ? 'WHERE favourite = 1' : ''} ORDER BY $orderBy '''); return rows.map(Product.fromMap).toList(); } Future> _shopLineNames() async { final db = await _database; final rows = await db.rawQuery(''' SELECT shop_items.product_id, shop_items.name, shops.supermarket FROM shop_items JOIN shops ON shops.id = shop_items.shop_id WHERE shop_items.product_id IS NOT NULL '''); return [ for (final row in rows) if (_readProductId(row['product_id']) case final int id when row['name'] is String && row['supermarket'] is String) ( productId: id, name: row['name']! as String, supermarket: row['supermarket']! as String, ), ]; } int? _readProductId(Object? value) { if (value is int) return value; if (value is num) return value.toInt(); return null; } Future getProductNameIssues() async { final products = await getAll(); return buildProductNameIssues( products: products, shopLines: await _shopLineNames(), dismissedKeys: await getDismissedNameIssues(), ); } Future> getDismissedNameIssues() async { final db = await _database; final rows = await db.query( 'app_settings', where: 'key = ?', whereArgs: ['dismissed_name_issues'], limit: 1, ); if (rows.isEmpty) return {}; final raw = rows.first['value'] as String?; if (raw == null || raw.isEmpty) return {}; try { final decoded = jsonDecode(raw) as List; return decoded.map((entry) => entry as String).toSet(); } catch (_) { return {}; } } Future dismissNameIssue(String key) async { final dismissed = await getDismissedNameIssues() ..add(key); final db = await _database; await db.insert('app_settings', { 'key': 'dismissed_name_issues', 'value': jsonEncode(dismissed.toList()..sort()), }, conflictAlgorithm: ConflictAlgorithm.replace); notifyListeners(); } Future>> getNameConflictGroups() async { return [ for (final group in (await getProductNameIssues()).mergeGroups) group.products, ]; } Future> getProductSpellingVariants() async { return (await getProductNameIssues()).spellingVariants; } /// Picks one spelling for a catalog product at a store. Future unifyProductSpelling({ required int productId, required String name, String? supermarket, }) async { final trimmed = name.trim(); final key = normalizeName(trimmed, supermarket: supermarket); final db = await _database; final now = DateTime.now().toIso8601String(); final storeKey = supermarket == null ? null : normalizeSupermarket(supermarket); await db.transaction((txn) async { await txn.update( 'products', {'name': trimmed, 'normalized_name': key, 'updated_at': now}, where: 'id = ?', whereArgs: [productId], ); final rows = await txn.rawQuery(''' SELECT shop_items.id, shop_items.name FROM shop_items JOIN shops ON shops.id = shop_items.shop_id WHERE shop_items.product_id = ? ${storeKey == null ? '' : 'AND upper(trim(shops.supermarket)) = ?'} ''', storeKey == null ? [productId] : [productId, storeKey]); for (final row in rows) { final lineName = row['name'] as String?; final lineId = row['id']; if (lineName == null || lineId == null) continue; if (normalizeName(lineName, supermarket: supermarket) != key) continue; await txn.update( 'shop_items', {'name': trimmed}, where: 'id = ?', whereArgs: [lineId], ); } }); notifyListeners(); } Future getOverview() async { final db = await _database; final productCount = Sqflite.firstIntValue( await db.rawQuery('SELECT COUNT(*) FROM products'), ) ?? 0; final favouriteCount = Sqflite.firstIntValue( await db.rawQuery( 'SELECT COUNT(*) FROM products WHERE favourite = 1', ), ) ?? 0; final tripCount = Sqflite.firstIntValue( await db.rawQuery('SELECT COUNT(*) FROM shops'), ) ?? 0; final listCount = Sqflite.firstIntValue( await db.rawQuery('SELECT COUNT(*) FROM shopping_lists'), ) ?? 0; final now = DateTime.now(); final monthStart = DateTime(now.year, now.month, 1).toIso8601String(); final monthRows = await db.rawQuery( ''' SELECT COUNT(*) AS trip_count, COALESCE(SUM(total), 0) AS month_total FROM shops WHERE shopped_at >= ? ''', [monthStart], ); final month = monthRows.first; final shops = await getShops(); return AppOverview( productCount: productCount, favouriteCount: favouriteCount, tripCount: tripCount, monthTripCount: (month['trip_count'] as num?)?.toInt() ?? 0, monthTotal: (month['month_total'] as num?)?.toDouble() ?? 0, listCount: listCount, recentShops: shops.take(3).toList(), ); } Future getProduct(int id) async { final db = await _database; final rows = await db.rawQuery( ''' SELECT products.*, ( SELECT COALESCE(shop_items.unit_price, shop_items.price) FROM shop_items JOIN shops ON shops.id = shop_items.shop_id WHERE shop_items.product_id = products.id ORDER BY shops.shopped_at DESC, shop_items.id DESC LIMIT 1 ) AS display_price FROM products WHERE products.id = ? LIMIT 1 ''', [id], ); if (rows.isEmpty) return null; return Product.fromMap(rows.first); } Future> getUsedCategories() async { final db = await _database; final rows = await db.rawQuery(''' SELECT DISTINCT category FROM products WHERE category IS NOT NULL AND TRIM(category) != '' ORDER BY category COLLATE NOCASE ASC '''); return [ for (final row in rows) if (row['category'] is String) row['category']! as String, ]; } Future updateProduct({ required int id, String? name, bool? favourite, String? supermarket, String? imagePath, String? notes, String? category, bool clearImage = false, bool clearSupermarket = false, bool clearNotes = false, bool clearCategory = false, }) async { final db = await _database; final existing = await db.query( 'products', where: 'id = ?', whereArgs: [id], limit: 1, ); if (existing.isEmpty) return; final row = existing.first; final nextName = name?.trim() ?? row['name']! as String; final nextSupermarket = clearSupermarket ? null : (supermarket ?? row['supermarket'] as String?); final values = { 'updated_at': DateTime.now().toIso8601String(), 'normalized_name': normalizeName( nextName, supermarket: nextSupermarket, ), }; if (name != null) values['name'] = nextName; if (favourite != null) values['favourite'] = favourite ? 1 : 0; if (clearSupermarket) { values['supermarket'] = null; } else if (supermarket != null) { values['supermarket'] = supermarket; } if (clearImage) { values['image_path'] = null; } else if (imagePath != null) { values['image_path'] = imagePath; } if (clearNotes) { values['notes'] = null; } else if (notes != null) { values['notes'] = notes; } if (clearCategory) { values['category'] = null; } else if (category != null) { values['category'] = category.trim(); } await db.update('products', values, where: 'id = ?', whereArgs: [id]); notifyListeners(); } Future setFavourite(int id, bool favourite) => updateProduct(id: id, favourite: favourite); Future setProductImage(int id, String sourcePath) async { final stored = await _persistImage(sourcePath, 'products/product_$id.jpg'); await updateProduct(id: id, imagePath: stored); return stored; } Future deleteProduct(int id) async { final db = await _database; final rows = await db.query( 'products', columns: ['image_path'], where: 'id = ?', whereArgs: [id], limit: 1, ); await db.transaction((txn) async { await txn.update( 'shop_items', {'product_id': null}, where: 'product_id = ?', whereArgs: [id], ); await txn.update( 'shopping_list_items', {'product_id': null}, where: 'product_id = ?', whereArgs: [id], ); await txn.delete('products', where: 'id = ?', whereArgs: [id]); }); if (rows.isNotEmpty) { await _tryDeleteFile(rows.first['image_path'] as String?); } notifyListeners(); } Future deleteAllProducts() async { final db = await _database; final rows = await db.query('products', columns: ['image_path']); await db.transaction((txn) async { await txn.update('shop_items', {'product_id': null}); await txn.update('shopping_list_items', {'product_id': null}); await txn.delete('products'); }); for (final row in rows) { await _tryDeleteFile(row['image_path'] as String?); } notifyListeners(); } /// Moves trip lines and list items onto [keepId], then deletes the rest. Future mergeProducts({ required int keepId, required List sourceIds, }) async { final mergeIds = [ for (final id in sourceIds) if (id != keepId) id, ]; if (mergeIds.isEmpty) return; final db = await _database; final keepRows = await db.query( 'products', where: 'id = ?', whereArgs: [keepId], limit: 1, ); if (keepRows.isEmpty) return; final keep = Product.fromMap(keepRows.first); final losers = []; for (final id in mergeIds) { final rows = await db.query( 'products', where: 'id = ?', whereArgs: [id], limit: 1, ); if (rows.isNotEmpty) losers.add(Product.fromMap(rows.first)); } if (losers.isEmpty) return; var imagePath = keep.imagePath; final unusedImages = []; for (final loser in losers) { final path = loser.imagePath; if ((imagePath == null || imagePath.isEmpty) && path != null && path.isNotEmpty) { imagePath = path; } else { unusedImages.add(path); } } var notes = keep.notes?.trim(); for (final loser in losers) { final extra = loser.notes?.trim(); if (extra == null || extra.isEmpty) continue; if (notes == null || notes.isEmpty) { notes = extra; } else if (notes != extra) { notes = '$notes\n$extra'; } } final favourite = keep.favourite || losers.any((item) => item.favourite); final supermarket = (keep.supermarket == null || keep.supermarket!.isEmpty) ? losers .map((item) => item.supermarket) .firstWhere( (value) => value != null && value.isNotEmpty, orElse: () => keep.supermarket, ) : keep.supermarket; final category = (keep.category == null || keep.category!.isEmpty) ? losers .map((item) => item.category) .firstWhere( (value) => value != null && value.isNotEmpty, orElse: () => keep.category, ) : keep.category; await db.transaction((txn) async { for (final id in mergeIds) { await txn.update( 'shop_items', {'product_id': keepId}, where: 'product_id = ?', whereArgs: [id], ); await txn.update( 'shopping_list_items', {'product_id': keepId}, where: 'product_id = ?', whereArgs: [id], ); await txn.delete('products', where: 'id = ?', whereArgs: [id]); } final seen = Sqflite.firstIntValue( await txn.rawQuery( 'SELECT COUNT(*) FROM shop_items WHERE product_id = ?', [keepId], ), ); final priceRows = await txn.rawQuery( ''' SELECT COALESCE(shop_items.unit_price, shop_items.price) AS price FROM shop_items JOIN shops ON shops.id = shop_items.shop_id WHERE shop_items.product_id = ? ORDER BY shops.shopped_at DESC, shop_items.id DESC LIMIT 1 ''', [keepId], ); final lastPrice = priceRows.isEmpty ? keep.lastPrice : (priceRows.first['price'] as num).toDouble(); await txn.update( 'products', { 'normalized_name': normalizeName( keep.name, supermarket: keep.supermarket, ), 'times_seen': seen ?? keep.timesSeen, 'last_price': lastPrice, 'favourite': favourite ? 1 : 0, 'image_path': imagePath, 'supermarket': supermarket, 'notes': (notes == null || notes.isEmpty) ? null : notes, 'category': category, 'updated_at': DateTime.now().toIso8601String(), }, where: 'id = ?', whereArgs: [keepId], ); }); for (final path in unusedImages) { await _tryDeleteFile(path); } notifyListeners(); } Future _tryDeleteFile(String? path) async { if (path == null || path.isEmpty) return; try { final file = File(path); if (await file.exists()) { await file.delete(); } } catch (_) {} } Future> getPurchases(int productId) async { final db = await _database; final rows = await db.rawQuery( ''' SELECT shop_items.shop_id, shop_items.price, shop_items.quantity, shop_items.unit_price, shops.shopped_at, shops.supermarket, shops.receipt_image_path FROM shop_items JOIN shops ON shops.id = shop_items.shop_id WHERE shop_items.product_id = ? ORDER BY shops.shopped_at DESC, shop_items.id DESC ''', [productId], ); return rows.map(Purchase.fromMap).toList(); } Future> getShops() async { final db = await _database; final rows = await db.rawQuery(''' SELECT shops.id, shops.supermarket, shops.name, shops.shopped_at, shops.receipt_image_path, shops.total, COUNT(shop_items.id) AS item_count FROM shops LEFT JOIN shop_items ON shop_items.shop_id = shops.id GROUP BY shops.id ORDER BY shops.shopped_at DESC, shops.id DESC '''); return _shopsWithTags(rows); } Future getShop(int id) async { final db = await _database; final rows = await db.rawQuery( ''' SELECT shops.id, shops.supermarket, shops.name, shops.shopped_at, shops.receipt_image_path, shops.total, COUNT(shop_items.id) AS item_count FROM shops LEFT JOIN shop_items ON shop_items.shop_id = shops.id WHERE shops.id = ? GROUP BY shops.id ''', [id], ); if (rows.isEmpty) return null; final shops = await _shopsWithTags(rows); return shops.first; } Future> _shopsWithTags(List> rows) async { final shops = rows.map(Shop.fromMap).toList(); if (shops.isEmpty) return shops; final tags = await _tagsByShopIds(shops.map((shop) => shop.id).toList()); return [ for (final shop in shops) shop.copyWith(tags: tags[shop.id] ?? const []), ]; } Future>> _tagsByShopIds(List ids) async { if (ids.isEmpty) return {}; final db = await _database; final placeholders = List.filled(ids.length, '?').join(','); final rows = await db.query( 'shop_tags', where: 'shop_id IN ($placeholders)', whereArgs: ids, orderBy: 'tag COLLATE NOCASE ASC', ); final tags = >{}; for (final row in rows) { final shopId = row['shop_id']! as int; (tags[shopId] ??= []).add(row['tag']! as String); } return tags; } Future>> getShopItemNamesByShop() async { final db = await _database; final rows = await db.query('shop_items', columns: ['shop_id', 'name']); final names = >{}; for (final row in rows) { (names[row['shop_id']! as int] ??= []).add(row['name']! as String); } return names; } Future> getUsedShopTags() async { final db = await _database; final rows = await db.rawQuery(''' SELECT tag FROM shop_tags GROUP BY normalized_tag ORDER BY tag COLLATE NOCASE ASC '''); return [for (final row in rows) row['tag']! as String]; } Future updateShop({ required int id, String? name, bool clearName = false, DateTime? shoppedAt, List? tags, }) async { final db = await _database; await db.transaction((txn) async { final values = {}; if (clearName || name != null) { values['name'] = clearName ? null : _optionalText(name); } if (shoppedAt != null) { values['shopped_at'] = shoppedAt.toIso8601String(); } if (values.isNotEmpty) { await txn.update('shops', values, where: 'id = ?', whereArgs: [id]); } if (tags != null) { await _replaceShopTags(txn, shopId: id, tags: tags); } }); notifyListeners(); } Future _replaceShopTags( DatabaseExecutor db, { required int shopId, required List tags, }) async { await db.delete('shop_tags', where: 'shop_id = ?', whereArgs: [shopId]); final seen = {}; for (final raw in tags) { final label = raw.trim().replaceAll(RegExp(r'\s+'), ' '); if (label.isEmpty) continue; final key = label.toLowerCase(); if (!seen.add(key)) continue; await db.insert('shop_tags', { 'shop_id': shopId, 'tag': label, 'normalized_tag': key, }); } } String? _optionalText(String? value) { final trimmed = value?.trim(); if (trimmed == null || trimmed.isEmpty) return null; return trimmed; } Future> getShopItems(int shopId) async { final db = await _database; final rows = await db.query( 'shop_items', where: 'shop_id = ?', whereArgs: [shopId], orderBy: 'id ASC', ); return rows.map(ShopItem.fromMap).toList(); } /// Removes a trip and its line items. Catalog products are left unchanged. Future deleteShop(int id) async { final shop = await getShop(id); final db = await _database; await db.transaction((txn) async { await txn.delete('shop_items', where: 'shop_id = ?', whereArgs: [id]); await txn.delete('shop_tags', where: 'shop_id = ?', whereArgs: [id]); await txn.delete('shops', where: 'id = ?', whereArgs: [id]); }); await _tryDeleteFile(shop?.receiptImagePath); notifyListeners(); } /// Adds a line to an existing trip and upserts the product catalog entry. Future addShopItem({ required int shopId, required LineItem item, }) async { final db = await _database; final shopRows = await db.query( 'shops', where: 'id = ?', whereArgs: [shopId], limit: 1, ); if (shopRows.isEmpty) throw StateError('Shop not found'); final supermarket = shopRows.first['supermarket']! as String; final now = DateTime.now().toIso8601String(); final itemId = await db.transaction((txn) async { final productId = await _upsertProduct( txn, item: item, supermarket: supermarket, now: now, ); return txn.insert('shop_items', { 'shop_id': shopId, 'product_id': productId, 'name': item.name.trim(), 'price': item.price, 'quantity': item.quantity, 'unit_price': item.unitPrice, }); }); await _recalculateShopTotal(shopId); notifyListeners(); final rows = await db.query( 'shop_items', where: 'id = ?', whereArgs: [itemId], limit: 1, ); return ShopItem.fromMap(rows.first); } /// Removes one trip line. Catalog products are left unchanged. Future deleteShopItem(int id) async { final db = await _database; final rows = await db.query( 'shop_items', columns: ['shop_id'], where: 'id = ?', whereArgs: [id], limit: 1, ); if (rows.isEmpty) return; final shopId = rows.first['shop_id']! as int; await db.delete('shop_items', where: 'id = ?', whereArgs: [id]); await _recalculateShopTotal(shopId); notifyListeners(); } Future updateShopItem({ required int id, String? name, double? price, int? quantity, double? unitPrice, bool clearQuantity = false, }) async { final db = await _database; final rows = await db.query( 'shop_items', where: 'id = ?', whereArgs: [id], limit: 1, ); if (rows.isEmpty) return; final shopId = rows.first['shop_id']! as int; final values = {}; if (name != null) values['name'] = name.trim(); if (price != null) values['price'] = price; if (clearQuantity) { values['quantity'] = null; values['unit_price'] = null; } else { if (quantity != null) values['quantity'] = quantity; if (unitPrice != null) values['unit_price'] = unitPrice; } if (values.isEmpty) return; await db.update('shop_items', values, where: 'id = ?', whereArgs: [id]); await _recalculateShopTotal(shopId); notifyListeners(); } Future _recalculateShopTotal(int shopId) async { final db = await _database; final rows = await db.rawQuery( 'SELECT COALESCE(SUM(price), 0) AS total FROM shop_items WHERE shop_id = ?', [shopId], ); final total = roundMoney((rows.first['total'] as num).toDouble()); await db.update( 'shops', {'total': total}, where: 'id = ?', whereArgs: [shopId], ); } Future> getShoppingLists() async { final db = await _database; final rows = await db.rawQuery(''' SELECT shopping_lists.id, shopping_lists.name, shopping_lists.created_at, shopping_lists.updated_at, COUNT(shopping_list_items.id) AS item_count, COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count FROM shopping_lists LEFT JOIN shopping_list_items ON shopping_list_items.list_id = shopping_lists.id GROUP BY shopping_lists.id ORDER BY shopping_lists.updated_at DESC, shopping_lists.id DESC '''); return rows.map(ShoppingList.fromMap).toList(); } Future getShoppingList(int id) async { final db = await _database; final rows = await db.rawQuery( ''' SELECT shopping_lists.id, shopping_lists.name, shopping_lists.created_at, shopping_lists.updated_at, COUNT(shopping_list_items.id) AS item_count, COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count FROM shopping_lists LEFT JOIN shopping_list_items ON shopping_list_items.list_id = shopping_lists.id WHERE shopping_lists.id = ? GROUP BY shopping_lists.id ''', [id], ); if (rows.isEmpty) return null; return ShoppingList.fromMap(rows.first); } Future createShoppingList(String name) async { final db = await _database; final now = DateTime.now().toIso8601String(); final id = await db.insert('shopping_lists', { 'name': name.trim(), 'created_at': now, 'updated_at': now, }); notifyListeners(); return id; } Future renameShoppingList(int id, String name) async { final db = await _database; await db.update( 'shopping_lists', {'name': name.trim(), 'updated_at': DateTime.now().toIso8601String()}, where: 'id = ?', whereArgs: [id], ); notifyListeners(); } Future resetShoppingListChecks(int listId) async { final db = await _database; await db.update( 'shopping_list_items', {'checked': 0}, where: 'list_id = ? AND checked = 1', whereArgs: [listId], ); await _touchShoppingList(listId); notifyListeners(); } Future deleteShoppingList(int id) async { final db = await _database; await db.transaction((txn) async { await txn.delete( 'shopping_list_items', where: 'list_id = ?', whereArgs: [id], ); await txn.delete('shopping_lists', where: 'id = ?', whereArgs: [id]); }); notifyListeners(); } Future> getShoppingListItems(int listId) async { final db = await _database; final rows = await db.query( 'shopping_list_items', where: 'list_id = ?', whereArgs: [listId], orderBy: 'checked ASC, sort_order ASC, id ASC', ); return rows.map(ShoppingListItem.fromMap).toList(); } Future addShoppingListItem({ required int listId, required String name, int? productId, }) async { final added = await addShoppingListItems( listId: listId, items: [(name: name, productId: productId)], ); return added.single.id; } Future> addShoppingListItems({ required int listId, required List<({String name, int? productId})> items, }) async { if (items.isEmpty) return const []; final db = await _database; final added = []; await db.transaction((txn) async { var max = Sqflite.firstIntValue( await txn.rawQuery( 'SELECT MAX(sort_order) FROM shopping_list_items WHERE list_id = ?', [listId], ), ); var sortOrder = (max ?? -1) + 1; for (final item in items) { final name = item.name.trim(); if (name.isEmpty) continue; final id = await txn.insert('shopping_list_items', { 'list_id': listId, 'product_id': item.productId, 'name': name, 'checked': 0, 'sort_order': sortOrder++, }); added.add( ShoppingListItem( id: id, listId: listId, name: name, checked: false, sortOrder: sortOrder - 1, productId: item.productId, ), ); } await txn.update( 'shopping_lists', {'updated_at': DateTime.now().toIso8601String()}, where: 'id = ?', whereArgs: [listId], ); }); notifyListeners(); return added; } Future setSupermarketBonusCard(int id, String sourcePath) async { final stored = await _persistImage(sourcePath, 'bonus_cards/store_$id.jpg'); final db = await _database; await db.update( 'supermarkets', {'bonus_image_path': stored}, where: 'id = ?', whereArgs: [id], ); notifyListeners(); return stored; } Future updateShoppingListItem({ required int id, String? name, bool? checked, int? productId, }) async { final db = await _database; final rows = await db.query( 'shopping_list_items', columns: ['list_id'], where: 'id = ?', whereArgs: [id], limit: 1, ); if (rows.isEmpty) return; final values = {}; if (name != null) values['name'] = name.trim(); if (checked != null) values['checked'] = checked ? 1 : 0; if (productId != null) values['product_id'] = productId; if (values.isEmpty) return; await db.update( 'shopping_list_items', values, where: 'id = ?', whereArgs: [id], ); await _touchShoppingList(rows.first['list_id']! as int); notifyListeners(); } Future deleteShoppingListItem(int id) async { final db = await _database; final rows = await db.query( 'shopping_list_items', columns: ['list_id'], where: 'id = ?', whereArgs: [id], limit: 1, ); if (rows.isEmpty) return; await db.delete('shopping_list_items', where: 'id = ?', whereArgs: [id]); await _touchShoppingList(rows.first['list_id']! as int); notifyListeners(); } Future _touchShoppingList(int id) async { final db = await _database; await db.update( 'shopping_lists', {'updated_at': DateTime.now().toIso8601String()}, where: 'id = ?', whereArgs: [id], ); } Future getDarkMode() async { final db = await _database; final rows = await db.query( 'app_settings', where: 'key = ?', whereArgs: ['dark_mode'], limit: 1, ); if (rows.isEmpty) return false; return rows.first['value'] == '1'; } Future setDarkMode(bool enabled) async { final db = await _database; await db.insert('app_settings', { 'key': 'dark_mode', 'value': enabled ? '1' : '0', }, conflictAlgorithm: ConflictAlgorithm.replace); } Future> getSupermarkets() async { final db = await _database; final rows = await db.query( 'supermarkets', orderBy: 'sort_order ASC, id ASC', ); if (rows.isEmpty) { await _seedSupermarkets(db); return getSupermarkets(); } return rows.map(Supermarket.fromMap).toList(); } /// Store names from newest trip to oldest, unique. Future> getLastTripSupermarkets() async { final db = await _database; final rows = await db.rawQuery(''' SELECT supermarket FROM shops GROUP BY supermarket ORDER BY MAX(shopped_at) DESC, MAX(id) DESC '''); return [ for (final row in rows) if (row['supermarket'] is String) row['supermarket']! as String, ]; } Future addSupermarket({ required String name, required int colorValue, }) async { final db = await _database; final maxOrder = Sqflite.firstIntValue( await db.rawQuery('SELECT MAX(sort_order) FROM supermarkets'), ); final id = await db.insert('supermarkets', { 'name': name.trim(), 'color': colorValue, 'sort_order': (maxOrder ?? -1) + 1, }); return Supermarket( id: id, name: name.trim(), colorValue: colorValue, sortOrder: (maxOrder ?? -1) + 1, ); } Future updateSupermarket(Supermarket store) async { final id = store.id; if (id == null) return; final db = await _database; final existing = await db.query( 'supermarkets', where: 'id = ?', whereArgs: [id], limit: 1, ); if (existing.isEmpty) return; final oldName = existing.first['name']! as String; await db.update( 'supermarkets', { 'name': store.name.trim(), 'color': store.colorValue, 'sort_order': store.sortOrder, 'bonus_image_path': store.bonusImagePath, }, where: 'id = ?', whereArgs: [id], ); if (oldName != store.name.trim()) { await db.update( 'products', {'supermarket': store.name.trim()}, where: 'supermarket = ?', whereArgs: [oldName], ); await db.update( 'shops', {'supermarket': store.name.trim()}, where: 'supermarket = ?', whereArgs: [oldName], ); } } Future deleteSupermarket(int id) async { final db = await _database; await db.delete('supermarkets', where: 'id = ?', whereArgs: [id]); } Future>> dumpTable(String table) async { final db = await _database; return db.query(table, orderBy: 'id ASC'); } Future writeImageBytes(String relativePath, List bytes) async { final dir = await getApplicationDocumentsDirectory(); final dest = File(p.join(dir.path, relativePath)); await dest.parent.create(recursive: true); await dest.writeAsBytes(bytes, flush: true); return dest.path; } Future clearImageFolders() async { try { final dir = await getApplicationDocumentsDirectory(); for (final name in ['receipts', 'products', 'bonus_cards']) { final folder = Directory(p.join(dir.path, name)); if (await folder.exists()) { await folder.delete(recursive: true); } } } catch (_) { // Platform folders are unavailable in some unit tests. } } /// Deletes all app data and restores default supermarkets. Future wipeAllData() async { final db = await _database; await db.transaction((txn) async { await txn.delete('shopping_list_items'); await txn.delete('shopping_lists'); await txn.delete('shop_tags'); await txn.delete('shop_items'); await txn.delete('shops'); await txn.delete('products'); await txn.delete('supermarkets'); await txn.delete('app_settings'); await _resetSequence(txn, 'supermarkets'); await _resetSequence(txn, 'products'); await _resetSequence(txn, 'shops'); await _resetSequence(txn, 'shop_items'); await _resetSequence(txn, 'shop_tags'); await _resetSequence(txn, 'shopping_lists'); await _resetSequence(txn, 'shopping_list_items'); await _seedSupermarkets(txn); }); await clearImageFolders(); notifyListeners(); } Future replaceAllData({ required List> supermarkets, required List> products, required List> shops, required List> shopItems, required bool darkMode, List> shoppingLists = const [], List> shoppingListItems = const [], List> shopTags = const [], }) async { final db = await _database; await db.transaction((txn) async { await txn.delete('shopping_list_items'); await txn.delete('shopping_lists'); await txn.delete('shop_tags'); await txn.delete('shop_items'); await txn.delete('shops'); await txn.delete('products'); await txn.delete('supermarkets'); await txn.delete('app_settings'); for (final row in supermarkets) { await txn.insert('supermarkets', _sqlRow(row, _supermarketColumns)); } for (final row in products) { await txn.insert('products', _sqlRow(row, _productColumns)); } for (final row in shops) { await txn.insert('shops', _sqlRow(row, _shopColumns)); } for (final row in shopItems) { await txn.insert('shop_items', _sqlRow(row, _shopItemColumns)); } for (final row in shopTags) { await txn.insert('shop_tags', _sqlRow(row, _shopTagColumns)); } for (final row in shoppingLists) { await txn.insert('shopping_lists', _sqlRow(row, _shoppingListColumns)); } for (final row in shoppingListItems) { await txn.insert( 'shopping_list_items', _sqlRow(row, _shoppingListItemColumns), ); } await txn.insert('app_settings', { 'key': 'dark_mode', 'value': darkMode ? '1' : '0', }); await _resetSequence(txn, 'supermarkets'); await _resetSequence(txn, 'products'); await _resetSequence(txn, 'shops'); await _resetSequence(txn, 'shop_items'); await _resetSequence(txn, 'shop_tags'); await _resetSequence(txn, 'shopping_lists'); await _resetSequence(txn, 'shopping_list_items'); }); notifyListeners(); } static const _supermarketColumns = { 'id', 'name', 'color', 'sort_order', 'bonus_image_path', }; static const _productColumns = { 'id', 'name', 'normalized_name', 'last_price', 'times_seen', 'favourite', 'image_path', 'supermarket', 'notes', 'category', 'created_at', 'updated_at', }; static const _shopColumns = { 'id', 'supermarket', 'name', 'shopped_at', 'receipt_image_path', 'total', 'created_at', }; static const _shopItemColumns = { 'id', 'shop_id', 'product_id', 'name', 'price', 'quantity', 'unit_price', }; static const _shopTagColumns = {'id', 'shop_id', 'tag', 'normalized_tag'}; static const _shoppingListColumns = { 'id', 'name', 'created_at', 'updated_at', }; static const _shoppingListItemColumns = { 'id', 'list_id', 'product_id', 'name', 'checked', 'sort_order', }; Map _sqlRow( Map source, Set columns, ) { final row = {}; for (final column in columns) { if (!source.containsKey(column)) continue; row[column] = _sqlValue(source[column]); } return row; } Object? _sqlValue(Object? value) { if (value == null) return null; if (value is bool) return value ? 1 : 0; if (value is int) return value; if (value is double) { if (value == value.roundToDouble()) return value.toInt(); return value; } if (value is num) return value.toDouble(); return value.toString(); } Future _resetSequence(DatabaseExecutor db, String table) async { try { final max = Sqflite.firstIntValue( await db.rawQuery('SELECT MAX(id) FROM $table'), ); await db.delete('sqlite_sequence', where: 'name = ?', whereArgs: [table]); if (max != null) { await db.insert('sqlite_sequence', {'name': table, 'seq': max}); } } catch (_) { // sqlite_sequence is missing on some empty databases. } } }