1681 lines
48 KiB
Dart
1681 lines
48 KiB
Dart
/// 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<Database>? _opening;
|
|
|
|
Future<Database> get _database {
|
|
_opening ??= _open();
|
|
return _opening!;
|
|
}
|
|
|
|
Future<Database> _open() async {
|
|
final path =
|
|
databasePath ??
|
|
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
|
|
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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _upgradeProductNormalizedNamesV9(DatabaseExecutor db) async {
|
|
final rows = await db.query('products', columns: ['id', 'name', 'supermarket']);
|
|
final used = <String>{};
|
|
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<String> _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<int> saveShop({
|
|
required String supermarket,
|
|
required DateTime shoppedAt,
|
|
required List<LineItem> items,
|
|
String? receiptImagePath,
|
|
String? name,
|
|
List<String> tags = const [],
|
|
}) async {
|
|
final db = await _database;
|
|
final now = DateTime.now().toIso8601String();
|
|
final total = roundMoney(
|
|
items.fold<double>(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<int> _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 = <String, Object?>{
|
|
'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<void> upsertItems(List<LineItem> items) async {
|
|
await saveShop(
|
|
supermarket: 'Other',
|
|
shoppedAt: DateTime.now(),
|
|
items: items,
|
|
);
|
|
}
|
|
|
|
Future<List<Product>> getAll({
|
|
ProductSort sort = ProductSort.name,
|
|
}) async {
|
|
final db = await _database;
|
|
final orderBy = switch (sort) {
|
|
ProductSort.name => 'name COLLATE NOCASE ASC',
|
|
ProductSort.preferred => 'times_seen DESC, name COLLATE NOCASE ASC',
|
|
ProductSort.start => 'favourite 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
|
|
ORDER BY $orderBy
|
|
''');
|
|
return rows.map(Product.fromMap).toList();
|
|
}
|
|
|
|
Future<List<({int productId, String name, String supermarket})>>
|
|
_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<ProductNameIssues> getProductNameIssues() async {
|
|
final products = await getAll();
|
|
return buildProductNameIssues(
|
|
products: products,
|
|
shopLines: await _shopLineNames(),
|
|
dismissedKeys: await getDismissedNameIssues(),
|
|
);
|
|
}
|
|
|
|
Future<Set<String>> 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<dynamic>;
|
|
return decoded.map((entry) => entry as String).toSet();
|
|
} catch (_) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
Future<void> 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<List<List<Product>>> getNameConflictGroups() async {
|
|
return [
|
|
for (final group in (await getProductNameIssues()).mergeGroups)
|
|
group.products,
|
|
];
|
|
}
|
|
|
|
Future<List<ProductSpellingVariant>> getProductSpellingVariants() async {
|
|
return (await getProductNameIssues()).spellingVariants;
|
|
}
|
|
|
|
/// Picks one spelling for a catalog product at a store.
|
|
Future<void> 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<AppOverview> 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<Product?> 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<List<String>> 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<void> 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 = <String, Object?>{
|
|
'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<void> setFavourite(int id, bool favourite) =>
|
|
updateProduct(id: id, favourite: favourite);
|
|
|
|
Future<String?> setProductImage(int id, String sourcePath) async {
|
|
final stored = await _persistImage(sourcePath, 'products/product_$id.jpg');
|
|
await updateProduct(id: id, imagePath: stored);
|
|
return stored;
|
|
}
|
|
|
|
Future<void> 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<void> 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<void> mergeProducts({
|
|
required int keepId,
|
|
required List<int> 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 = <Product>[];
|
|
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 = <String?>[];
|
|
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<void> _tryDeleteFile(String? path) async {
|
|
if (path == null || path.isEmpty) return;
|
|
try {
|
|
final file = File(path);
|
|
if (await file.exists()) {
|
|
await file.delete();
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<List<Purchase>> 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<List<Shop>> 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<Shop?> 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<List<Shop>> _shopsWithTags(List<Map<String, Object?>> 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<Map<int, List<String>>> _tagsByShopIds(List<int> 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 = <int, List<String>>{};
|
|
for (final row in rows) {
|
|
final shopId = row['shop_id']! as int;
|
|
(tags[shopId] ??= []).add(row['tag']! as String);
|
|
}
|
|
return tags;
|
|
}
|
|
|
|
Future<Map<int, List<String>>> getShopItemNamesByShop() async {
|
|
final db = await _database;
|
|
final rows = await db.query('shop_items', columns: ['shop_id', 'name']);
|
|
final names = <int, List<String>>{};
|
|
for (final row in rows) {
|
|
(names[row['shop_id']! as int] ??= []).add(row['name']! as String);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
Future<List<String>> 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<void> updateShop({
|
|
required int id,
|
|
String? name,
|
|
bool clearName = false,
|
|
DateTime? shoppedAt,
|
|
List<String>? tags,
|
|
}) async {
|
|
final db = await _database;
|
|
await db.transaction((txn) async {
|
|
final values = <String, Object?>{};
|
|
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<void> _replaceShopTags(
|
|
DatabaseExecutor db, {
|
|
required int shopId,
|
|
required List<String> tags,
|
|
}) async {
|
|
await db.delete('shop_tags', where: 'shop_id = ?', whereArgs: [shopId]);
|
|
final seen = <String>{};
|
|
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<List<ShopItem>> 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<void> 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<ShopItem> 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<void> 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<void> updateShopItem({
|
|
required int id,
|
|
String? name,
|
|
double? price,
|
|
int? quantity,
|
|
double? unitPrice,
|
|
}) 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 = <String, Object?>{};
|
|
if (name != null) values['name'] = name.trim();
|
|
if (price != null) values['price'] = price;
|
|
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<void> _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<List<ShoppingList>> 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<ShoppingList?> 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<int> 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<void> 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<void> 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<void> 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<List<ShoppingListItem>> 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<int> 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<List<ShoppingListItem>> addShoppingListItems({
|
|
required int listId,
|
|
required List<({String name, int? productId})> items,
|
|
}) async {
|
|
if (items.isEmpty) return const [];
|
|
|
|
final db = await _database;
|
|
final added = <ShoppingListItem>[];
|
|
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<String?> 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<void> 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 = <String, Object?>{};
|
|
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<void> 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<void> _touchShoppingList(int id) async {
|
|
final db = await _database;
|
|
await db.update(
|
|
'shopping_lists',
|
|
{'updated_at': DateTime.now().toIso8601String()},
|
|
where: 'id = ?',
|
|
whereArgs: [id],
|
|
);
|
|
}
|
|
|
|
Future<bool> 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<void> setDarkMode(bool enabled) async {
|
|
final db = await _database;
|
|
await db.insert('app_settings', {
|
|
'key': 'dark_mode',
|
|
'value': enabled ? '1' : '0',
|
|
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
|
}
|
|
|
|
Future<List<Supermarket>> 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<Supermarket> 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<void> 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<void> deleteSupermarket(int id) async {
|
|
final db = await _database;
|
|
await db.delete('supermarkets', where: 'id = ?', whereArgs: [id]);
|
|
}
|
|
|
|
Future<List<Map<String, Object?>>> dumpTable(String table) async {
|
|
final db = await _database;
|
|
return db.query(table, orderBy: 'id ASC');
|
|
}
|
|
|
|
Future<String> writeImageBytes(String relativePath, List<int> 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<void> 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<void> replaceAllData({
|
|
required List<Map<String, Object?>> supermarkets,
|
|
required List<Map<String, Object?>> products,
|
|
required List<Map<String, Object?>> shops,
|
|
required List<Map<String, Object?>> shopItems,
|
|
required bool darkMode,
|
|
List<Map<String, Object?>> shoppingLists = const [],
|
|
List<Map<String, Object?>> shoppingListItems = const [],
|
|
List<Map<String, Object?>> 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<String, Object?> _sqlRow(
|
|
Map<String, Object?> source,
|
|
Set<String> columns,
|
|
) {
|
|
final row = <String, Object?>{};
|
|
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<void> _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.
|
|
}
|
|
}
|
|
}
|