parser logic added

This commit is contained in:
davmar
2026-08-30 12:24:29 +02:00
parent 35d5c90d81
commit 3b698a3667
28 changed files with 3018 additions and 346 deletions
+181
View File
@@ -0,0 +1,181 @@
/// Drag a crop box over the receipt photo before OCR runs.
library;
import 'dart:io';
import 'dart:typed_data';
import 'package:crop_your_image/crop_your_image.dart';
import 'package:flutter/material.dart';
class CropReceiptScreen extends StatefulWidget {
const CropReceiptScreen({super.key, required this.imagePath});
final String imagePath;
@override
State<CropReceiptScreen> createState() => _CropReceiptScreenState();
}
class _CropReceiptScreenState extends State<CropReceiptScreen> {
final CropController _controller = CropController();
Uint8List? _image;
String? _error;
bool _ready = false;
bool _cropping = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final bytes = await File(widget.imagePath).readAsBytes();
if (!mounted) return;
setState(() => _image = bytes);
} catch (_) {
if (!mounted) return;
setState(() => _error = 'Could not open this photo.');
}
}
void _onCropped(CropResult result) {
switch (result) {
case CropSuccess(:final croppedImage):
_saveAndPop(croppedImage);
case CropFailure():
if (!mounted) return;
setState(() => _cropping = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not crop this photo.')),
);
}
}
Future<void> _saveAndPop(Uint8List bytes) async {
try {
final file = File(
'${Directory.systemTemp.path}/receipity_crop_${DateTime.now().millisecondsSinceEpoch}.jpg',
);
await file.writeAsBytes(bytes, flush: true);
if (!mounted) return;
Navigator.of(context).pop(file.path);
} catch (_) {
if (!mounted) return;
setState(() => _cropping = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not save the cropped photo.')),
);
}
}
void _confirm() {
if (!_ready || _cropping) return;
setState(() => _cropping = true);
_controller.crop();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return PopScope(
canPop: !_cropping,
child: Scaffold(
appBar: AppBar(title: const Text('Crop receipt')),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: _editor(theme)),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Drag the corners over the text you want to read. Pinch to zoom.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _ready && !_cropping ? _confirm : null,
icon: const Icon(Icons.check),
label: Text(_cropping ? 'Cropping…' : 'Use this area'),
),
],
),
),
),
],
),
),
);
}
Widget _editor(ThemeData theme) {
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
_error!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
),
);
}
final image = _image;
if (image == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
fit: StackFit.expand,
children: [
Crop(
image: image,
controller: _controller,
onCropped: _onCropped,
interactive: true,
filterQuality: FilterQuality.medium,
baseColor: theme.colorScheme.surface,
maskColor: Colors.black.withValues(alpha: 0.55),
radius: 8,
progressIndicator: const Center(child: CircularProgressIndicator()),
initialRectBuilder: InitialRectBuilder.withBuilder((
viewportRect,
imageRect,
) {
final dx = imageRect.width * 0.06;
final dy = imageRect.height * 0.06;
return Rect.fromLTRB(
imageRect.left + dx,
imageRect.top + dy,
imageRect.right - dx,
imageRect.bottom - dy,
);
}),
cornerDotBuilder: (size, _) =>
DotControl(color: theme.colorScheme.primary),
onStatusChanged: (status) {
final ready = status == CropStatus.ready;
if (ready == _ready) return;
setState(() => _ready = ready);
},
),
if (_cropping)
const ColoredBox(
color: Color(0x66000000),
child: Center(child: CircularProgressIndicator()),
),
],
);
}
}
+29 -20
View File
@@ -1,4 +1,4 @@
/// Bottom navigation: products, scan, and shop log.
/// Bottom navigation: products, lists, add, and shop log.
library;
import 'package:flutter/material.dart';
@@ -8,6 +8,7 @@ import 'scan_screen.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'shop_log_screen.dart';
import 'shopping_lists_screen.dart';
class HomeShell extends StatefulWidget {
const HomeShell({super.key, required this.repository});
@@ -19,10 +20,10 @@ class HomeShell extends StatefulWidget {
}
class _HomeShellState extends State<HomeShell> {
static const _scanIndex = 1;
static const _shopLogIndex = 2;
static const _addIndex = 2;
static const _shopLogIndex = 3;
int _index = _scanIndex;
int _index = _addIndex;
int _revision = 0;
AppSettings? _settings;
int _seenGeneration = 0;
@@ -59,9 +60,8 @@ class _HomeShellState extends State<HomeShell> {
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Shopping trip saved.')),
);
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Shopping trip saved.')));
});
}
@@ -70,17 +70,21 @@ class _HomeShellState extends State<HomeShell> {
return Scaffold(
body: switch (_index) {
0 => ProductsScreen(
key: ValueKey('products-$_revision'),
repository: widget.repository,
),
1 => ScanScreen(
repository: widget.repository,
onTripSaved: _onTripSaved,
),
key: ValueKey('products-$_revision'),
repository: widget.repository,
),
1 => ShoppingListsScreen(
key: ValueKey('lists-$_revision'),
repository: widget.repository,
),
2 => ScanScreen(
repository: widget.repository,
onTripSaved: _onTripSaved,
),
_ => ShopLogScreen(
key: ValueKey('shops-$_revision'),
repository: widget.repository,
),
key: ValueKey('shops-$_revision'),
repository: widget.repository,
),
},
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
@@ -92,9 +96,14 @@ class _HomeShellState extends State<HomeShell> {
label: 'Products',
),
NavigationDestination(
icon: Icon(Icons.photo_camera_outlined),
selectedIcon: Icon(Icons.photo_camera),
label: 'Scan',
icon: Icon(Icons.checklist_outlined),
selectedIcon: Icon(Icons.checklist),
label: 'Lists',
),
NavigationDestination(
icon: Icon(Icons.add),
selectedIcon: Icon(Icons.add_circle),
label: 'Add',
),
NavigationDestination(
icon: Icon(Icons.receipt_long_outlined),
+4 -2
View File
@@ -6,17 +6,19 @@ import 'package:flutter/material.dart';
import 'home_shell.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'services/receipt_parser.dart';
import 'utils/app_theme.dart';
import 'utils/constants.dart';
void main() {
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await ReceiptParser.loadConfig();
runApp(ReceipityApp());
}
class ReceipityApp extends StatefulWidget {
ReceipityApp({super.key, ProductRepository? repository})
: repository = repository ?? ProductRepository();
: repository = repository ?? ProductRepository();
final ProductRepository repository;
+8
View File
@@ -8,20 +8,27 @@ class LineItem {
required this.price,
this.quantity,
this.unitPrice,
this.discount = 0,
});
final String id;
final String name;
/// Line total after any discount.
final double price;
final int? quantity;
final double? unitPrice;
/// Negative amount when a discount was folded into [price].
final double discount;
LineItem copyWith({
String? id,
String? name,
double? price,
int? quantity,
double? unitPrice,
double? discount,
bool clearQuantity = false,
}) {
return LineItem(
@@ -30,6 +37,7 @@ class LineItem {
price: price ?? this.price,
quantity: clearQuantity ? null : (quantity ?? this.quantity),
unitPrice: clearQuantity ? null : (unitPrice ?? this.unitPrice),
discount: discount ?? this.discount,
);
}
}
+57
View File
@@ -0,0 +1,57 @@
/// A named list of things to buy.
library;
class ShoppingList {
const ShoppingList({
required this.id,
required this.name,
required this.itemCount,
required this.checkedCount,
required this.updatedAt,
});
final int id;
final String name;
final int itemCount;
final int checkedCount;
final DateTime updatedAt;
factory ShoppingList.fromMap(Map<String, Object?> map) {
return ShoppingList(
id: map['id']! as int,
name: map['name']! as String,
itemCount: (map['item_count'] as num?)?.toInt() ?? 0,
checkedCount: (map['checked_count'] as num?)?.toInt() ?? 0,
updatedAt: DateTime.parse(map['updated_at']! as String),
);
}
}
class ShoppingListItem {
const ShoppingListItem({
required this.id,
required this.listId,
required this.name,
required this.checked,
required this.sortOrder,
this.productId,
});
final int id;
final int listId;
final String name;
final bool checked;
final int sortOrder;
final int? productId;
factory ShoppingListItem.fromMap(Map<String, Object?> map) {
return ShoppingListItem(
id: map['id']! as int,
listId: map['list_id']! as int,
name: map['name']! as String,
checked: (map['checked'] as int? ?? 0) == 1,
sortOrder: (map['sort_order'] as num?)?.toInt() ?? 0,
productId: map['product_id'] as int?,
);
}
}
+46 -117
View File
@@ -9,6 +9,7 @@ import 'services/product_repository.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'utils/supermarkets.dart';
import 'widgets/item_edit_sheet.dart';
import 'widgets/store_tag.dart';
class ReviewScreen extends StatefulWidget {
@@ -19,6 +20,8 @@ class ReviewScreen extends StatefulWidget {
required this.repository,
this.imagePath,
this.supermarketNames = kSupermarkets,
this.needsReview = false,
this.detectedStore,
});
final List<LineItem> items;
@@ -26,6 +29,8 @@ class ReviewScreen extends StatefulWidget {
final ProductRepository repository;
final String? imagePath;
final List<String> supermarketNames;
final bool needsReview;
final String? detectedStore;
@override
State<ReviewScreen> createState() => _ReviewScreenState();
@@ -49,6 +54,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
);
if (detected != null) {
_supermarket = detected;
} else if (widget.detectedStore != null) {
_supermarket = widget.detectedStore;
}
}
@@ -63,12 +70,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
}
Future<void> _edit({LineItem? existing}) async {
final result = await showModalBottomSheet<LineItem>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => _ItemEditSheet(item: existing),
);
final result = await showItemEditSheet(context, item: existing);
if (result == null || !mounted) return;
setState(() {
@@ -97,9 +99,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
Future<void> _confirm() async {
final supermarket = _resolvedSupermarket;
if (supermarket == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Select a supermarket.')),
);
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Select a supermarket.')));
return;
}
if (_items.isEmpty) {
@@ -142,7 +143,13 @@ class _ReviewScreenState extends State<ReviewScreen> {
}
return Scaffold(
appBar: AppBar(title: const Text('Review items')),
appBar: AppBar(
title: Text(
widget.rawText.isEmpty && widget.imagePath == null
? 'New trip'
: 'Review items',
),
),
body: Column(
children: [
Padding(
@@ -150,13 +157,27 @@ class _ReviewScreenState extends State<ReviewScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.needsReview) ...[
Material(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Parsed totals do not match the receipt. Check the items before saving.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
),
const SizedBox(height: 12),
],
DropdownButtonFormField<String>(
key: ValueKey(_supermarket),
initialValue: _supermarket,
hint: const Text('Select supermarket'),
decoration: const InputDecoration(
labelText: 'Supermarket',
),
decoration: const InputDecoration(labelText: 'Supermarket'),
items: [
for (final store in names)
DropdownMenuItem(
@@ -177,9 +198,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
TextField(
enabled: !_saving,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Store name',
),
decoration: const InputDecoration(labelText: 'Store name'),
onChanged: (value) => _customStore = value,
),
],
@@ -198,7 +217,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
child: _items.isEmpty
? Center(
child: Text(
'No line items found.\nAdd anything the parser missed.',
'No line items yet.\nAdd products for this trip.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
@@ -231,11 +250,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
},
child: ListTile(
title: Text(item.name),
subtitle: item.quantity == null
? null
: Text(
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
),
subtitle: _itemSubtitle(item),
trailing: Text(
formatEuro(item.price),
style: theme.textTheme.titleMedium,
@@ -274,9 +289,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
FilledButton(
onPressed: _saving ? null : _confirm,
child: Text(
_saving
? 'Saving…'
: 'Save trip · ${formatEuro(total)}',
_saving ? 'Saving…' : 'Save trip · ${formatEuro(total)}',
),
),
],
@@ -287,98 +300,14 @@ class _ReviewScreenState extends State<ReviewScreen> {
),
);
}
}
class _ItemEditSheet extends StatefulWidget {
const _ItemEditSheet({this.item});
final LineItem? item;
@override
State<_ItemEditSheet> createState() => _ItemEditSheetState();
}
class _ItemEditSheetState extends State<_ItemEditSheet> {
late final TextEditingController _nameController;
late final TextEditingController _priceController;
String? _error;
@override
void initState() {
super.initState();
final item = widget.item;
_nameController = TextEditingController(text: item?.name ?? '');
_priceController = TextEditingController(
text: item == null ? '' : item.price.toStringAsFixed(2),
);
}
@override
void dispose() {
_nameController.dispose();
_priceController.dispose();
super.dispose();
}
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.');
return;
}
Navigator.of(context).pop(
LineItem(
id: widget.item?.id ?? 'manual-${DateTime.now().microsecondsSinceEpoch}',
name: name,
price: roundMoney(price),
quantity: widget.item?.quantity,
unitPrice: widget.item?.unitPrice,
),
);
}
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
widget.item == null ? 'Add item' : 'Edit item',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextField(
controller: _nameController,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(labelText: 'Product name'),
autofocus: widget.item == null,
),
const SizedBox(height: 12),
TextField(
controller: _priceController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Price'),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 16),
FilledButton(
onPressed: _save,
child: const Text('Save'),
),
],
),
);
Widget? _itemSubtitle(LineItem item) {
final parts = [
if (item.quantity != null)
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
if (item.discount != 0) 'Discount ${formatEuro(item.discount.abs())}',
];
if (parts.isEmpty) return null;
return Text(parts.join(' · '));
}
}
+47 -22
View File
@@ -8,6 +8,8 @@ import 'package:flutter/services.dart';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import 'package:image_picker/image_picker.dart';
import 'crop_receipt_screen.dart';
import 'models/line_item.dart';
import 'review_screen.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
@@ -17,11 +19,7 @@ import 'utils/supermarkets.dart';
import 'widgets/settings_button.dart';
class ScanScreen extends StatefulWidget {
const ScanScreen({
super.key,
required this.repository,
this.onTripSaved,
});
const ScanScreen({super.key, required this.repository, this.onTripSaved});
final ProductRepository repository;
final VoidCallback? onTripSaved;
@@ -51,7 +49,7 @@ class _ScanScreenState extends State<ScanScreen> {
if (!mounted || response.isEmpty) return;
final file = response.file;
if (file != null) {
await _runOcr(file.path);
await _cropThenOcr(file.path);
}
} on UnimplementedError {
// Desktop / platforms that do not support lost-data recovery.
@@ -59,12 +57,9 @@ class _ScanScreenState extends State<ScanScreen> {
}
Future<void> _capture(ImageSource source) async {
final file = await _picker.pickImage(
source: source,
imageQuality: 95,
);
final file = await _picker.pickImage(source: source, imageQuality: 95);
if (file == null || !mounted) return;
await _runOcr(file.path);
await _cropThenOcr(file.path);
}
Future<void> _scanSampleReceipt() async {
@@ -75,7 +70,7 @@ class _ScanScreenState extends State<ScanScreen> {
);
await file.writeAsBytes(data.buffer.asUint8List(), flush: true);
if (!mounted) return;
await _runOcr(file.path);
await _cropThenOcr(file.path);
} catch (_) {
if (!mounted) return;
setState(() {
@@ -84,6 +79,17 @@ class _ScanScreenState extends State<ScanScreen> {
}
}
Future<void> _cropThenOcr(String imagePath) async {
final croppedPath = await Navigator.of(context).push<String>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => CropReceiptScreen(imagePath: imagePath),
),
);
if (croppedPath == null || !mounted) return;
await _runOcr(croppedPath);
}
Future<void> _runOcr(String imagePath) async {
setState(() {
_busy = true;
@@ -127,14 +133,26 @@ class _ScanScreenState extends State<ScanScreen> {
}
}
Future<void> _enterManually() async {
await _openReview('', items: const []);
}
String _ocrFailureMessage(String? detail) {
const fallback = 'Could not read text from this image.';
if (detail == null || detail.trim().isEmpty) return fallback;
return '$fallback\n$detail';
}
Future<void> _openReview(String rawText) async {
final items = ReceiptParser.parse(rawText);
Future<void> _openReview(
String rawText, {
List<LineItem>? items,
String? imagePath,
}) async {
final parsedItems = items;
ReceiptParseResult? parsed;
if (parsedItems == null) {
parsed = ReceiptParser.parseReceipt(rawText);
}
if (!mounted) return;
final names =
SettingsScope.maybeOf(context)?.supermarkets.map((s) => s.name) ??
@@ -142,11 +160,13 @@ class _ScanScreenState extends State<ScanScreen> {
final saved = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => ReviewScreen(
items: items,
items: parsedItems ?? parsed!.items,
rawText: rawText,
repository: widget.repository,
imagePath: _imagePath,
imagePath: imagePath ?? (items == null ? _imagePath : null),
supermarketNames: names.toList(),
needsReview: parsed != null && !parsed.validationPassed,
detectedStore: parsed?.store,
),
),
);
@@ -182,6 +202,12 @@ class _ScanScreenState extends State<ScanScreen> {
label: const Text('Pick from gallery'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _busy ? null : _enterManually,
icon: const Icon(Icons.edit_note_outlined),
label: const Text('Enter manually'),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _busy ? null : _scanSampleReceipt,
icon: const Icon(Icons.receipt_long_outlined),
@@ -198,13 +224,12 @@ class _ScanScreenState extends State<ScanScreen> {
const SizedBox(height: 16),
const LinearProgressIndicator(),
const SizedBox(height: 8),
Text(
'Reading text…',
style: theme.textTheme.bodyMedium,
),
Text('Reading text…', style: theme.textTheme.bodyMedium),
],
const SizedBox(height: 16),
Expanded(child: _ResultPane(text: _extractedText, error: _error)),
Expanded(
child: _ResultPane(text: _extractedText, error: _error),
),
],
),
),
@@ -236,7 +261,7 @@ class _ResultPane extends StatelessWidget {
if (text.isEmpty) {
return Center(
child: Text(
'Take a photo of a shopping receipt, or pick one from the gallery.\n\nConfirmed trips show up in Shop log.',
'Scan a receipt or enter a trip by hand.\n\nConfirmed trips show up in Shop log.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
+9 -1
View File
@@ -3,7 +3,7 @@ library;
import 'dart:convert';
const int kBackupVersion = 1;
const int kBackupVersion = 2;
class BackupDocument {
const BackupDocument({
@@ -14,6 +14,8 @@ class BackupDocument {
required this.products,
required this.shops,
required this.shopItems,
required this.shoppingLists,
required this.shoppingListItems,
required this.images,
});
@@ -24,6 +26,8 @@ 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?>> shoppingLists;
final List<Map<String, Object?>> shoppingListItems;
final Map<String, String> images;
Map<String, Object?> toJson() => {
@@ -35,6 +39,8 @@ class BackupDocument {
'products': products,
'shops': shops,
'shopItems': shopItems,
'shoppingLists': shoppingLists,
'shoppingListItems': shoppingListItems,
'images': images,
};
@@ -60,6 +66,8 @@ class BackupDocument {
products: _maps(decoded['products']),
shops: _maps(decoded['shops']),
shopItems: _maps(decoded['shopItems']),
shoppingLists: _maps(decoded['shoppingLists']),
shoppingListItems: _maps(decoded['shoppingListItems']),
images: _strings(decoded['images']),
);
}
+9 -5
View File
@@ -57,6 +57,8 @@ class BackupService {
products: rewrittenProducts,
shops: rewrittenShops,
shopItems: await repository.dumpTable('shop_items'),
shoppingLists: await repository.dumpTable('shopping_lists'),
shoppingListItems: await repository.dumpTable('shopping_list_items'),
images: images,
);
}
@@ -114,7 +116,10 @@ class BackupService {
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['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) {
@@ -141,14 +146,13 @@ class BackupService {
products: products,
shops: shops,
shopItems: document.shopItems,
shoppingLists: document.shoppingLists,
shoppingListItems: document.shoppingListItems,
darkMode: document.darkMode,
);
}
Future<String?> _restoreImage(
Object? key,
Map<String, String> images,
) async {
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;
+306 -18
View File
@@ -11,6 +11,7 @@ import '../models/line_item.dart';
import '../models/product.dart';
import '../models/purchase.dart';
import '../models/shop.dart';
import '../models/shopping_list.dart';
import '../models/supermarket.dart';
import '../utils/money.dart';
import '../utils/supermarkets.dart';
@@ -27,15 +28,20 @@ class ProductRepository {
}
Future<Database> _open() async {
final path = databasePath ??
final path =
databasePath ??
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
return openDatabase(
path,
version: 4,
version: 5,
onConfigure: (db) async {
await db.execute('PRAGMA foreign_keys = ON');
},
onCreate: (db, version) async {
await _createProducts(db);
await _createShopTables(db);
await _createSettingsTables(db);
await _createShoppingListTables(db);
await _seedSupermarkets(db);
},
onUpgrade: (db, oldVersion, newVersion) async {
@@ -55,6 +61,9 @@ class ProductRepository {
if (oldVersion < 4) {
await db.execute('ALTER TABLE products ADD COLUMN category TEXT');
}
if (oldVersion < 5) {
await _createShoppingListTables(db);
}
},
);
}
@@ -133,6 +142,29 @@ class ProductRepository {
''');
}
Future<void> _createShoppingListTables(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS shopping_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE IF NOT EXISTS shopping_list_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL,
product_id INTEGER,
name TEXT NOT NULL,
checked INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (list_id) REFERENCES shopping_lists(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id)
)
''');
}
static String normalizeName(String name) =>
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
@@ -315,13 +347,11 @@ class ProductRepository {
Future<List<String>> getUsedCategories() async {
final db = await _database;
final rows = await db.rawQuery(
'''
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,
@@ -455,6 +485,238 @@ class ProductRepository {
return rows.map(ShopItem.fromMap).toList();
}
/// Removes a trip and its line items. Catalog products are left unchanged.
Future<void> deleteShop(int id) async {
final shop = await getShop(id);
final db = await _database;
await db.transaction((txn) async {
await txn.delete('shop_items', where: 'shop_id = ?', whereArgs: [id]);
await txn.delete('shops', where: 'id = ?', whereArgs: [id]);
});
final path = shop?.receiptImagePath;
if (path == null || path.isEmpty) return;
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
/// Removes one trip line. Catalog products are left unchanged.
Future<void> deleteShopItem(int id) async {
final db = await _database;
final rows = await db.query(
'shop_items',
columns: ['shop_id'],
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return;
final shopId = rows.first['shop_id']! as int;
await db.delete('shop_items', where: 'id = ?', whereArgs: [id]);
await _recalculateShopTotal(shopId);
}
Future<void> updateShopItem({
required int id,
String? name,
double? price,
int? quantity,
double? unitPrice,
}) async {
final db = await _database;
final rows = await db.query(
'shop_items',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return;
final shopId = rows.first['shop_id']! as int;
final values = <String, Object?>{};
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 (values.isEmpty) return;
await db.update('shop_items', values, where: 'id = ?', whereArgs: [id]);
await _recalculateShopTotal(shopId);
}
Future<void> _recalculateShopTotal(int shopId) async {
final db = await _database;
final rows = await db.rawQuery(
'SELECT COALESCE(SUM(price), 0) AS total FROM shop_items WHERE shop_id = ?',
[shopId],
);
final total = roundMoney((rows.first['total'] as num).toDouble());
await db.update(
'shops',
{'total': total},
where: 'id = ?',
whereArgs: [shopId],
);
}
Future<List<ShoppingList>> getShoppingLists() async {
final db = await _database;
final rows = await db.rawQuery('''
SELECT
shopping_lists.id,
shopping_lists.name,
shopping_lists.updated_at,
COUNT(shopping_list_items.id) AS item_count,
COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count
FROM shopping_lists
LEFT JOIN shopping_list_items
ON shopping_list_items.list_id = shopping_lists.id
GROUP BY shopping_lists.id
ORDER BY shopping_lists.updated_at DESC, shopping_lists.id DESC
''');
return rows.map(ShoppingList.fromMap).toList();
}
Future<ShoppingList?> getShoppingList(int id) async {
final db = await _database;
final rows = await db.rawQuery(
'''
SELECT
shopping_lists.id,
shopping_lists.name,
shopping_lists.updated_at,
COUNT(shopping_list_items.id) AS item_count,
COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count
FROM shopping_lists
LEFT JOIN shopping_list_items
ON shopping_list_items.list_id = shopping_lists.id
WHERE shopping_lists.id = ?
GROUP BY shopping_lists.id
''',
[id],
);
if (rows.isEmpty) return null;
return ShoppingList.fromMap(rows.first);
}
Future<int> createShoppingList(String name) async {
final db = await _database;
final now = DateTime.now().toIso8601String();
return db.insert('shopping_lists', {
'name': name.trim(),
'created_at': now,
'updated_at': now,
});
}
Future<void> renameShoppingList(int id, String name) async {
final db = await _database;
await db.update(
'shopping_lists',
{'name': name.trim(), 'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> deleteShoppingList(int id) async {
final db = await _database;
await db.transaction((txn) async {
await txn.delete(
'shopping_list_items',
where: 'list_id = ?',
whereArgs: [id],
);
await txn.delete('shopping_lists', where: 'id = ?', whereArgs: [id]);
});
}
Future<List<ShoppingListItem>> getShoppingListItems(int listId) async {
final db = await _database;
final rows = await db.query(
'shopping_list_items',
where: 'list_id = ?',
whereArgs: [listId],
orderBy: 'checked ASC, sort_order ASC, id ASC',
);
return rows.map(ShoppingListItem.fromMap).toList();
}
Future<int> addShoppingListItem({
required int listId,
required String name,
int? productId,
}) async {
final db = await _database;
final max = Sqflite.firstIntValue(
await db.rawQuery(
'SELECT MAX(sort_order) FROM shopping_list_items WHERE list_id = ?',
[listId],
),
);
final id = await db.insert('shopping_list_items', {
'list_id': listId,
'product_id': productId,
'name': name.trim(),
'checked': 0,
'sort_order': (max ?? -1) + 1,
});
await _touchShoppingList(listId);
return id;
}
Future<void> updateShoppingListItem({
required int id,
String? name,
bool? checked,
int? productId,
}) async {
final db = await _database;
final rows = await db.query(
'shopping_list_items',
columns: ['list_id'],
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return;
final values = <String, Object?>{};
if (name != null) values['name'] = name.trim();
if (checked != null) values['checked'] = checked ? 1 : 0;
if (productId != null) values['product_id'] = productId;
if (values.isEmpty) return;
await db.update(
'shopping_list_items',
values,
where: 'id = ?',
whereArgs: [id],
);
await _touchShoppingList(rows.first['list_id']! as int);
}
Future<void> deleteShoppingListItem(int id) async {
final db = await _database;
final rows = await db.query(
'shopping_list_items',
columns: ['list_id'],
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return;
await db.delete('shopping_list_items', where: 'id = ?', whereArgs: [id]);
await _touchShoppingList(rows.first['list_id']! as int);
}
Future<void> _touchShoppingList(int id) async {
final db = await _database;
await db.update(
'shopping_lists',
{'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
}
Future<bool> getDarkMode() async {
final db = await _database;
final rows = await db.query(
@@ -469,16 +731,18 @@ class ProductRepository {
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,
);
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');
final rows = await db.query(
'supermarkets',
orderBy: 'sort_order ASC, id ASC',
);
if (rows.isEmpty) {
await _seedSupermarkets(db);
return getSupermarkets();
@@ -579,9 +843,13 @@ class ProductRepository {
required List<Map<String, Object?>> shops,
required List<Map<String, Object?>> shopItems,
required bool darkMode,
List<Map<String, Object?>> shoppingLists = const [],
List<Map<String, Object?>> shoppingListItems = 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_items');
await txn.delete('shops');
await txn.delete('products');
@@ -600,6 +868,15 @@ class ProductRepository {
for (final row in shopItems) {
await txn.insert('shop_items', _sqlRow(row, _shopItemColumns));
}
for (final row in shoppingLists) {
await txn.insert('shopping_lists', _sqlRow(row, _shoppingListColumns));
}
for (final row in shoppingListItems) {
await txn.insert(
'shopping_list_items',
_sqlRow(row, _shoppingListItemColumns),
);
}
await txn.insert('app_settings', {
'key': 'dark_mode',
'value': darkMode ? '1' : '0',
@@ -609,15 +886,12 @@ class ProductRepository {
await _resetSequence(txn, 'products');
await _resetSequence(txn, 'shops');
await _resetSequence(txn, 'shop_items');
await _resetSequence(txn, 'shopping_lists');
await _resetSequence(txn, 'shopping_list_items');
});
}
static const _supermarketColumns = {
'id',
'name',
'color',
'sort_order',
};
static const _supermarketColumns = {'id', 'name', 'color', 'sort_order'};
static const _productColumns = {
'id',
'name',
@@ -649,6 +923,20 @@ class ProductRepository {
'quantity',
'unit_price',
};
static const _shoppingListColumns = {
'id',
'name',
'created_at',
'updated_at',
};
static const _shoppingListItemColumns = {
'id',
'list_id',
'product_id',
'name',
'checked',
'sort_order',
};
Map<String, Object?> _sqlRow(
Map<String, Object?> source,
+179
View File
@@ -0,0 +1,179 @@
/// Keyword and regex config loaded from receipt-parsing-keywords.json.
library;
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
class ReceiptParseConfig {
ReceiptParseConfig({
required this.knownStores,
required this.columnHeaderKeywords,
required this.separatorPattern,
required this.quantityStandalone,
required this.quantityInline,
required this.discountKeywords,
required this.negativeAmount,
required this.totalDiscountKeywords,
required this.subtotalKeywords,
required this.grandTotalKeywords,
required this.taxInclusiveTotalKeywords,
required this.paymentKeywords,
required this.taxKeywords,
required this.categoryRow,
required this.trailingCategoryLetter,
required this.noiseKeywords,
required this.itemCountKeywords,
});
final List<String> knownStores;
final List<String> columnHeaderKeywords;
final RegExp separatorPattern;
final RegExp quantityStandalone;
final RegExp quantityInline;
final List<String> discountKeywords;
final RegExp negativeAmount;
final List<String> totalDiscountKeywords;
final List<String> subtotalKeywords;
final List<String> grandTotalKeywords;
final List<String> taxInclusiveTotalKeywords;
final List<String> paymentKeywords;
final List<String> taxKeywords;
final RegExp categoryRow;
final RegExp trailingCategoryLetter;
final List<String> noiseKeywords;
final List<String> itemCountKeywords;
factory ReceiptParseConfig.fromJson(Map<String, dynamic> json) {
List<String> list(String section, [String key = 'list']) {
final block = json[section];
if (block is! Map) return const [];
final value = block[key];
if (value is! List) return const [];
return [
for (final item in value)
if (item is String) item,
];
}
RegExp rx(String section, String key, {bool caseInsensitive = true}) {
final block = json[section];
final value = block is Map ? block[key] : null;
final source = value is String ? value : '^\$';
return RegExp(source, caseSensitive: !caseInsensitive);
}
return ReceiptParseConfig(
knownStores: list('known_stores'),
columnHeaderKeywords: list('column_header_keywords'),
separatorPattern: rx('column_header_keywords', 'separator_pattern'),
quantityStandalone: rx(
'quantity_modifier_pattern',
'regex',
caseInsensitive: true,
),
quantityInline: rx(
'quantity_modifier_pattern',
'inline_regex',
caseInsensitive: true,
),
discountKeywords: list('discount_keywords'),
negativeAmount: rx('discount_keywords', 'negative_amount_pattern'),
totalDiscountKeywords: list(
'discount_keywords',
'receipt_level_total_discount_keywords',
),
subtotalKeywords: list('total_keywords', 'subtotal_keywords'),
grandTotalKeywords: list('total_keywords', 'grand_total_keywords'),
taxInclusiveTotalKeywords: list(
'total_keywords',
'tax_inclusive_total_keywords',
),
paymentKeywords: list('payment_keywords'),
taxKeywords: list('tax_breakdown_keywords'),
categoryRow: rx('tax_breakdown_keywords', 'category_row_pattern'),
trailingCategoryLetter: rx(
'tax_breakdown_keywords',
'trailing_category_letter_pattern',
),
noiseKeywords: list('noise_footer_keywords'),
itemCountKeywords: list('line_item_count_keywords'),
);
}
static const assetPath = 'receipt-parsing-keywords.json';
/// True if [line] matches a config keyword.
///
/// Short tokens (3 characters or fewer) use a word boundary so `PIN` does
/// not match `SPINAZIE`. Longer tokens use a case-insensitive substring,
/// and a letter-only compact form so OCR splits like `Betaal d:` still
/// match `BETAALD`.
bool matchesKeyword(String line, String keyword) {
final upper = line.toUpperCase();
final key = keyword.toUpperCase();
if (key.length <= 3) {
return RegExp('\\b${RegExp.escape(key)}\\b').hasMatch(upper);
}
if (upper.contains(key)) return true;
final compactLine = upper.replaceAll(RegExp(r'[^A-Z0-9]'), '');
final compactKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
return compactKey.length >= 4 && compactLine.contains(compactKey);
}
bool isStandaloneKeywordLine(String line, List<String> keywords) {
final compact = line.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
for (final keyword in keywords) {
final key = keyword.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
if (key.isEmpty) continue;
if (compact == key) return true;
if (compact.startsWith(key) && compact.length <= key.length + 3) {
return true;
}
}
return false;
}
bool matchesAny(String line, List<String> keywords) {
for (final keyword in keywords) {
if (matchesKeyword(line, keyword)) return true;
}
return false;
}
}
ReceiptParseConfig? _loadedConfig;
ReceiptParseConfig get receiptParseConfig {
if (_loadedConfig != null) return _loadedConfig!;
final file = File(ReceiptParseConfig.assetPath);
if (file.existsSync()) {
_loadedConfig = ReceiptParseConfig.fromJson(
jsonDecode(file.readAsStringSync()) as Map<String, dynamic>,
);
return _loadedConfig!;
}
throw StateError(
'Receipt keyword config was not loaded. Call ReceiptParser.loadConfig().',
);
}
void setReceiptParseConfig(ReceiptParseConfig config) {
_loadedConfig = config;
}
Future<void> loadReceiptParseConfig() async {
try {
final raw = await rootBundle.loadString(ReceiptParseConfig.assetPath);
_loadedConfig = ReceiptParseConfig.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
} catch (_) {
final file = File(ReceiptParseConfig.assetPath);
if (!file.existsSync()) rethrow;
_loadedConfig = ReceiptParseConfig.fromJson(
jsonDecode(file.readAsStringSync()) as Map<String, dynamic>,
);
}
}
+499 -113
View File
@@ -1,57 +1,31 @@
/// Turns raw OCR text into [LineItem]s using line-end prices and a keyword filter.
/// Turns raw OCR text into [LineItem]s using receipt-parsing-keywords.json.
library;
import '../models/line_item.dart';
import '../utils/money.dart';
import 'receipt_parse_config.dart';
/// Headers, payments, and totals — never treated as products.
const Set<String> kHeaderKeywords = {
'TOTAL',
'SUBTOTAL',
'TOTAAL',
'TAX',
'VAT',
'BTW',
'CASH',
'CARD',
'CHANGE',
'VISA',
'MASTERCARD',
'MAESTRO',
'PIN',
'DEBIT',
'CREDIT',
'OMSCHRIJVING',
'BEDRAG',
'AMOUNT',
'DESCRIPTION',
'THANK',
'BEDANKT',
'WISSELGELD',
'BETAALD',
'BETAAL',
'CONTANT',
'KASSA',
'RECEIPT',
'WWW',
'HTTP',
};
export 'receipt_parse_config.dart' show loadReceiptParseConfig;
/// Discount / promo rows. Paired with a price, then dropped.
const Set<String> kDropKeywords = {
'DISCOUNT',
'KORTING',
'ACTIE',
};
/// Result of a full receipt parse, including totals used for review.
class ReceiptParseResult {
const ReceiptParseResult({
required this.items,
this.store,
this.receiptTotal,
this.totalDiscount = 0,
this.validationPassed = true,
});
final _separatorPattern = RegExp(r'^[=\-_.:\s]+$');
final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b');
final _phonePattern = RegExp(r'^\d{8,}$');
final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}$");
final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false);
final List<LineItem> items;
final String? store;
final double? receiptTotal;
final double totalDiscount;
final bool validationPassed;
}
class _QtyLine {
const _QtyLine({required this.count, required this.unitPrice, this.total});
class _Qty {
const _Qty({required this.count, required this.unitPrice, this.total});
final int count;
final double unitPrice;
@@ -60,6 +34,59 @@ class _QtyLine {
double get lineTotal => total ?? roundMoney(count * unitPrice);
}
class _Pending {
_Pending({
required this.name,
this.unitPrice,
this.quantity = 1,
this.lineTotal,
});
String name;
double? unitPrice;
int quantity;
double? lineTotal;
double discount = 0;
}
enum _Kind {
skip,
item,
nameOnly,
quantity,
discount,
grandTotal,
totalDiscount,
priceOnly,
}
class _Line {
const _Line({
required this.kind,
required this.raw,
this.name,
this.amount,
this.qty,
});
final _Kind kind;
final String raw;
final String? name;
final double? amount;
final _Qty? qty;
}
final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b');
final _phonePattern = RegExp(r'^\d{8,}$');
final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}[A-Za-z]?$");
final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false);
/// Jumbo-style OCR often adds a line total after `N X unit`.
final _qtyWithTotal = RegExp(
r'^\s*(\d+)\s*[xX×]\s*(-?\d+[,.]\d{2})(?:\s+(?:PER\s+STUK|PER\s+KG|ST\.?|STUKS?))?(?:\s+(-?\d+[,.]\d{2}))?\s*$',
caseSensitive: false,
);
class ReceiptParser {
ReceiptParser._();
@@ -67,74 +94,198 @@ class ReceiptParser {
static String _id() => 'item-${++_nextId}';
/// Parses [rawText] into product rows.
///
/// Prefers same-line `name … price` (with a following `2 x 1.50` line when
/// the name has no price). If OCR split the receipt into a name column and a
/// price column, falls back to pairing those columns.
static List<LineItem> parse(String rawText) {
static Future<void> loadConfig() => loadReceiptParseConfig();
static List<LineItem> parse(String rawText) => parseReceipt(rawText).items;
static ReceiptParseResult parseReceipt(String rawText) {
final config = receiptParseConfig;
final lines = rawText
.split('\n')
.map((line) => line.trim())
.map(_preprocess)
.where((line) => line.isNotEmpty)
.toList();
final inline = _parseInline(lines);
final columns = _parseColumns(lines);
final chosen = columns.length > inline.length ? columns : inline;
return chosen.where(_keepItem).toList();
if (_looksLikeColumnOcr(lines, config)) {
return _parseColumns(lines, config, rawText);
}
return _parseSequential(lines, config, rawText);
}
static List<LineItem> _parseInline(List<String> lines) {
final items = <LineItem>[];
static String _preprocess(String line) =>
line.trim().replaceAll(RegExp(r'\s+'), ' ');
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
if (_shouldSkipLine(line) || isPriceOnly(line)) continue;
static String _stripTax(String line, ReceiptParseConfig config) {
return line.replaceFirst(config.trailingCategoryLetter, '').trim();
}
final qty = _parseQty(line);
if (qty != null) continue;
final trailing = trailingPricePattern.firstMatch(line);
if (trailing != null) {
final name = trailing.group(1)!.trim();
final price = tryParsePrice(trailing.group(2)!);
if (name.isEmpty || price == null || _shouldSkipLine(name)) continue;
items.add(LineItem(id: _id(), name: name, price: price));
continue;
static bool _looksLikeColumnOcr(
List<String> lines,
ReceiptParseConfig config,
) {
var run = 0;
var maxRun = 0;
var prices = 0;
var names = 0;
for (final line in lines) {
final stripped = _stripTax(line, config);
if (isPriceOnly(stripped)) {
prices++;
run++;
if (run > maxRun) maxRun = run;
} else {
run = 0;
if (_parseQty(stripped, config) == null &&
!_isIgnorable(stripped, config)) {
names++;
}
}
}
return maxRun >= 4 && prices >= 4 && names >= 4;
}
if (i + 1 >= lines.length) continue;
final nextQty = _parseQty(lines[i + 1]);
if (nextQty == null) continue;
items.add(
LineItem(
id: _id(),
name: line,
price: nextQty.lineTotal,
quantity: nextQty.count,
unitPrice: nextQty.unitPrice,
),
);
i++;
static ReceiptParseResult _parseSequential(
List<String> lines,
ReceiptParseConfig config,
String rawText,
) {
final items = <LineItem>[];
_Pending? pending;
var receiptTotal = _readAmountAfterKeyword(
lines,
config,
config.grandTotalKeywords,
skipIf: (line) => config.matchesAny(line, [
...config.totalDiscountKeywords,
...config.taxInclusiveTotalKeywords,
...config.subtotalKeywords,
]),
);
var totalDiscount = 0.0;
var stop = false;
void flush() {
final current = pending;
pending = null;
if (current == null) return;
final total = current.lineTotal ?? current.unitPrice;
if (total == null) return;
items.add(_toItem(current, total));
}
return items;
for (var i = 0; i < lines.length && !stop; i++) {
final classified = _classify(lines[i], config);
switch (classified.kind) {
case _Kind.skip:
continue;
case _Kind.item:
flush();
pending = _Pending(
name: classified.name!,
unitPrice: classified.amount,
quantity: classified.qty?.count ?? 1,
lineTotal: classified.qty == null
? classified.amount
: classified.qty!.lineTotal,
);
if (classified.qty != null) {
pending!.unitPrice = classified.qty!.unitPrice;
}
case _Kind.nameOnly:
flush();
pending = _Pending(name: classified.name ?? classified.raw);
case _Kind.quantity:
final qty = classified.qty!;
if (pending != null) {
pending!
..quantity = qty.count
..unitPrice = qty.unitPrice
..lineTotal = qty.lineTotal;
}
case _Kind.discount:
var amount = classified.amount;
if (amount == null && i + 1 < lines.length) {
amount = tryParsePrice(_stripTax(lines[i + 1], config));
if (amount != null) i++;
}
if (amount == null) continue;
if (amount > 0) amount = -amount;
if (pending != null) {
pending!.discount += amount;
pending!.lineTotal = roundMoney(
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
);
} else {
totalDiscount += amount;
}
case _Kind.totalDiscount:
var amount = classified.amount;
if (amount == null && i + 1 < lines.length) {
amount = tryParsePrice(_stripTax(lines[i + 1], config));
if (amount != null) i++;
}
if (amount != null) {
totalDiscount += amount > 0 ? -amount : amount;
}
case _Kind.grandTotal:
flush();
if (classified.amount != null) {
receiptTotal = classified.amount;
stop = true;
}
case _Kind.priceOnly:
final amount = classified.amount!;
if (amount < 0 && pending != null) {
pending!.discount += amount;
pending!.lineTotal = roundMoney(
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
);
}
}
}
flush();
return _result(
items: items,
rawText: rawText,
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
);
}
static List<LineItem> _parseColumns(List<String> lines) {
static ReceiptParseResult _parseColumns(
List<String> lines,
ReceiptParseConfig config,
String rawText,
) {
final names = <({String name, int? quantity, double? unitPrice})>[];
final prices = <double>[];
final discountSlots = <int>[];
final discounts = <double>[];
for (final line in lines) {
for (final raw in lines) {
final line = _stripTax(raw, config);
if (isPriceOnly(line)) {
final price = tryParsePrice(line);
if (price != null) prices.add(price);
if (price == null) continue;
if (price < 0) {
discounts.add(price);
} else {
prices.add(price);
}
continue;
}
if (_isIgnorable(line, config)) continue;
if (config.matchesAny(line, [
...config.grandTotalKeywords,
...config.subtotalKeywords,
...config.taxInclusiveTotalKeywords,
...config.totalDiscountKeywords,
])) {
continue;
}
if (_shouldSkipLine(line)) continue;
final qty = _parseQty(line);
final qty = _parseQty(line, config);
if (qty != null) {
if (names.isNotEmpty) {
final last = names.removeLast();
@@ -147,11 +298,16 @@ class ReceiptParser {
continue;
}
if (config.matchesAny(line, config.discountKeywords)) {
if (names.isNotEmpty) discountSlots.add(names.length - 1);
continue;
}
names.add((name: line, quantity: null, unitPrice: null));
}
final count = names.length < prices.length ? names.length : prices.length;
return [
final items = <LineItem>[
for (var i = 0; i < count; i++)
LineItem(
id: _id(),
@@ -161,44 +317,274 @@ class ReceiptParser {
unitPrice: names[i].unitPrice,
),
];
for (var i = 0; i < discountSlots.length && i < discounts.length; i++) {
final index = discountSlots[i];
if (index < 0 || index >= items.length) continue;
final item = items[index];
final discount = discounts[i];
items[index] = item.copyWith(
price: roundMoney(item.price + discount),
discount: roundMoney(item.discount + discount),
);
}
final receiptTotal = _readAmountAfterKeyword(
lines,
config,
config.grandTotalKeywords,
skipIf: (line) => config.matchesAny(line, [
...config.totalDiscountKeywords,
...config.taxInclusiveTotalKeywords,
...config.subtotalKeywords,
]),
);
var totalDiscount = discounts.fold<double>(0, (sum, value) => sum + value);
totalDiscount +=
_readAmountAfterKeyword(lines, config, config.totalDiscountKeywords) ??
0;
return _result(
items: items,
rawText: rawText,
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
);
}
static bool _keepItem(LineItem item) {
if (item.price < 0) return false;
if (item.name.trim().isEmpty) return false;
return !_containsKeyword(item.name, kDropKeywords);
static LineItem _toItem(_Pending pending, double total) {
return LineItem(
id: _id(),
name: pending.name,
price: roundMoney(total),
quantity: pending.quantity == 1 ? null : pending.quantity,
unitPrice: pending.quantity == 1 ? null : pending.unitPrice,
discount: pending.discount == 0 ? 0 : roundMoney(pending.discount),
);
}
static bool _shouldSkipLine(String line) {
if (_separatorPattern.hasMatch(line)) return true;
static ReceiptParseResult _result({
required List<LineItem> items,
required String rawText,
double? receiptTotal,
double totalDiscount = 0,
}) {
final kept = items
.where((item) => item.name.trim().isNotEmpty && item.price > 0)
.toList();
final sum = roundMoney(
kept.fold<double>(0, (total, item) => total + item.price),
);
final passed =
receiptTotal == null ||
(sum - receiptTotal).abs() <= 0.01 ||
(sum + totalDiscount - receiptTotal).abs() <= 0.01;
return ReceiptParseResult(
items: kept,
store: _detectStore(rawText),
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
validationPassed: passed,
);
}
static String? _detectStore(String rawText) {
final config = receiptParseConfig;
final header = rawText
.split('\n')
.map(_preprocess)
.where((line) => line.isNotEmpty)
.take(8)
.join('\n')
.toUpperCase();
final stores = [...config.knownStores]
..sort((a, b) => b.length.compareTo(a.length));
for (final store in stores) {
if (RegExp('\\b${RegExp.escape(store)}\\b').hasMatch(header)) {
return _canonicalStore(store);
}
}
return null;
}
static String _canonicalStore(String raw) {
const aliases = {
'AH': 'Albert Heijn',
'ALBERT HEIJN': 'Albert Heijn',
'JUMBO': 'Jumbo',
'LIDL': 'Lidl',
'KRUIDVAT': 'Kruidvat',
'ALDI': 'Aldi',
'PLUS': 'Plus',
'DIRK': 'Dirk',
'COOP': 'Coop',
'SPAR': 'SPAR',
'VOMAR': 'Vomar',
'EKOPLAZA': 'Ekoplaza',
'HOOGVLIET': 'Hoogvliet',
'ACTION': 'Action',
'ETOS': 'Etos',
'WORLD TOKO': 'World Toko',
};
return aliases[raw.toUpperCase()] ?? raw;
}
static double? _readAmountAfterKeyword(
List<String> lines,
ReceiptParseConfig config,
List<String> keywords, {
bool Function(String line)? skipIf,
}) {
for (var i = 0; i < lines.length; i++) {
final line = _stripTax(lines[i], config);
if (!config.matchesAny(line, keywords)) continue;
if (skipIf != null && skipIf(line)) continue;
final trailing = _trailingPrice(line, config);
if (trailing != null) return trailing;
if (i + 1 < lines.length) {
final next = tryParsePrice(_stripTax(lines[i + 1], config));
if (next != null) return next;
}
}
return null;
}
static _Line _classify(String raw, ReceiptParseConfig config) {
final line = _stripTax(raw, config);
if (_isIgnorable(line, config)) {
return _Line(kind: _Kind.skip, raw: line);
}
if (config.matchesAny(line, config.totalDiscountKeywords)) {
return _Line(
kind: _Kind.totalDiscount,
raw: line,
amount: _trailingPrice(line, config),
);
}
if (config.matchesAny(line, config.taxInclusiveTotalKeywords) ||
config.matchesAny(line, config.subtotalKeywords)) {
return _Line(kind: _Kind.skip, raw: line);
}
if (config.matchesAny(line, config.grandTotalKeywords)) {
return _Line(
kind: _Kind.grandTotal,
raw: line,
amount: _trailingPrice(line, config),
);
}
final qty = _parseQty(line, config);
if (qty != null) {
return _Line(kind: _Kind.quantity, raw: line, qty: qty);
}
final isDiscount =
config.matchesAny(line, config.discountKeywords) ||
config.negativeAmount.hasMatch(line);
if (isDiscount &&
(config.matchesAny(line, config.discountKeywords) ||
isPriceOnly(line))) {
return _Line(
kind: _Kind.discount,
raw: line,
name: line,
amount: _trailingPrice(line, config) ?? tryParsePrice(line),
);
}
if (isPriceOnly(line)) {
return _Line(
kind: _Kind.priceOnly,
raw: line,
amount: tryParsePrice(line),
);
}
final inline = config.quantityInline.firstMatch(line);
final trailing = _trailingPrice(line, config);
if (trailing != null) {
var name = line.replaceFirst(RegExp(r'\s+-?\d+[.,]\d{2}\s*$'), '').trim();
_Qty? inlineQty;
if (inline != null) {
name = line.substring(0, inline.start).trim();
final count = int.tryParse(inline.group(1)!);
final unit = tryParsePrice(inline.group(2)!);
if (count != null && unit != null) {
inlineQty = _Qty(count: count, unitPrice: unit, total: trailing);
}
}
if (name.isEmpty || _isIgnorable(name, config)) {
return _Line(kind: _Kind.skip, raw: line);
}
return _Line(
kind: _Kind.item,
raw: line,
name: name,
amount: trailing,
qty: inlineQty,
);
}
if (inline != null) {
final name = line.substring(0, inline.start).trim();
final count = int.tryParse(inline.group(1)!);
final unit = tryParsePrice(inline.group(2)!);
if (name.isNotEmpty && count != null && unit != null) {
return _Line(
kind: _Kind.item,
raw: line,
name: name,
amount: roundMoney(count * unit),
qty: _Qty(count: count, unitPrice: unit),
);
}
}
return _Line(kind: _Kind.nameOnly, raw: line, name: line);
}
static bool _isIgnorable(String line, ReceiptParseConfig config) {
if (config.separatorPattern.hasMatch(line)) return true;
if (_urlPattern.hasMatch(line)) return true;
if (_postalPattern.hasMatch(line)) return true;
if (_phonePattern.hasMatch(line.replaceAll(RegExp(r'\s'), ''))) {
return true;
}
if (_streetPattern.hasMatch(line)) return true;
if (_containsKeyword(line, kHeaderKeywords)) return true;
return false;
}
static bool _containsKeyword(String line, Set<String> keywords) {
final upper = line.toUpperCase();
for (final keyword in keywords) {
if (RegExp('\\b${RegExp.escape(keyword)}\\b').hasMatch(upper)) {
return true;
}
if (config.categoryRow.hasMatch(line)) return true;
if (config.isStandaloneKeywordLine(line, config.columnHeaderKeywords)) {
return true;
}
if (config.isStandaloneKeywordLine(line, config.itemCountKeywords)) {
return true;
}
if (config.matchesAny(line, config.taxKeywords)) return true;
if (config.matchesAny(line, config.paymentKeywords)) return true;
if (config.matchesAny(line, config.noiseKeywords)) return true;
return false;
}
static _QtyLine? _parseQty(String line) {
final match = quantityPattern.firstMatch(line.trim());
static double? _trailingPrice(String line, ReceiptParseConfig config) {
final match = RegExp(r'(-?\d+[.,]\d{2})\s*$').firstMatch(line);
if (match == null) return null;
return tryParsePrice(match.group(1)!);
}
static _Qty? _parseQty(String line, ReceiptParseConfig config) {
final match =
_qtyWithTotal.firstMatch(line) ??
config.quantityStandalone.firstMatch(line);
if (match == null) return null;
final count = int.tryParse(match.group(1)!);
final unit = tryParsePrice(match.group(2)!);
if (count == null || count < 1 || unit == null) return null;
final totalRaw = match.group(3);
final total = totalRaw == null ? null : tryParsePrice(totalRaw);
return _QtyLine(count: count, unitPrice: unit, total: total);
double? total;
if (match.groupCount >= 3) {
final raw = match.group(3);
if (raw != null && RegExp(r'^-?\d+[.,]\d{2}$').hasMatch(raw)) {
total = tryParsePrice(raw);
}
}
return _Qty(count: count, unitPrice: unit, total: total);
}
}
+92 -13
View File
@@ -5,11 +5,14 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'models/line_item.dart';
import 'models/shop.dart';
import 'product_detail_screen.dart';
import 'services/product_repository.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/item_edit_sheet.dart';
class ShopDetailScreen extends StatefulWidget {
const ShopDetailScreen({
@@ -27,6 +30,7 @@ class ShopDetailScreen extends StatefulWidget {
class _ShopDetailScreenState extends State<ShopDetailScreen> {
late Future<({Shop shop, List<ShopItem> items})> _future;
bool _editing = false;
@override
void initState() {
@@ -43,6 +47,10 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
return (shop: shop, items: items);
}
void _reload() {
setState(() => _future = _load());
}
Future<void> _openReceipt(File file) async {
await showDialog<void>(
context: context,
@@ -68,12 +76,66 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
);
}
Future<void> _editItem(ShopItem item) async {
final result = await showItemEditSheet(
context,
item: LineItem(
id: '${item.id}',
name: item.name,
price: item.price,
quantity: item.quantity,
unitPrice: item.unitPrice,
),
);
if (result == null || !mounted) return;
await widget.repository.updateShopItem(
id: item.id,
name: result.name,
price: result.price,
quantity: result.quantity,
unitPrice: result.unitPrice,
);
if (mounted) _reload();
}
Future<void> _removeItem(ShopItem item) async {
final confirmed = await confirmAction(
context,
title: 'Remove item?',
message:
'"${item.name}" will be removed from this trip. The product stays in your catalog.',
confirmLabel: 'Remove',
);
if (!confirmed || !mounted) return;
await widget.repository.deleteShopItem(item.id);
if (mounted) _reload();
}
Widget? _itemSubtitle(ShopItem item) {
final parts = [
if (item.quantity != null)
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
if (_editing) formatEuro(item.price),
];
if (parts.isEmpty) return null;
return Text(parts.join(' · '));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Shopping trip')),
appBar: AppBar(
title: const Text('Shopping trip'),
actions: [
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
],
),
body: FutureBuilder(
future: _future,
builder: (context, snapshot) {
@@ -92,10 +154,8 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
final shop = snapshot.data!.shop;
final items = snapshot.data!.items;
final receipt = shop.receiptImagePath;
final receiptFile =
receipt == null ? null : File(receipt);
final hasReceipt =
receiptFile != null && receiptFile.existsSync();
final receiptFile = receipt == null ? null : File(receipt);
final hasReceipt = receiptFile != null && receiptFile.existsSync();
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
@@ -125,20 +185,39 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
const SizedBox(height: 24),
Text('Products', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
if (items.isEmpty)
Text(
'No items on this trip.',
style: theme.textTheme.bodyLarge,
),
for (final item in items)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(item.name),
subtitle: item.quantity == null
? null
subtitle: _itemSubtitle(item),
trailing: _editing
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
visualDensity: VisualDensity.compact,
onPressed: () => _editItem(item),
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Remove',
visualDensity: VisualDensity.compact,
onPressed: () => _removeItem(item),
icon: const Icon(Icons.delete_outline),
),
],
)
: Text(
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
formatEuro(item.price),
style: theme.textTheme.titleMedium,
),
trailing: Text(
formatEuro(item.price),
style: theme.textTheme.titleMedium,
),
onTap: () => _openProduct(item.productId),
onTap: _editing ? null : () => _openProduct(item.productId),
),
const Divider(),
ListTile(
+35 -11
View File
@@ -11,6 +11,7 @@ import 'services/product_repository.dart';
import 'shop_detail_screen.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/settings_button.dart';
import 'widgets/store_tag.dart';
@@ -25,6 +26,7 @@ class ShopLogScreen extends StatefulWidget {
class _ShopLogScreenState extends State<ShopLogScreen> {
late Future<List<Shop>> _future;
bool _editing = false;
@override
void initState() {
@@ -39,15 +41,24 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
Future<void> _openShop(Shop shop) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => ShopDetailScreen(
repository: widget.repository,
shopId: shop.id,
),
builder: (_) =>
ShopDetailScreen(repository: widget.repository, shopId: shop.id),
),
);
if (mounted) _reload();
}
Future<void> _removeShop(Shop shop) async {
final confirmed = await confirmAction(
context,
title: 'Remove shopping trip?',
message: 'This trip will be removed from the shop log. Products from this trip stay in your catalog.',
);
if (!confirmed || !mounted) return;
await widget.repository.deleteShop(shop.id);
if (mounted) _reload();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -56,7 +67,14 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
return Scaffold(
appBar: AppBar(
title: const Text('Shop log'),
actions: const [SettingsButton()],
actions: [
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
const SettingsButton(),
],
),
body: FutureBuilder<List<Shop>>(
future: _future,
@@ -77,7 +95,7 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
if (shops.isEmpty) {
return Center(
child: Text(
'Scan a receipt to log a shopping trip.',
'Scan a receipt or enter a trip to log it.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
@@ -103,11 +121,17 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
count == 1 ? '1 product' : '$count products',
].join(' · '),
),
trailing: Text(
formatEuro(shop.total),
style: theme.textTheme.titleMedium,
),
onTap: () => _openShop(shop),
trailing: _editing
? IconButton(
tooltip: 'Remove',
onPressed: () => _removeShop(shop),
icon: const Icon(Icons.delete_outline),
)
: Text(
formatEuro(shop.total),
style: theme.textTheme.titleMedium,
),
onTap: _editing ? null : () => _openShop(shop),
);
},
);
+316
View File
@@ -0,0 +1,316 @@
/// Items on one shopping list, with check-off and edit/remove.
library;
import 'package:flutter/material.dart';
import 'models/product.dart';
import 'models/shopping_list.dart';
import 'product_detail_screen.dart';
import 'services/product_repository.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/name_dialog.dart';
class ShoppingListDetailScreen extends StatefulWidget {
const ShoppingListDetailScreen({
super.key,
required this.repository,
required this.listId,
});
final ProductRepository repository;
final int listId;
@override
State<ShoppingListDetailScreen> createState() =>
_ShoppingListDetailScreenState();
}
class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
late Future<({ShoppingList list, List<ShoppingListItem> items})> _future;
bool _editing = false;
@override
void initState() {
super.initState();
_future = _load();
}
Future<({ShoppingList list, List<ShoppingListItem> items})> _load() async {
final list = await widget.repository.getShoppingList(widget.listId);
if (list == null) {
throw StateError('List not found');
}
final items = await widget.repository.getShoppingListItems(widget.listId);
return (list: list, items: items);
}
void _reload() {
setState(() => _future = _load());
}
Future<void> _rename(ShoppingList list) async {
final name = await showNameDialog(
context,
title: 'Rename list',
initial: list.name,
label: 'List name',
);
if (name == null || !mounted) return;
await widget.repository.renameShoppingList(widget.listId, name);
if (mounted) _reload();
}
Future<void> _openAddSheet() async {
final products = await widget.repository.getAll();
if (!mounted) return;
final result = await showModalBottomSheet<({String name, int? productId})>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => _AddListItemSheet(products: products),
);
if (result == null || !mounted) return;
await widget.repository.addShoppingListItem(
listId: widget.listId,
name: result.name,
productId: result.productId,
);
if (mounted) _reload();
}
Future<void> _editItem(ShoppingListItem item) async {
final name = await showNameDialog(
context,
title: 'Edit item',
initial: item.name,
label: 'Item',
);
if (name == null || !mounted) return;
await widget.repository.updateShoppingListItem(id: item.id, name: name);
if (mounted) _reload();
}
Future<void> _removeItem(ShoppingListItem item) async {
final confirmed = await confirmAction(
context,
title: 'Remove item?',
message: '"${item.name}" will be removed from this list.',
confirmLabel: 'Remove',
);
if (!confirmed || !mounted) return;
await widget.repository.deleteShoppingListItem(item.id);
if (mounted) _reload();
}
Future<void> _toggle(ShoppingListItem item) async {
await widget.repository.updateShoppingListItem(
id: item.id,
checked: !item.checked,
);
if (mounted) _reload();
}
Future<void> _openProduct(int productId) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => ProductDetailScreen(
repository: widget.repository,
productId: productId,
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: FutureBuilder(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Text(
'Could not load this list.',
style: theme.textTheme.bodyLarge,
),
),
);
}
if (!snapshot.hasData) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: CircularProgressIndicator()),
);
}
final list = snapshot.data!.list;
final items = snapshot.data!.items;
return Scaffold(
appBar: AppBar(
title: Text(list.name),
actions: [
IconButton(
tooltip: 'Rename',
onPressed: () => _rename(list),
icon: const Icon(Icons.drive_file_rename_outline),
),
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _openAddSheet,
tooltip: 'Add item',
child: const Icon(Icons.add),
),
body: items.isEmpty
? Center(
child: Text(
'Add things to buy.',
style: theme.textTheme.bodyLarge,
),
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
leading: Checkbox(
value: item.checked,
onChanged: _editing ? null : (_) => _toggle(item),
),
title: Text(
item.name,
style: item.checked
? theme.textTheme.bodyLarge?.copyWith(
decoration: TextDecoration.lineThrough,
color: theme.colorScheme.onSurfaceVariant,
)
: null,
),
trailing: _editing
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
onPressed: () => _editItem(item),
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Remove',
onPressed: () => _removeItem(item),
icon: const Icon(Icons.delete_outline),
),
],
)
: item.productId == null
? null
: IconButton(
tooltip: 'Product',
onPressed: () => _openProduct(item.productId!),
icon: const Icon(Icons.chevron_right),
),
onTap: _editing ? null : () => _toggle(item),
);
},
),
);
},
),
);
}
}
class _AddListItemSheet extends StatefulWidget {
const _AddListItemSheet({required this.products});
final List<Product> products;
@override
State<_AddListItemSheet> createState() => _AddListItemSheetState();
}
class _AddListItemSheetState extends State<_AddListItemSheet> {
final TextEditingController _controller = TextEditingController();
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
List<Product> get _matches {
final query = _controller.text.trim().toLowerCase();
if (query.isEmpty) {
return widget.products.take(8).toList();
}
return widget.products
.where((product) => product.name.toLowerCase().contains(query))
.take(8)
.toList();
}
void _add({String? name, int? productId}) {
final value = (name ?? _controller.text).trim();
if (value.isEmpty) {
setState(() => _error = 'Enter an item name.');
return;
}
Navigator.of(context).pop((name: value, productId: productId));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
final matches = _matches;
return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Add item', style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
TextField(
controller: _controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(labelText: 'Item name'),
onChanged: (_) => setState(() => _error = null),
onSubmitted: (_) => _add(),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
],
if (matches.isNotEmpty) ...[
const SizedBox(height: 12),
Text('From products', style: theme.textTheme.labelMedium),
const SizedBox(height: 4),
for (final product in matches)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(product.name),
onTap: () => _add(name: product.name, productId: product.id),
),
],
const SizedBox(height: 8),
FilledButton(onPressed: () => _add(), child: const Text('Add')),
],
),
);
}
}
+151
View File
@@ -0,0 +1,151 @@
/// Named shopping lists of things to buy.
library;
import 'package:flutter/material.dart';
import 'models/shopping_list.dart';
import 'services/product_repository.dart';
import 'shopping_list_detail_screen.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/name_dialog.dart';
import 'widgets/settings_button.dart';
class ShoppingListsScreen extends StatefulWidget {
const ShoppingListsScreen({super.key, required this.repository});
final ProductRepository repository;
@override
State<ShoppingListsScreen> createState() => _ShoppingListsScreenState();
}
class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
late Future<List<ShoppingList>> _future;
bool _editing = false;
@override
void initState() {
super.initState();
_future = widget.repository.getShoppingLists();
}
void _reload() {
setState(() => _future = widget.repository.getShoppingLists());
}
Future<void> _create() async {
final name = await showNameDialog(
context,
title: 'New shopping list',
label: 'List name',
confirmLabel: 'Create',
);
if (name == null || !mounted) return;
final id = await widget.repository.createShoppingList(name);
if (!mounted) return;
await _openList(id);
}
Future<void> _openList(int id) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) =>
ShoppingListDetailScreen(repository: widget.repository, listId: id),
),
);
if (mounted) _reload();
}
Future<void> _removeList(ShoppingList list) async {
final confirmed = await confirmAction(
context,
title: 'Delete shopping list?',
message: '"${list.name}" and its items will be deleted.',
);
if (!confirmed || !mounted) return;
await widget.repository.deleteShoppingList(list.id);
if (mounted) _reload();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Shopping lists'),
actions: [
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
const SettingsButton(),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _create,
tooltip: 'New list',
child: const Icon(Icons.add),
),
body: FutureBuilder<List<ShoppingList>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text(
'Could not load shopping lists.',
style: theme.textTheme.bodyLarge,
),
);
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final lists = snapshot.data!;
if (lists.isEmpty) {
return Center(
child: Text(
'Create a list for your next shop.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
itemCount: lists.length,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final list = lists[index];
final remaining = list.itemCount - list.checkedCount;
return ListTile(
leading: CircleAvatar(child: Text('${list.itemCount}')),
title: Text(list.name),
subtitle: Text(
list.itemCount == 0
? 'Empty'
: remaining == 0
? 'All done'
: remaining == 1
? '1 item left'
: '$remaining items left',
),
trailing: _editing
? IconButton(
tooltip: 'Delete',
onPressed: () => _removeList(list),
icon: const Icon(Icons.delete_outline),
)
: const Icon(Icons.chevron_right),
onTap: _editing ? null : () => _openList(list.id),
);
},
);
},
),
);
}
}
+21 -12
View File
@@ -17,13 +17,18 @@ final List<Supermarket> kDefaultSupermarkets = [
const Supermarket(name: 'Nettorama', colorValue: 0xFFE87722, sortOrder: 8),
const Supermarket(name: 'Hoogvliet', colorValue: 0xFFD32F2F, sortOrder: 9),
const Supermarket(name: 'Picnic', colorValue: 0xFFE31C5F, sortOrder: 10),
const Supermarket(name: kOtherSupermarket, colorValue: 0xFF78909C, sortOrder: 11),
const Supermarket(
name: kOtherSupermarket,
colorValue: 0xFF78909C,
sortOrder: 11,
),
];
const Map<String, List<String>> kSupermarketAliases = {
'Albert Heijn': ['ALBERT HEIJN', 'AH TO GO', 'AH XL'],
'Jumbo': ['JUMBO'],
'Lidl': ['LIDL'],
'Kruidvat': ['KRUIDVAT'],
'Aldi': ['ALDI'],
'Plus': ['PLUS'],
'Dirk': ['DIRK'],
@@ -31,6 +36,10 @@ const Map<String, List<String>> kSupermarketAliases = {
'SPAR': ['SPAR'],
'Nettorama': ['NETTORAMA'],
'Hoogvliet': ['HOOGVLIET'],
'Ekoplaza': ['EKOPLAZA'],
'Action': ['ACTION'],
'Etos': ['ETOS'],
'World Toko': ['WORLD TOKO'],
'Picnic': ['PICNIC'],
};
@@ -51,20 +60,20 @@ const List<String> kSupermarkets = [
];
/// Returns a supermarket name if it appears in [rawText].
String? detectSupermarket(
String rawText, {
Iterable<String> names = const [],
}) {
String? detectSupermarket(String rawText, {Iterable<String> names = const []}) {
final upper = rawText.toUpperCase();
final extra = names
.map((name) => name.trim())
.where((name) => name.isNotEmpty && name != kOtherSupermarket)
.toList()
..sort((a, b) => b.length.compareTo(a.length));
final extra =
names
.map((name) => name.trim())
.where((name) => name.isNotEmpty && name != kOtherSupermarket)
.toList()
..sort((a, b) => b.length.compareTo(a.length));
for (final name in extra) {
if (RegExp('\\b${RegExp.escape(name)}\\b', caseSensitive: false)
.hasMatch(rawText)) {
if (RegExp(
'\\b${RegExp.escape(name)}\\b',
caseSensitive: false,
).hasMatch(rawText)) {
return name;
}
}
+32
View File
@@ -0,0 +1,32 @@
/// Confirm a destructive action before it runs.
library;
import 'package:flutter/material.dart';
Future<bool> confirmAction(
BuildContext context, {
required String title,
required String message,
String confirmLabel = 'Delete',
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmLabel),
),
],
);
},
);
return result == true;
}
+113
View File
@@ -0,0 +1,113 @@
/// Bottom sheet to add or edit a priced line item.
library;
import 'package:flutter/material.dart';
import '../models/line_item.dart';
import '../utils/money.dart';
Future<LineItem?> showItemEditSheet(BuildContext context, {LineItem? item}) {
return showModalBottomSheet<LineItem>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => ItemEditSheet(item: item),
);
}
class ItemEditSheet extends StatefulWidget {
const ItemEditSheet({super.key, this.item});
final LineItem? item;
@override
State<ItemEditSheet> createState() => _ItemEditSheetState();
}
class _ItemEditSheetState extends State<ItemEditSheet> {
late final TextEditingController _nameController;
late final TextEditingController _priceController;
String? _error;
@override
void initState() {
super.initState();
final item = widget.item;
_nameController = TextEditingController(text: item?.name ?? '');
_priceController = TextEditingController(
text: item == null ? '' : item.price.toStringAsFixed(2),
);
}
@override
void dispose() {
_nameController.dispose();
_priceController.dispose();
super.dispose();
}
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.');
return;
}
final rounded = roundMoney(price);
final quantity = widget.item?.quantity;
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,
),
);
}
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
widget.item == null ? 'Add item' : 'Edit item',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextField(
controller: _nameController,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(labelText: 'Product name'),
autofocus: widget.item == null,
),
const SizedBox(height: 12),
TextField(
controller: _priceController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Price'),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 16),
FilledButton(onPressed: _save, child: const Text('Save')),
],
),
);
}
}
+101
View File
@@ -0,0 +1,101 @@
/// Name prompt that owns its text controller for the dialog lifetime.
library;
import 'package:flutter/material.dart';
Future<String?> showNameDialog(
BuildContext context, {
required String title,
String? initial,
String label = 'Name',
String confirmLabel = 'Save',
}) {
return showDialog<String>(
context: context,
builder: (context) => _NameDialog(
title: title,
initial: initial ?? '',
label: label,
confirmLabel: confirmLabel,
),
);
}
class _NameDialog extends StatefulWidget {
const _NameDialog({
required this.title,
required this.initial,
required this.label,
required this.confirmLabel,
});
final String title;
final String initial;
final String label;
final String confirmLabel;
@override
State<_NameDialog> createState() => _NameDialogState();
}
class _NameDialogState extends State<_NameDialog> {
late final TextEditingController _controller;
String? _error;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initial);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _save() {
final name = _controller.text.trim();
if (name.isEmpty) {
setState(() => _error = 'Enter a name.');
return;
}
Navigator.of(context).pop(name);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: widget.label),
onSubmitted: (_) => _save(),
),
if (_error != null) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(onPressed: _save, child: Text(widget.confirmLabel)),
],
);
}
}