/// Local SQLite store of products and shopping trips. library; import 'dart:io'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; import '../models/line_item.dart'; import '../models/product.dart'; import '../models/purchase.dart'; import '../models/shop.dart'; import '../models/supermarket.dart'; import '../utils/money.dart'; import '../utils/supermarkets.dart'; class ProductRepository { ProductRepository({this.databasePath}); final String? databasePath; Database? _db; Future get _database async { _db ??= await _open(); return _db!; } Future _open() async { final path = databasePath ?? p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db'); return openDatabase( path, version: 4, onCreate: (db, version) async { await _createProducts(db); await _createShopTables(db); await _createSettingsTables(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'); } }, ); } 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 ) '''); } 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, 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 NOT NULL, 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) ) '''); } static String normalizeName(String name) => name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' '); 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, }) async { final db = await _database; final now = DateTime.now().toIso8601String(); final total = roundMoney( items.fold(0, (sum, item) => sum + item.price), ); final shopId = await db.transaction((txn) async { final id = await txn.insert('shops', { 'supermarket': supermarket, 'shopped_at': shoppedAt.toIso8601String(), 'total': total, 'created_at': now, }); 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. } } return shopId; } Future _upsertProduct( Transaction txn, { required LineItem item, required String supermarket, required String now, }) async { final key = normalizeName(item.name); 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, bool favouritesOnly = false, }) async { final db = await _database; final orderBy = switch (sort) { ProductSort.name => 'name COLLATE NOCASE ASC', ProductSort.preferred => 'times_seen DESC, 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 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 values = { 'updated_at': DateTime.now().toIso8601String(), }; if (name != null) { values['name'] = name.trim(); values['normalized_name'] = normalizeName(name); } 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]); } 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> 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.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 rows.map(Shop.fromMap).toList(); } Future getShop(int id) async { final db = await _database; final rows = await db.rawQuery( ''' SELECT shops.id, shops.supermarket, 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; return Shop.fromMap(rows.first); } 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(); } 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(); } 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, }, 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 { final dir = await getApplicationDocumentsDirectory(); for (final name in ['receipts', 'products']) { final folder = Directory(p.join(dir.path, name)); if (await folder.exists()) { await folder.delete(recursive: true); } } } Future replaceAllData({ required List> supermarkets, required List> products, required List> shops, required List> shopItems, required bool darkMode, }) async { final db = await _database; await db.transaction((txn) async { 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)); } 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'); }); } static const _supermarketColumns = { 'id', 'name', 'color', 'sort_order', }; 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', 'shopped_at', 'receipt_image_path', 'total', 'created_at', }; static const _shopItemColumns = { 'id', 'shop_id', 'product_id', 'name', 'price', 'quantity', 'unit_price', }; 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. } } }