added categories, import export, settings
This commit is contained in:
@@ -1,14 +1,24 @@
|
||||
/// Local SQLite store of confirmed products.
|
||||
/// 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<Database> get _database async {
|
||||
@@ -17,77 +27,664 @@ class ProductRepository {
|
||||
}
|
||||
|
||||
Future<Database> _open() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final path = databasePath ??
|
||||
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
|
||||
return openDatabase(
|
||||
p.join(dir.path, 'receipity.db'),
|
||||
version: 1,
|
||||
path,
|
||||
version: 4,
|
||||
onCreate: (db, version) 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,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
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<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
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
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,
|
||||
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<void> upsertItems(List<LineItem> items) async {
|
||||
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,
|
||||
}) async {
|
||||
final db = await _database;
|
||||
final now = DateTime.now().toIso8601String();
|
||||
final total = roundMoney(
|
||||
items.fold<double>(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,
|
||||
});
|
||||
|
||||
await db.transaction((txn) async {
|
||||
for (final item in items) {
|
||||
final key = normalizeName(item.name);
|
||||
if (key.isEmpty) continue;
|
||||
|
||||
final existing = await txn.query(
|
||||
'products',
|
||||
where: 'normalized_name = ?',
|
||||
whereArgs: [key],
|
||||
limit: 1,
|
||||
final productId = await _upsertProduct(
|
||||
txn,
|
||||
item: item,
|
||||
supermarket: supermarket,
|
||||
now: now,
|
||||
);
|
||||
|
||||
if (existing.isEmpty) {
|
||||
await txn.insert('products', {
|
||||
'name': item.name.trim(),
|
||||
'normalized_name': key,
|
||||
'last_price': item.price,
|
||||
'times_seen': 1,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
});
|
||||
} else {
|
||||
await txn.update(
|
||||
'products',
|
||||
{
|
||||
'name': item.name.trim(),
|
||||
'last_price': item.price,
|
||||
'times_seen': (existing.first['times_seen']! as int) + 1,
|
||||
'updated_at': now,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [existing.first['id']],
|
||||
);
|
||||
}
|
||||
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<int> _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 = <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,
|
||||
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<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 values = <String, Object?>{
|
||||
'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<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<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.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<Shop?> 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<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();
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
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,
|
||||
}) 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');
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Product>> getAll() async {
|
||||
final db = await _database;
|
||||
final rows = await db.query(
|
||||
'products',
|
||||
orderBy: 'name COLLATE NOCASE ASC',
|
||||
);
|
||||
return rows.map(Product.fromMap).toList();
|
||||
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<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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user