new plus button
This commit is contained in:
@@ -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']),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user