fixes before build

This commit is contained in:
gitea
2026-08-31 16:51:31 +02:00
parent 7692809219
commit 756f9db356
34 changed files with 3542 additions and 837 deletions
+3 -1
View File
@@ -84,6 +84,8 @@ class SettingsScope extends InheritedNotifier<AppSettings> {
}
static AppSettings? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<SettingsScope>()?.notifier;
return context
.dependOnInheritedWidgetOfExactType<SettingsScope>()
?.notifier;
}
}
+1
View File
@@ -126,6 +126,7 @@ class BackupService {
if (copy['normalized_name'] == null && copy['name'] is String) {
copy['normalized_name'] = ProductRepository.normalizeName(
copy['name']! as String,
supermarket: copy['supermarket'] as String?,
);
}
products.add(copy);
+458 -33
View File
@@ -1,6 +1,7 @@
/// Local SQLite store of products and shopping trips.
library;
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
@@ -16,6 +17,7 @@ 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 {
@@ -35,7 +37,7 @@ class ProductRepository extends ChangeNotifier {
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
return openDatabase(
path,
version: 7,
version: 9,
onConfigure: (db) async {
await db.execute('PRAGMA foreign_keys = ON');
},
@@ -72,6 +74,14 @@ class ProductRepository extends ChangeNotifier {
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);
}
},
);
}
@@ -107,7 +117,8 @@ class ProductRepository extends ChangeNotifier {
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color INTEGER NOT NULL,
sort_order INTEGER NOT NULL
sort_order INTEGER NOT NULL,
bonus_image_path TEXT
)
''');
}
@@ -222,8 +233,28 @@ class ProductRepository extends ChangeNotifier {
''');
}
static String normalizeName(String name) =>
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
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();
@@ -308,7 +339,7 @@ class ProductRepository extends ChangeNotifier {
required String supermarket,
required String now,
}) async {
final key = normalizeName(item.name);
final key = normalizeName(item.name, supermarket: supermarket);
final existing = await txn.query(
'products',
where: 'normalized_name = ?',
@@ -359,12 +390,12 @@ class ProductRepository extends ChangeNotifier {
Future<List<Product>> 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',
ProductSort.start => 'favourite DESC, name COLLATE NOCASE ASC',
};
final rows = await db.rawQuery('''
SELECT
@@ -378,12 +409,132 @@ class ProductRepository extends ChangeNotifier {
LIMIT 1
) AS display_price
FROM products
${favouritesOnly ? 'WHERE favourite = 1' : ''}
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 =
@@ -399,7 +550,9 @@ class ProductRepository extends ChangeNotifier {
) ??
0;
final tripCount =
Sqflite.firstIntValue(await db.rawQuery('SELECT COUNT(*) FROM shops')) ??
Sqflite.firstIntValue(
await db.rawQuery('SELECT COUNT(*) FROM shops'),
) ??
0;
final listCount =
Sqflite.firstIntValue(
@@ -480,13 +633,28 @@ class ProductRepository extends ChangeNotifier {
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'] = name.trim();
values['normalized_name'] = normalizeName(name);
}
if (name != null) values['name'] = nextName;
if (favourite != null) values['favourite'] = favourite ? 1 : 0;
if (clearSupermarket) {
values['supermarket'] = null;
@@ -565,6 +733,145 @@ class ProductRepository extends ChangeNotifier {
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 {
@@ -691,17 +998,20 @@ class ProductRepository extends ChangeNotifier {
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) {
await txn.update(
'shops',
{'name': clearName ? null : _optionalText(name)},
where: 'id = ?',
whereArgs: [id],
);
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);
@@ -760,6 +1070,51 @@ class ProductRepository extends ChangeNotifier {
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;
@@ -825,6 +1180,7 @@ class ProductRepository extends ChangeNotifier {
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
@@ -844,6 +1200,7 @@ class ProductRepository extends ChangeNotifier {
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
@@ -882,6 +1239,18 @@ class ProductRepository extends ChangeNotifier {
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 {
@@ -911,23 +1280,72 @@ class ProductRepository extends ChangeNotifier {
required String name,
int? productId,
}) async {
final db = await _database;
final max = Sqflite.firstIntValue(
await db.rawQuery(
'SELECT MAX(sort_order) FROM shopping_list_items WHERE list_id = ?',
[listId],
),
final added = await addShoppingListItems(
listId: listId,
items: [(name: name, productId: productId)],
);
final id = await db.insert('shopping_list_items', {
'list_id': listId,
'product_id': productId,
'name': name.trim(),
'checked': 0,
'sort_order': (max ?? -1) + 1,
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],
);
});
await _touchShoppingList(listId);
notifyListeners();
return id;
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({
@@ -1057,6 +1475,7 @@ class ProductRepository extends ChangeNotifier {
'name': store.name.trim(),
'color': store.colorValue,
'sort_order': store.sortOrder,
'bonus_image_path': store.bonusImagePath,
},
where: 'id = ?',
whereArgs: [id],
@@ -1166,7 +1585,13 @@ class ProductRepository extends ChangeNotifier {
notifyListeners();
}
static const _supermarketColumns = {'id', 'name', 'color', 'sort_order'};
static const _supermarketColumns = {
'id',
'name',
'color',
'sort_order',
'bonus_image_path',
};
static const _productColumns = {
'id',
'name',