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;
+225 -1
View File
@@ -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"
+3
View File
@@ -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:
+154
View File
@@ -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
''';
+30 -1
View File
@@ -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);
});
}