added categories, import export, settings

This commit is contained in:
davmar
2026-08-29 16:45:27 +02:00
parent 2a5a922aab
commit 732a0dc14a
30 changed files with 3700 additions and 167 deletions
+89
View File
@@ -0,0 +1,89 @@
/// App-wide settings: dark mode and the user supermarket list.
library;
import 'package:flutter/material.dart';
import '../models/supermarket.dart';
import '../utils/supermarkets.dart';
import 'product_repository.dart';
class AppSettings extends ChangeNotifier {
AppSettings(this.repository);
final ProductRepository repository;
bool darkMode = false;
List<Supermarket> supermarkets = List<Supermarket>.from(kDefaultSupermarkets);
int catalogGeneration = 0;
Future<void> load() async {
try {
darkMode = await repository.getDarkMode();
supermarkets = await repository.getSupermarkets();
notifyListeners();
} catch (_) {
// Widget tests and first-run without a plugin keep the defaults.
}
}
Future<void> setDarkMode(bool enabled) async {
darkMode = enabled;
notifyListeners();
try {
await repository.setDarkMode(enabled);
} catch (_) {}
}
Future<void> addSupermarket(String name, int colorValue) async {
final trimmed = name.trim();
if (trimmed.isEmpty) return;
final store = await repository.addSupermarket(
name: trimmed,
colorValue: colorValue,
);
supermarkets = [...supermarkets, store];
notifyListeners();
}
Future<void> updateSupermarket(Supermarket store) async {
await repository.updateSupermarket(store);
supermarkets = [
for (final item in supermarkets)
if (item.id == store.id) store else item,
];
notifyListeners();
}
Future<void> deleteSupermarket(int id) async {
await repository.deleteSupermarket(id);
supermarkets = [
for (final item in supermarkets)
if (item.id != id) item,
];
notifyListeners();
}
Future<void> reload() async {
await load();
catalogGeneration++;
notifyListeners();
}
}
class SettingsScope extends InheritedNotifier<AppSettings> {
const SettingsScope({
super.key,
required AppSettings settings,
required super.child,
}) : super(notifier: settings);
static AppSettings of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<SettingsScope>();
assert(scope != null, 'SettingsScope not found');
return scope!.notifier!;
}
static AppSettings? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<SettingsScope>()?.notifier;
}
}
+99
View File
@@ -0,0 +1,99 @@
/// JSON backup document: products, trips, settings, and embedded photos.
library;
import 'dart:convert';
const int kBackupVersion = 1;
class BackupDocument {
const BackupDocument({
required this.version,
required this.exportedAt,
required this.darkMode,
required this.supermarkets,
required this.products,
required this.shops,
required this.shopItems,
required this.images,
});
final int version;
final String exportedAt;
final bool darkMode;
final List<Map<String, Object?>> supermarkets;
final List<Map<String, Object?>> products;
final List<Map<String, Object?>> shops;
final List<Map<String, Object?>> shopItems;
final Map<String, String> images;
Map<String, Object?> toJson() => {
'format': 'receipity-backup',
'version': version,
'exportedAt': exportedAt,
'darkMode': darkMode,
'supermarkets': supermarkets,
'products': products,
'shops': shops,
'shopItems': shopItems,
'images': images,
};
String encode() => const JsonEncoder.withIndent(' ').convert(toJson());
static BackupDocument parse(String json) {
final decoded = jsonDecode(json);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Backup is not a JSON object.');
}
if (decoded['format'] != 'receipity-backup') {
throw const FormatException('This file is not a Receipity backup.');
}
final version = decoded['version'];
if (version is! int || version < 1 || version > kBackupVersion) {
throw const FormatException('Unsupported backup version.');
}
return BackupDocument(
version: version,
exportedAt: decoded['exportedAt'] as String? ?? '',
darkMode: decoded['darkMode'] == true,
supermarkets: _maps(decoded['supermarkets']),
products: _maps(decoded['products']),
shops: _maps(decoded['shops']),
shopItems: _maps(decoded['shopItems']),
images: _strings(decoded['images']),
);
}
static List<Map<String, Object?>> _maps(Object? value) {
if (value == null) return [];
if (value is! List) {
throw const FormatException('Backup tables must be lists.');
}
return [
for (final item in value)
if (item is Map)
Map<String, Object?>.from(
item.map((key, val) => MapEntry(key.toString(), val)),
),
];
}
static Map<String, String> _strings(Object? value) {
if (value == null) return {};
if (value is! Map) return {};
return {
for (final entry in value.entries)
if (entry.value is String) entry.key.toString(): entry.value as String,
};
}
}
String? backupImageKey(String? absolutePath) {
if (absolutePath == null || absolutePath.isEmpty) return null;
final normalized = absolutePath.replaceAll('\\', '/');
final products = normalized.split('/products/');
if (products.length == 2) return 'products/${products.last}';
final receipts = normalized.split('/receipts/');
if (receipts.length == 2) return 'receipts/${receipts.last}';
return null;
}
+161
View File
@@ -0,0 +1,161 @@
/// Build, share, and restore a Receipity JSON backup.
library;
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'backup_format.dart';
import 'product_repository.dart';
class BackupService {
BackupService(this.repository);
final ProductRepository repository;
Future<BackupDocument> buildBackup() async {
final products = await repository.dumpTable('products');
final shops = await repository.dumpTable('shops');
final images = <String, String>{};
Future<void> addImage(String? path) async {
final key = backupImageKey(path);
if (key == null || images.containsKey(key)) return;
final file = File(path!);
if (!file.existsSync()) return;
images[key] = base64Encode(await file.readAsBytes());
}
final rewrittenProducts = <Map<String, Object?>>[];
for (final row in products) {
final copy = Map<String, Object?>.from(row);
final path = copy['image_path'] as String?;
await addImage(path);
copy['image_path'] = backupImageKey(path);
rewrittenProducts.add(copy);
}
final rewrittenShops = <Map<String, Object?>>[];
for (final row in shops) {
final copy = Map<String, Object?>.from(row);
final path = copy['receipt_image_path'] as String?;
await addImage(path);
copy['receipt_image_path'] = backupImageKey(path);
rewrittenShops.add(copy);
}
return BackupDocument(
version: kBackupVersion,
exportedAt: DateTime.now().toIso8601String(),
darkMode: await repository.getDarkMode(),
supermarkets: await repository.dumpTable('supermarkets'),
products: rewrittenProducts,
shops: rewrittenShops,
shopItems: await repository.dumpTable('shop_items'),
images: images,
);
}
Future<bool> exportBackup() async {
final document = await buildBackup();
final bytes = Uint8List.fromList(utf8.encode(document.encode()));
final stamp = DateTime.now().toIso8601String().split('T').first;
final fileName = 'receipity-backup-$stamp.json';
try {
final saved = await FilePicker.saveFile(
dialogTitle: 'Save Receipity backup',
fileName: fileName,
bytes: bytes,
mimeType: 'application/json',
type: FileType.custom,
allowedExtensions: const ['json'],
);
return saved != null;
} catch (_) {
// Fall through to the share sheet if save-as is unavailable.
}
final dir = await getTemporaryDirectory();
final file = File(p.join(dir.path, fileName));
await file.writeAsBytes(bytes, flush: true);
final result = await SharePlus.instance.share(
ShareParams(
files: [XFile(file.path, mimeType: 'application/json')],
subject: 'Receipity backup',
text: 'Receipity backup $stamp',
),
);
return result.status != ShareResultStatus.dismissed;
}
Future<bool> importBackup() async {
final picked = await FilePicker.pickFile(
dialogTitle: 'Import Receipity backup',
type: FileType.custom,
allowedExtensions: const ['json'],
);
if (picked == null) return false;
final bytes = await picked.readAsBytes();
await restoreBackup(utf8.decode(bytes));
return true;
}
Future<void> restoreBackup(String json) async {
final document = BackupDocument.parse(json);
await repository.clearImageFolders();
final products = <Map<String, Object?>>[];
final now = DateTime.now().toIso8601String();
for (final row in document.products) {
final copy = Map<String, Object?>.from(row);
copy['image_path'] = await _restoreImage(copy['image_path'], document.images);
copy['created_at'] ??= now;
copy['updated_at'] ??= copy['created_at'];
if (copy['normalized_name'] == null && copy['name'] is String) {
copy['normalized_name'] = ProductRepository.normalizeName(
copy['name']! as String,
);
}
products.add(copy);
}
final shops = <Map<String, Object?>>[];
for (final row in document.shops) {
final copy = Map<String, Object?>.from(row);
copy['receipt_image_path'] = await _restoreImage(
copy['receipt_image_path'],
document.images,
);
copy['created_at'] ??= now;
shops.add(copy);
}
await repository.replaceAllData(
supermarkets: document.supermarkets,
products: products,
shops: shops,
shopItems: document.shopItems,
darkMode: document.darkMode,
);
}
Future<String?> _restoreImage(
Object? key,
Map<String, String> images,
) async {
if (key is! String || key.isEmpty) return null;
final encoded = images[key];
if (encoded == null) return null;
try {
return await repository.writeImageBytes(key, base64Decode(encoded));
} catch (_) {
return null;
}
}
}
+652 -55
View File
@@ -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.
}
}
}