diff --git a/lib/main.dart b/lib/main.dart index 41cacaa..f1e188f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,19 +1,23 @@ -/// Application entry point: a single receipt-capture screen. +/// Application entry point: capture a receipt, review line items, save products. library; import 'package:flutter/material.dart'; import 'scan_screen.dart'; +import 'services/product_repository.dart'; import 'utils/app_theme.dart'; import 'utils/constants.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); - runApp(const ReceipityApp()); + runApp(ReceipityApp()); } class ReceipityApp extends StatelessWidget { - const ReceipityApp({super.key}); + ReceipityApp({super.key, ProductRepository? repository}) + : repository = repository ?? ProductRepository(); + + final ProductRepository repository; @override Widget build(BuildContext context) { @@ -22,7 +26,7 @@ class ReceipityApp extends StatelessWidget { debugShowCheckedModeBanner: false, theme: AppTheme.light(), darkTheme: AppTheme.dark(), - home: const ScanScreen(), + home: ScanScreen(repository: repository), ); } } diff --git a/lib/models/line_item.dart b/lib/models/line_item.dart new file mode 100644 index 0000000..14a52be --- /dev/null +++ b/lib/models/line_item.dart @@ -0,0 +1,35 @@ +/// A parsed receipt row shown on the review screen. +library; + +class LineItem { + LineItem({ + required this.id, + required this.name, + required this.price, + this.quantity, + this.unitPrice, + }); + + final String id; + final String name; + final double price; + final int? quantity; + final double? unitPrice; + + LineItem copyWith({ + String? id, + String? name, + double? price, + int? quantity, + double? unitPrice, + bool clearQuantity = false, + }) { + return LineItem( + id: id ?? this.id, + name: name ?? this.name, + price: price ?? this.price, + quantity: clearQuantity ? null : (quantity ?? this.quantity), + unitPrice: clearQuantity ? null : (unitPrice ?? this.unitPrice), + ); + } +} diff --git a/lib/models/product.dart b/lib/models/product.dart new file mode 100644 index 0000000..fc7bff3 --- /dev/null +++ b/lib/models/product.dart @@ -0,0 +1,28 @@ +/// A product stored after the user confirms a parsed receipt. +library; + +class Product { + const Product({ + required this.id, + required this.name, + required this.lastPrice, + required this.timesSeen, + required this.updatedAt, + }); + + final int id; + final String name; + final double lastPrice; + final int timesSeen; + final DateTime updatedAt; + + factory Product.fromMap(Map map) { + return Product( + id: map['id']! as int, + name: map['name']! as String, + lastPrice: (map['last_price']! as num).toDouble(), + timesSeen: map['times_seen']! as int, + updatedAt: DateTime.parse(map['updated_at']! as String), + ); + } +} diff --git a/lib/products_screen.dart b/lib/products_screen.dart new file mode 100644 index 0000000..b4f51c7 --- /dev/null +++ b/lib/products_screen.dart @@ -0,0 +1,101 @@ +/// Confirmed products stored on device. +library; + +import 'package:flutter/material.dart'; + +import 'models/product.dart'; +import 'services/product_repository.dart'; +import 'utils/money.dart'; + +class ProductsScreen extends StatefulWidget { + const ProductsScreen({ + super.key, + required this.repository, + this.savedCount, + }); + + final ProductRepository repository; + final int? savedCount; + + @override + State createState() => _ProductsScreenState(); +} + +class _ProductsScreenState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = widget.repository.getAll(); + final saved = widget.savedCount; + if (saved != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + saved == 1 + ? 'Saved 1 product.' + : 'Saved $saved products.', + ), + ), + ); + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('Products')), + body: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text( + 'Could not load products.', + style: theme.textTheme.bodyLarge, + ), + ); + } + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + final products = snapshot.data!; + if (products.isEmpty) { + return Center( + child: Text( + 'Confirmed receipt items will show up here.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: products.length, + separatorBuilder: (_, _) => const Divider(indent: 16, endIndent: 16), + itemBuilder: (context, index) { + final product = products[index]; + final seen = product.timesSeen; + return ListTile( + title: Text(product.name), + subtitle: Text(seen == 1 ? 'Seen once' : 'Seen $seen times'), + trailing: Text( + formatEuro(product.lastPrice), + style: theme.textTheme.titleMedium, + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/review_screen.dart b/lib/review_screen.dart new file mode 100644 index 0000000..6c270ff --- /dev/null +++ b/lib/review_screen.dart @@ -0,0 +1,274 @@ +/// Editable list of parsed receipt lines before they are saved as products. +library; + +import 'package:flutter/material.dart'; + +import 'models/line_item.dart'; +import 'products_screen.dart'; +import 'services/product_repository.dart'; +import 'utils/money.dart'; + +class ReviewScreen extends StatefulWidget { + const ReviewScreen({ + super.key, + required this.items, + required this.rawText, + required this.repository, + }); + + final List items; + final String rawText; + final ProductRepository repository; + + @override + State createState() => _ReviewScreenState(); +} + +class _ReviewScreenState extends State { + late List _items; + bool _saving = false; + + @override + void initState() { + super.initState(); + _items = List.from(widget.items); + } + + Future _edit({LineItem? existing}) async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => _ItemEditSheet(item: existing), + ); + if (result == null || !mounted) return; + + setState(() { + if (existing == null) { + _items.add(result); + } else { + final index = _items.indexWhere((item) => item.id == existing.id); + if (index >= 0) { + _items[index] = result; + } + } + }); + } + + Future _confirm() async { + setState(() => _saving = true); + try { + await widget.repository.upsertItems(_items); + if (!mounted) return; + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => ProductsScreen( + repository: widget.repository, + savedCount: _items.length, + ), + ), + (route) => route.isFirst, + ); + } catch (_) { + if (!mounted) return; + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not save products.')), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('Review items')), + body: Column( + children: [ + Expanded( + child: _items.isEmpty + ? Center( + child: Text( + 'No line items found.\nAdd anything the parser missed.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + itemCount: _items.length, + separatorBuilder: (_, _) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final item = _items[index]; + return Dismissible( + key: ValueKey(item.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.delete_outline, + color: theme.colorScheme.onErrorContainer, + ), + ), + onDismissed: (_) { + setState(() { + _items.removeWhere((row) => row.id == item.id); + }); + }, + child: ListTile( + title: Text(item.name), + subtitle: item.quantity == null + ? null + : Text( + '${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}', + ), + trailing: Text( + formatEuro(item.price), + style: theme.textTheme.titleMedium, + ), + onTap: _saving ? null : () => _edit(existing: item), + ), + ); + }, + ), + ), + if (widget.rawText.isNotEmpty) + ExpansionTile( + title: const Text('Raw OCR text'), + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 180), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: SelectableText(widget.rawText), + ), + ), + ], + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + OutlinedButton.icon( + onPressed: _saving ? null : () => _edit(), + icon: const Icon(Icons.add), + label: const Text('Add item'), + ), + const SizedBox(height: 8), + FilledButton( + onPressed: _saving ? null : _confirm, + child: Text( + _saving ? 'Saving…' : 'Confirm', + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +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'), + ), + ], + ), + ); + } +} diff --git a/lib/scan_screen.dart b/lib/scan_screen.dart index 69ffc80..f00a55a 100644 --- a/lib/scan_screen.dart +++ b/lib/scan_screen.dart @@ -8,10 +8,16 @@ import 'package:flutter/services.dart'; import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; import 'package:image_picker/image_picker.dart'; +import 'products_screen.dart'; +import 'review_screen.dart'; +import 'services/product_repository.dart'; +import 'services/receipt_parser.dart'; import 'utils/constants.dart'; class ScanScreen extends StatefulWidget { - const ScanScreen({super.key}); + const ScanScreen({super.key, required this.repository}); + + final ProductRepository repository; @override State createState() => _ScanScreenState(); @@ -99,6 +105,32 @@ class _ScanScreenState extends State { setState(() => _busy = false); } } + + if (mounted && _extractedText.isNotEmpty) { + await _openReview(_extractedText); + } + } + + Future _openReview(String rawText) async { + final items = ReceiptParser.parse(rawText); + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ReviewScreen( + items: items, + rawText: rawText, + repository: widget.repository, + ), + ), + ); + } + + void _openProducts() { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ProductsScreen(repository: widget.repository), + ), + ); } @override @@ -106,7 +138,16 @@ class _ScanScreenState extends State { final theme = Theme.of(context); return Scaffold( - appBar: AppBar(title: const Text(kAppName)), + appBar: AppBar( + title: const Text(kAppName), + actions: [ + IconButton( + tooltip: 'Products', + onPressed: _openProducts, + icon: const Icon(Icons.inventory_2_outlined), + ), + ], + ), body: SafeArea( child: Padding( padding: const EdgeInsets.all(16), @@ -130,6 +171,13 @@ class _ScanScreenState extends State { icon: const Icon(Icons.receipt_long_outlined), label: const Text('Use sample receipt'), ), + if (_extractedText.isNotEmpty) ...[ + const SizedBox(height: 8), + OutlinedButton( + onPressed: _busy ? null : () => _openReview(_extractedText), + child: const Text('Review items'), + ), + ], if (_busy) ...[ const SizedBox(height: 16), const LinearProgressIndicator(), diff --git a/lib/services/product_repository.dart b/lib/services/product_repository.dart new file mode 100644 index 0000000..7ebe357 --- /dev/null +++ b/lib/services/product_repository.dart @@ -0,0 +1,93 @@ +/// Local SQLite store of confirmed products. +library; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; + +import '../models/line_item.dart'; +import '../models/product.dart'; + +class ProductRepository { + Database? _db; + + Future get _database async { + _db ??= await _open(); + return _db!; + } + + Future _open() async { + final dir = await getApplicationDocumentsDirectory(); + return openDatabase( + p.join(dir.path, 'receipity.db'), + version: 1, + onCreate: (db, version) async { + await db.execute(''' + CREATE TABLE products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL UNIQUE, + last_price REAL NOT NULL, + times_seen INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + }, + ); + } + + static String normalizeName(String name) => + name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' '); + + Future upsertItems(List items) async { + final db = await _database; + final now = DateTime.now().toIso8601String(); + + await db.transaction((txn) async { + for (final item in items) { + final key = normalizeName(item.name); + if (key.isEmpty) continue; + + final existing = await txn.query( + 'products', + where: 'normalized_name = ?', + whereArgs: [key], + limit: 1, + ); + + if (existing.isEmpty) { + await txn.insert('products', { + 'name': item.name.trim(), + 'normalized_name': key, + 'last_price': item.price, + 'times_seen': 1, + 'created_at': now, + 'updated_at': now, + }); + } else { + await txn.update( + 'products', + { + 'name': item.name.trim(), + 'last_price': item.price, + 'times_seen': (existing.first['times_seen']! as int) + 1, + 'updated_at': now, + }, + where: 'id = ?', + whereArgs: [existing.first['id']], + ); + } + } + }); + } + + Future> getAll() async { + final db = await _database; + final rows = await db.query( + 'products', + orderBy: 'name COLLATE NOCASE ASC', + ); + return rows.map(Product.fromMap).toList(); + } +} diff --git a/lib/services/receipt_parser.dart b/lib/services/receipt_parser.dart new file mode 100644 index 0000000..5fe9015 --- /dev/null +++ b/lib/services/receipt_parser.dart @@ -0,0 +1,204 @@ +/// Turns raw OCR text into [LineItem]s using line-end prices and a keyword filter. +library; + +import '../models/line_item.dart'; +import '../utils/money.dart'; + +/// Headers, payments, and totals — never treated as products. +const Set 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', +}; + +/// Discount / promo rows. Paired with a price, then dropped. +const Set kDropKeywords = { + 'DISCOUNT', + 'KORTING', + 'ACTIE', +}; + +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); + +class _QtyLine { + const _QtyLine({required this.count, required this.unitPrice, this.total}); + + final int count; + final double unitPrice; + final double? total; + + double get lineTotal => total ?? roundMoney(count * unitPrice); +} + +class ReceiptParser { + ReceiptParser._(); + + static int _nextId = 0; + + 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 parse(String rawText) { + final lines = rawText + .split('\n') + .map((line) => line.trim()) + .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(); + } + + static List _parseInline(List lines) { + final items = []; + + for (var i = 0; i < lines.length; i++) { + final line = lines[i]; + if (_shouldSkipLine(line) || isPriceOnly(line)) continue; + + 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; + } + + 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++; + } + + return items; + } + + static List _parseColumns(List lines) { + final names = <({String name, int? quantity, double? unitPrice})>[]; + final prices = []; + + for (final line in lines) { + if (isPriceOnly(line)) { + final price = tryParsePrice(line); + if (price != null) prices.add(price); + continue; + } + if (_shouldSkipLine(line)) continue; + + final qty = _parseQty(line); + if (qty != null) { + if (names.isNotEmpty) { + final last = names.removeLast(); + names.add(( + name: last.name, + quantity: qty.count, + unitPrice: qty.unitPrice, + )); + } + continue; + } + + names.add((name: line, quantity: null, unitPrice: null)); + } + + final count = names.length < prices.length ? names.length : prices.length; + return [ + for (var i = 0; i < count; i++) + LineItem( + id: _id(), + name: names[i].name, + price: prices[i], + quantity: names[i].quantity, + unitPrice: names[i].unitPrice, + ), + ]; + } + + static bool _keepItem(LineItem item) { + if (item.price < 0) return false; + if (item.name.trim().isEmpty) return false; + return !_containsKeyword(item.name, kDropKeywords); + } + + static bool _shouldSkipLine(String line) { + if (_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 keywords) { + final upper = line.toUpperCase(); + for (final keyword in keywords) { + if (RegExp('\\b${RegExp.escape(keyword)}\\b').hasMatch(upper)) { + return true; + } + } + return false; + } + + static _QtyLine? _parseQty(String line) { + final match = quantityPattern.firstMatch(line.trim()); + 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); + } +} diff --git a/lib/utils/money.dart b/lib/utils/money.dart new file mode 100644 index 0000000..b26d439 --- /dev/null +++ b/lib/utils/money.dart @@ -0,0 +1,28 @@ +/// Price formatting and parsing for receipt amounts. +library; + +/// Matches a price that is the entire line, e.g. `2,09` or `-2.00`. +final priceOnlyPattern = RegExp(r'^-?\d+[.,]\d{2}$'); + +/// Name (optional) plus a price at the end of the line. +final trailingPricePattern = RegExp(r'^(.*?)\s+(-?\d+[.,]\d{2})$'); + +/// `2 x 1.50` or `2 X 1,50 3,00` (quantity, unit price, optional line total). +final quantityPattern = RegExp( + r'^(\d+)\s*[xX×]\s*(-?\d+[.,]\d{2})(?:\s+(-?\d+[.,]\d{2}))?$', +); + +bool isPriceOnly(String line) => priceOnlyPattern.hasMatch(line); + +double? tryParsePrice(String raw) { + final cleaned = raw + .trim() + .replaceAll('€', '') + .replaceAll(RegExp(r'\s'), '') + .replaceAll(',', '.'); + return double.tryParse(cleaned); +} + +String formatEuro(double value) => '€ ${value.toStringAsFixed(2)}'; + +double roundMoney(double value) => (value * 100).round() / 100; diff --git a/pubspec.lock b/pubspec.lock index e16b52a..3ca6224 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -33,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -49,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.5+5" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -65,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" file_selector_linux: dependency: transitive description: @@ -144,6 +176,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.17.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" http: dependency: transitive description: @@ -224,6 +264,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" leak_tracker: dependency: transitive description: @@ -256,6 +320,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -288,14 +360,86 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - path: + objective_c: dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -304,6 +448,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" sky_engine: dependency: transitive description: flutter @@ -317,6 +477,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" + url: "https://pub.dev" + source: hosted + version: "2.5.11" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + url: "https://pub.dev" + source: hosted + version: "2.4.3+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" stack_trace: dependency: transitive description: @@ -341,6 +541,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + url: "https://pub.dev" + source: hosted + version: "3.4.1+2" term_glyph: dependency: transitive description: @@ -389,6 +597,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" sdks: dart: ">=3.13.2 <4.0.0" flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index f91cdfb..1af5403 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,9 @@ dependencies: cupertino_icons: ^1.0.8 image_picker: ^1.2.3 google_mlkit_text_recognition: ^0.17.1 + path: ^1.9.1 + path_provider: ^2.1.5 + sqflite: ^2.4.2 dev_dependencies: flutter_test: diff --git a/test/receipt_parser_test.dart b/test/receipt_parser_test.dart new file mode 100644 index 0000000..adb1138 --- /dev/null +++ b/test/receipt_parser_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:receipity/services/receipt_parser.dart'; + +void main() { + test('parses name and price at the end of a line', () { + final items = ReceiptParser.parse(''' +MILK 1.50 +BREAD 2.00 +TOTAL 3.50 +CASH 5.00 +CHANGE 1.50 +'''); + + expect(items.map((item) => item.name), ['MILK', 'BREAD']); + expect(items.map((item) => item.price), [1.50, 2.00]); + }); + + test('uses the next line when quantity and total are split', () { + final items = ReceiptParser.parse(''' +EGGS +2 X 1.50 3.00 +BUTTER 2.25 +SUBTOTAL 5.25 +'''); + + expect(items, hasLength(2)); + expect(items[0].name, 'EGGS'); + expect(items[0].price, 3.00); + expect(items[0].quantity, 2); + expect(items[0].unitPrice, 1.50); + expect(items[1].name, 'BUTTER'); + expect(items[1].price, 2.25); + }); + + test('computes a total when the quantity line has no line total', () { + final items = ReceiptParser.parse(''' +BROODSTICKS OLIJF +4 X 1,25 +'''); + + expect(items, hasLength(1)); + expect(items.single.name, 'BROODSTICKS OLIJF'); + expect(items.single.price, 5.00); + expect(items.single.quantity, 4); + }); + + test('drops discount rows and Dutch totals', () { + final items = ReceiptParser.parse(''' +AARDBEIEN 7,98 +ACTIE AARDBEIEN -2,00 +Totaal 82,99 +VISA 82,99 +Totaal korting: -2,21 +'''); + + expect(items.map((item) => item.name), ['AARDBEIEN']); + expect(items.single.price, 7.98); + }); + + test('pairs a name column with a price column from OCR', () { + final items = ReceiptParser.parse(_jumboColumnOcr); + + expect(items.map((item) => item.name), isNot(contains('OMSCHRIJVING'))); + expect(items.map((item) => item.name), isNot(contains('Totaal'))); + expect(items.map((item) => item.name), isNot(contains('ACTIE AARDBEIEN'))); + expect(items.first.name, 'JUMBO BIO VOLLE MELK'); + expect(items.first.price, 2.09); + + final eggs = items.firstWhere((item) => item.name.startsWith('BIOLOGISCH EI')); + expect(eggs.quantity, 2); + expect(eggs.price, 5.58); + + expect(items.last.name, 'JUMBO PREM LUIER M4'); + expect(items.last.price, 6.20); + expect(items, hasLength(26)); + }); +} + +const _jumboColumnOcr = ''' +OMSCHRIJVING +JUMBO BIO VOLLE MELK +JUMBO BIO BLOEMKOOLS +JUMBO BIO ROM TOMAAT +JUMBO BIO SIZ LINZEN +JUMBO BIO SIZ CHAMP. +JUMBO FRITO +BIOLOGISCH EI 6 ST +2 X 2,79 +KABELJAUW GROENE KR +JUMBO BIO KIPDI JFILE +JUMBO RASP MOZZAREL +JUMBO KAASBL BELEGEN +GALBAN MOZZAREL MAXI +TORTELL DROGE HAM +PATURATIN NATURAL +PAPRIKA ROOD +BIO COTTAGE CHEESE +FP BIO JB PLAK +BIO VASTKOKEND AARD +2 X 1,99 +AARDBEIEN +2 X 3,99 +ACTIE AARDBEIEN +BROCCOLI +PREI +CITROENEN +KOMKOMMER +2 X 0,95 +ACTIE KOMKOMMER +BOL KNOFLOOK +BROODSTICKS OLIJF +4 X 1,25 +Krui dernhof 26 +1112 PS Diemen +0206904928 +www.jumbo. com +JUMBO PREM LUIER M4 +Totaal +Betaal d: +VISA +Totaal korting: +BEDRAG IN € +2,09 +2,39 +2,89 +2,89 +2,39 +0,87 +5,58 +9,54 +4.74 +2,49 +3,89 +2,77 +2,49 +2,59 +1,09 +0,99 +4,12 +3,98 +7,98 +-2,00 +1,24 +0,69 +2,11 +1,90 +-0,21 +1,79 +5,00 +6,20 +82,99 +82,99 +-2,21 +'''; diff --git a/test/widget_test.dart b/test/widget_test.dart index 82ac2e3..765bcfa 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,9 +1,13 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:receipity/main.dart'; +import 'package:receipity/models/line_item.dart'; +import 'package:receipity/review_screen.dart'; +import 'package:receipity/services/product_repository.dart'; void main() { testWidgets('scan screen shows capture actions', (tester) async { - await tester.pumpWidget(const ReceipityApp()); + await tester.pumpWidget(ReceipityApp()); expect(find.text('Receipity'), findsOneWidget); expect(find.text('Take photo'), findsOneWidget); @@ -14,4 +18,29 @@ void main() { findsOneWidget, ); }); + + testWidgets('review screen lists items and can delete one', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: ReviewScreen( + items: [ + LineItem(id: '1', name: 'Milk', price: 1.5), + LineItem(id: '2', name: 'Bread', price: 2), + ], + rawText: 'Milk 1.50\nBread 2.00', + repository: ProductRepository(), + ), + ), + ); + + expect(find.text('Milk'), findsOneWidget); + expect(find.text('Bread'), findsOneWidget); + expect(find.text('Add item'), findsOneWidget); + expect(find.text('Confirm'), findsOneWidget); + + await tester.drag(find.text('Milk'), const Offset(-500, 0)); + await tester.pumpAndSettle(); + expect(find.text('Milk'), findsNothing); + expect(find.text('Bread'), findsOneWidget); + }); }