new plus button

This commit is contained in:
davmar
2026-08-31 09:40:38 +02:00
parent 3b698a3667
commit 92581e6d55
17 changed files with 1336 additions and 152 deletions
+5 -1
View File
@@ -3,7 +3,7 @@ library;
import 'dart:convert';
const int kBackupVersion = 2;
const int kBackupVersion = 3;
class BackupDocument {
const BackupDocument({
@@ -17,6 +17,7 @@ class BackupDocument {
required this.shoppingLists,
required this.shoppingListItems,
required this.images,
this.shopTags = const [],
});
final int version;
@@ -26,6 +27,7 @@ class BackupDocument {
final List<Map<String, Object?>> products;
final List<Map<String, Object?>> shops;
final List<Map<String, Object?>> shopItems;
final List<Map<String, Object?>> shopTags;
final List<Map<String, Object?>> shoppingLists;
final List<Map<String, Object?>> shoppingListItems;
final Map<String, String> images;
@@ -39,6 +41,7 @@ class BackupDocument {
'products': products,
'shops': shops,
'shopItems': shopItems,
'shopTags': shopTags,
'shoppingLists': shoppingLists,
'shoppingListItems': shoppingListItems,
'images': images,
@@ -66,6 +69,7 @@ class BackupDocument {
products: _maps(decoded['products']),
shops: _maps(decoded['shops']),
shopItems: _maps(decoded['shopItems']),
shopTags: _maps(decoded['shopTags']),
shoppingLists: _maps(decoded['shoppingLists']),
shoppingListItems: _maps(decoded['shoppingListItems']),
images: _strings(decoded['images']),
+2
View File
@@ -57,6 +57,7 @@ class BackupService {
products: rewrittenProducts,
shops: rewrittenShops,
shopItems: await repository.dumpTable('shop_items'),
shopTags: await repository.dumpTable('shop_tags'),
shoppingLists: await repository.dumpTable('shopping_lists'),
shoppingListItems: await repository.dumpTable('shopping_list_items'),
images: images,
@@ -146,6 +147,7 @@ class BackupService {
products: products,
shops: shops,
shopItems: document.shopItems,
shopTags: document.shopTags,
shoppingLists: document.shoppingLists,
shoppingListItems: document.shoppingListItems,
darkMode: document.darkMode,
+142 -3
View File
@@ -33,7 +33,7 @@ class ProductRepository {
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
return openDatabase(
path,
version: 5,
version: 6,
onConfigure: (db) async {
await db.execute('PRAGMA foreign_keys = ON');
},
@@ -64,6 +64,9 @@ class ProductRepository {
if (oldVersion < 5) {
await _createShoppingListTables(db);
}
if (oldVersion < 6) {
await _upgradeShopsToV6(db);
}
},
);
}
@@ -121,6 +124,7 @@ class ProductRepository {
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,
@@ -140,6 +144,29 @@ class ProductRepository {
FOREIGN KEY (product_id) REFERENCES products(id)
)
''');
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> _createShoppingListTables(DatabaseExecutor db) async {
@@ -185,20 +212,25 @@ class ProductRepository {
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(
@@ -440,6 +472,7 @@ class ProductRepository {
SELECT
shops.id,
shops.supermarket,
shops.name,
shops.shopped_at,
shops.receipt_image_path,
shops.total,
@@ -449,7 +482,7 @@ class ProductRepository {
GROUP BY shops.id
ORDER BY shops.shopped_at DESC, shops.id DESC
''');
return rows.map(Shop.fromMap).toList();
return _shopsWithTags(rows);
}
Future<Shop?> getShop(int id) async {
@@ -459,6 +492,7 @@ class ProductRepository {
SELECT
shops.id,
shops.supermarket,
shops.name,
shops.shopped_at,
shops.receipt_image_path,
shops.total,
@@ -471,7 +505,103 @@ class ProductRepository {
[id],
);
if (rows.isEmpty) return null;
return Shop.fromMap(rows.first);
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,
List<String>? tags,
}) async {
final db = await _database;
await db.transaction((txn) async {
if (clearName || name != null) {
await txn.update(
'shops',
{'name': clearName ? null : _optionalText(name)},
where: 'id = ?',
whereArgs: [id],
);
}
if (tags != null) {
await _replaceShopTags(txn, shopId: id, tags: tags);
}
});
}
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 {
@@ -491,6 +621,7 @@ class ProductRepository {
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]);
});
final path = shop?.receiptImagePath;
@@ -845,11 +976,13 @@ class ProductRepository {
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');
@@ -868,6 +1001,9 @@ class ProductRepository {
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));
}
@@ -886,6 +1022,7 @@ class ProductRepository {
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');
});
@@ -909,6 +1046,7 @@ class ProductRepository {
static const _shopColumns = {
'id',
'supermarket',
'name',
'shopped_at',
'receipt_image_path',
'total',
@@ -923,6 +1061,7 @@ class ProductRepository {
'quantity',
'unit_price',
};
static const _shopTagColumns = {'id', 'shop_id', 'tag', 'normalized_tag'};
static const _shoppingListColumns = {
'id',
'name',