scan to list working fine

This commit is contained in:
davmar
2026-08-29 11:23:24 +02:00
parent 2d8fc46a30
commit 2a5a922aab
13 changed files with 1233 additions and 8 deletions
+8 -4
View File
@@ -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),
);
}
}
+35
View File
@@ -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),
);
}
}
+28
View File
@@ -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<String, Object?> 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),
);
}
}
+101
View File
@@ -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<ProductsScreen> createState() => _ProductsScreenState();
}
class _ProductsScreenState extends State<ProductsScreen> {
late Future<List<Product>> _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<List<Product>>(
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,
),
);
},
);
},
),
);
}
}
+274
View File
@@ -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<LineItem> items;
final String rawText;
final ProductRepository repository;
@override
State<ReviewScreen> createState() => _ReviewScreenState();
}
class _ReviewScreenState extends State<ReviewScreen> {
late List<LineItem> _items;
bool _saving = false;
@override
void initState() {
super.initState();
_items = List<LineItem>.from(widget.items);
}
Future<void> _edit({LineItem? existing}) async {
final result = await showModalBottomSheet<LineItem>(
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<void> _confirm() async {
setState(() => _saving = true);
try {
await widget.repository.upsertItems(_items);
if (!mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute<void>(
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'),
),
],
),
);
}
}
+50 -2
View File
@@ -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<ScanScreen> createState() => _ScanScreenState();
@@ -99,6 +105,32 @@ class _ScanScreenState extends State<ScanScreen> {
setState(() => _busy = false);
}
}
if (mounted && _extractedText.isNotEmpty) {
await _openReview(_extractedText);
}
}
Future<void> _openReview(String rawText) async {
final items = ReceiptParser.parse(rawText);
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ReviewScreen(
items: items,
rawText: rawText,
repository: widget.repository,
),
),
);
}
void _openProducts() {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ProductsScreen(repository: widget.repository),
),
);
}
@override
@@ -106,7 +138,16 @@ class _ScanScreenState extends State<ScanScreen> {
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<ScanScreen> {
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(),
+93
View File
@@ -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<Database> get _database async {
_db ??= await _open();
return _db!;
}
Future<Database> _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<void> upsertItems(List<LineItem> 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<List<Product>> getAll() async {
final db = await _database;
final rows = await db.query(
'products',
orderBy: 'name COLLATE NOCASE ASC',
);
return rows.map(Product.fromMap).toList();
}
}
+204
View File
@@ -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<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',
};
/// Discount / promo rows. Paired with a price, then dropped.
const Set<String> 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<LineItem> 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<LineItem> _parseInline(List<String> lines) {
final items = <LineItem>[];
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<LineItem> _parseColumns(List<String> lines) {
final names = <({String name, int? quantity, double? unitPrice})>[];
final prices = <double>[];
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<String> 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);
}
}
+28
View File
@@ -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;