From edbfade4771abd34a315a3824335f40f441b74dc Mon Sep 17 00:00:00 2001 From: gitea Date: Mon, 31 Aug 2026 17:35:46 +0200 Subject: [PATCH] fixes before build 3 --- lib/review_screen.dart | 45 ++++++++ lib/services/app_settings.dart | 9 ++ lib/services/product_repository.dart | 50 ++++++-- lib/shop_detail_screen.dart | 1 + lib/widgets/item_edit_sheet.dart | 167 ++++++++++++++++++++++++--- lib/widgets/settings_button.dart | 71 ++++++++++-- test/catalog_test.dart | 19 +++ 7 files changed, 330 insertions(+), 32 deletions(-) diff --git a/lib/review_screen.dart b/lib/review_screen.dart index c0f7cc5..c58a998 100644 --- a/lib/review_screen.dart +++ b/lib/review_screen.dart @@ -1,6 +1,8 @@ /// Editable list of parsed receipt lines before they are saved as a shop trip. library; +import 'dart:io'; + import 'package:flutter/material.dart'; import 'models/line_item.dart'; @@ -113,6 +115,24 @@ class _ReviewScreenState extends State { setState(() => _shoppedAt = picked); } + Future _openReceipt() async { + final path = widget.imagePath; + if (path == null || path.isEmpty) return; + final file = File(path); + if (!file.existsSync()) return; + await showDialog( + context: context, + builder: (context) { + return Dialog( + insetPadding: const EdgeInsets.all(16), + child: InteractiveViewer( + child: Image.file(file, fit: BoxFit.contain), + ), + ); + }, + ); + } + Future _confirm() async { final supermarket = _resolvedSupermarket; if (supermarket == null) { @@ -160,6 +180,10 @@ class _ReviewScreenState extends State { if (_supermarket != null && !names.contains(_supermarket)) { names.add(_supermarket!); } + final receiptPath = widget.imagePath; + final hasReceipt = receiptPath != null && + receiptPath.isNotEmpty && + File(receiptPath).existsSync(); return Scaffold( appBar: AppBar( @@ -313,6 +337,27 @@ class _ReviewScreenState extends State { ], ), ), + if (hasReceipt) + ExpansionTile( + title: const Text('See receipt'), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: GestureDetector( + onTap: _openReceipt, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + File(receiptPath), + fit: BoxFit.contain, + height: 220, + width: double.infinity, + ), + ), + ), + ), + ], + ), if (widget.rawText.isNotEmpty) ExpansionTile( title: const Text('Raw OCR text'), diff --git a/lib/services/app_settings.dart b/lib/services/app_settings.dart index c5513fb..deb4e35 100644 --- a/lib/services/app_settings.dart +++ b/lib/services/app_settings.dart @@ -68,6 +68,15 @@ class AppSettings extends ChangeNotifier { catalogGeneration++; notifyListeners(); } + + Future wipeAllData() async { + await repository.wipeAllData(); + darkMode = false; + supermarkets = List.from(kDefaultSupermarkets); + catalogGeneration++; + notifyListeners(); + await load(); + } } class SettingsScope extends InheritedNotifier { diff --git a/lib/services/product_repository.dart b/lib/services/product_repository.dart index 7f31a09..2be92b8 100644 --- a/lib/services/product_repository.dart +++ b/lib/services/product_repository.dart @@ -1148,6 +1148,7 @@ class ProductRepository extends ChangeNotifier { double? price, int? quantity, double? unitPrice, + bool clearQuantity = false, }) async { final db = await _database; final rows = await db.query( @@ -1161,8 +1162,13 @@ class ProductRepository extends ChangeNotifier { final values = {}; if (name != null) values['name'] = name.trim(); if (price != null) values['price'] = price; - if (quantity != null) values['quantity'] = quantity; - if (unitPrice != null) values['unit_price'] = unitPrice; + if (clearQuantity) { + values['quantity'] = null; + values['unit_price'] = null; + } else { + if (quantity != null) values['quantity'] = quantity; + if (unitPrice != null) values['unit_price'] = unitPrice; + } if (values.isEmpty) return; await db.update('shop_items', values, where: 'id = ?', whereArgs: [id]); await _recalculateShopTotal(shopId); @@ -1540,15 +1546,45 @@ class ProductRepository extends ChangeNotifier { } Future 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); + try { + final dir = await getApplicationDocumentsDirectory(); + for (final name in ['receipts', 'products', 'bonus_cards']) { + final folder = Directory(p.join(dir.path, name)); + if (await folder.exists()) { + await folder.delete(recursive: true); + } } + } catch (_) { + // Platform folders are unavailable in some unit tests. } } + /// Deletes all app data and restores default supermarkets. + Future wipeAllData() 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'); + await txn.delete('supermarkets'); + await txn.delete('app_settings'); + + await _resetSequence(txn, 'supermarkets'); + 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'); + await _seedSupermarkets(txn); + }); + await clearImageFolders(); + notifyListeners(); + } + Future replaceAllData({ required List> supermarkets, required List> products, diff --git a/lib/shop_detail_screen.dart b/lib/shop_detail_screen.dart index 2c3438c..c4236ee 100644 --- a/lib/shop_detail_screen.dart +++ b/lib/shop_detail_screen.dart @@ -168,6 +168,7 @@ class _ShopDetailScreenState extends State { price: result.price, quantity: result.quantity, unitPrice: result.unitPrice, + clearQuantity: result.quantity == null, ); if (mounted) _load(); } diff --git a/lib/widgets/item_edit_sheet.dart b/lib/widgets/item_edit_sheet.dart index e4b440f..0f0fef9 100644 --- a/lib/widgets/item_edit_sheet.dart +++ b/lib/widgets/item_edit_sheet.dart @@ -2,6 +2,7 @@ library; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../models/line_item.dart'; import '../utils/money.dart'; @@ -26,47 +27,147 @@ class ItemEditSheet extends StatefulWidget { class _ItemEditSheetState extends State { late final TextEditingController _nameController; - late final TextEditingController _priceController; + late final TextEditingController _quantityController; + late final TextEditingController _unitPriceController; + late final TextEditingController _totalController; String? _error; + bool _syncing = false; @override void initState() { super.initState(); final item = widget.item; + final quantity = item?.quantity ?? 1; + final total = item?.price; + final unit = item?.unitPrice ?? + (total != null && quantity > 0 ? roundMoney(total / quantity) : null); + _nameController = TextEditingController(text: item?.name ?? ''); - _priceController = TextEditingController( - text: item == null ? '' : item.price.toStringAsFixed(2), + _quantityController = TextEditingController(text: '$quantity'); + _unitPriceController = TextEditingController( + text: unit == null ? '' : unit.toStringAsFixed(2), ); + _totalController = TextEditingController( + text: total == null ? '' : total.toStringAsFixed(2), + ); + + _quantityController.addListener(_onQuantityChanged); + _unitPriceController.addListener(_onUnitPriceChanged); + _totalController.addListener(_onTotalChanged); } @override void dispose() { + _quantityController.removeListener(_onQuantityChanged); + _unitPriceController.removeListener(_onUnitPriceChanged); + _totalController.removeListener(_onTotalChanged); _nameController.dispose(); - _priceController.dispose(); + _quantityController.dispose(); + _unitPriceController.dispose(); + _totalController.dispose(); super.dispose(); } + int? get _quantity { + final raw = _quantityController.text.trim(); + if (raw.isEmpty) return null; + return int.tryParse(raw); + } + + void _setText(TextEditingController controller, String value) { + if (controller.text == value) return; + controller.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed(offset: value.length), + ); + } + + void _onQuantityChanged() { + if (_syncing) return; + final quantity = _quantity; + if (quantity == null || quantity <= 0) { + setState(() {}); + return; + } + final unit = tryParsePrice(_unitPriceController.text); + final total = tryParsePrice(_totalController.text); + _syncing = true; + if (unit != null) { + _setText(_totalController, roundMoney(quantity * unit).toStringAsFixed(2)); + } else if (total != null) { + _setText( + _unitPriceController, + roundMoney(total / quantity).toStringAsFixed(2), + ); + } + _syncing = false; + setState(() {}); + } + + void _onUnitPriceChanged() { + if (_syncing) return; + final quantity = _quantity; + final unit = tryParsePrice(_unitPriceController.text); + if (quantity == null || quantity <= 0 || unit == null) { + setState(() {}); + return; + } + _syncing = true; + _setText(_totalController, roundMoney(quantity * unit).toStringAsFixed(2)); + _syncing = false; + setState(() {}); + } + + void _onTotalChanged() { + if (_syncing) return; + final quantity = _quantity; + final total = tryParsePrice(_totalController.text); + if (quantity == null || quantity <= 0 || total == null) { + setState(() {}); + return; + } + _syncing = true; + _setText( + _unitPriceController, + roundMoney(total / quantity).toStringAsFixed(2), + ); + _syncing = false; + setState(() {}); + } + void _save() { final name = _nameController.text.trim(); - final price = tryParsePrice(_priceController.text); - if (name.isEmpty || price == null) { - setState(() => _error = 'Enter a product name and a price.'); + final quantity = _quantity; + final unit = tryParsePrice(_unitPriceController.text); + final total = tryParsePrice(_totalController.text); + + if (name.isEmpty) { + setState(() => _error = 'Enter a product name.'); return; } - final rounded = roundMoney(price); - final quantity = widget.item?.quantity; + if (quantity == null || quantity <= 0) { + setState(() => _error = 'Enter a quantity of at least 1.'); + return; + } + if (total == null && unit == null) { + setState(() => _error = 'Enter a unit price or a total.'); + return; + } + + final resolvedTotal = total ?? roundMoney(quantity * unit!); + final resolvedUnit = unit ?? roundMoney(resolvedTotal / quantity); + final hasMulti = quantity > 1; + Navigator.of(context).pop( LineItem( id: widget.item?.id ?? 'manual-${DateTime.now().microsecondsSinceEpoch}', name: name, - price: rounded, - quantity: quantity, - unitPrice: quantity != null && quantity > 0 - ? roundMoney(rounded / quantity) - : widget.item?.unitPrice, - discount: widget.item?.price == rounded + price: roundMoney(resolvedTotal), + quantity: hasMulti ? quantity : null, + unitPrice: hasMulti ? roundMoney(resolvedUnit) : null, + discount: widget.item?.price == roundMoney(resolvedTotal) ? widget.item?.discount ?? 0 : 0, ), @@ -76,6 +177,11 @@ class _ItemEditSheetState extends State { @override Widget build(BuildContext context) { final bottom = MediaQuery.viewInsetsOf(context).bottom; + final quantity = _quantity; + final unit = tryParsePrice(_unitPriceController.text); + final preview = quantity != null && quantity > 1 && unit != null + ? '$quantity × ${formatEuro(unit)}' + : null; return Padding( padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), @@ -95,10 +201,37 @@ class _ItemEditSheetState extends State { autofocus: widget.item == null, ), const SizedBox(height: 12), + Row( + children: [ + SizedBox( + width: 88, + child: TextField( + controller: _quantityController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Qty'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _unitPriceController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration(labelText: 'Unit price'), + ), + ), + ], + ), + const SizedBox(height: 12), TextField( - controller: _priceController, + controller: _totalController, keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Price'), + decoration: InputDecoration( + labelText: 'Total price', + helperText: preview, + ), ), if (_error != null) ...[ const SizedBox(height: 8), diff --git a/lib/widgets/settings_button.dart b/lib/widgets/settings_button.dart index 86ac419..a2cc353 100644 --- a/lib/widgets/settings_button.dart +++ b/lib/widgets/settings_button.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import '../services/app_settings.dart'; import '../services/backup_service.dart'; import '../supermarkets_screen.dart'; +import 'confirm_dialog.dart'; class SettingsButton extends StatelessWidget { const SettingsButton({super.key}); @@ -104,6 +105,36 @@ class _SettingsSheetState extends State<_SettingsSheet> { } } + Future _wipeAllData(AppSettings settings) async { + final confirmed = await confirmAction( + context, + title: 'Delete all data?', + message: + 'This permanently removes all products, shopping trips, lists, ' + 'photos, bonus cards, and settings on this device. ' + 'Export a backup first if you want to keep anything.', + confirmLabel: 'Delete everything', + ); + if (!confirmed || !mounted) return; + + setState(() => _busy = true); + try { + await settings.wipeAllData(); + if (!mounted) return; + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('All data deleted.')), + ); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not delete all data.')), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + void _openSupermarkets(BuildContext context, AppSettings settings) { Navigator.of(context) ..pop() @@ -117,18 +148,20 @@ class _SettingsSheetState extends State<_SettingsSheet> { @override Widget build(BuildContext context) { final settings = SettingsScope.of(context); + final theme = Theme.of(context); + final scheme = theme.colorScheme; final bottom = MediaQuery.viewInsetsOf(context).bottom; + final maxHeight = MediaQuery.sizeOf(context).height * 0.85; return ListenableBuilder( listenable: settings, builder: (context, _) { - return Padding( - padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + return SizedBox( + height: maxHeight, + child: ListView( + padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), children: [ - Text('Settings', style: Theme.of(context).textTheme.titleLarge), + Text('Settings', style: theme.textTheme.titleLarge), const SizedBox(height: 8), SwitchListTile( contentPadding: EdgeInsets.zero, @@ -151,10 +184,10 @@ class _SettingsSheetState extends State<_SettingsSheet> { : () => _openSupermarkets(context, settings), ), const SizedBox(height: 16), - Text('Backup', style: Theme.of(context).textTheme.titleMedium), + Text('Backup', style: theme.textTheme.titleMedium), Text( 'Save everything to a JSON file, or replace this device from a backup.', - style: Theme.of(context).textTheme.bodyMedium, + style: theme.textTheme.bodyMedium, ), const SizedBox(height: 8), Row( @@ -180,6 +213,28 @@ class _SettingsSheetState extends State<_SettingsSheet> { const SizedBox(height: 8), const LinearProgressIndicator(), ], + const SizedBox(height: 32), + Text( + 'Danger zone', + style: theme.textTheme.titleMedium?.copyWith( + color: scheme.error, + ), + ), + const SizedBox(height: 4), + Text( + 'Permanently delete everything on this device.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: scheme.error, + side: BorderSide(color: scheme.error), + ), + onPressed: _busy ? null : () => _wipeAllData(settings), + icon: const Icon(Icons.delete_forever_outlined), + label: const Text('Delete all data'), + ), ], ), ); diff --git a/test/catalog_test.dart b/test/catalog_test.dart index 7fc12d9..2707604 100644 --- a/test/catalog_test.dart +++ b/test/catalog_test.dart @@ -71,6 +71,25 @@ void main() { ]); }); + test('wipeAllData clears products, trips, lists, and settings', () async { + final repository = memoryRepository(); + await repository.saveShop( + supermarket: 'Jumbo', + shoppedAt: DateTime(2026, 8, 12), + items: [LineItem(id: '1', name: 'Milk', price: 1.5)], + ); + await repository.createShoppingList('Weekly'); + await repository.setDarkMode(true); + + await repository.wipeAllData(); + + expect(await repository.getAll(), isEmpty); + expect(await repository.getShops(), isEmpty); + expect(await repository.getShoppingLists(), isEmpty); + expect(await repository.getDarkMode(), isFalse); + expect((await repository.getSupermarkets()).length, greaterThan(0)); + }); + test('deleting all products leaves shopping trips in place', () async { final repository = memoryRepository(); await repository.saveShop(