scan to list working fine
This commit is contained in:
+8
-4
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user