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;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'scan_screen.dart';
|
import 'scan_screen.dart';
|
||||||
|
import 'services/product_repository.dart';
|
||||||
import 'utils/app_theme.dart';
|
import 'utils/app_theme.dart';
|
||||||
import 'utils/constants.dart';
|
import 'utils/constants.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
runApp(const ReceipityApp());
|
runApp(ReceipityApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
class ReceipityApp extends StatelessWidget {
|
class ReceipityApp extends StatelessWidget {
|
||||||
const ReceipityApp({super.key});
|
ReceipityApp({super.key, ProductRepository? repository})
|
||||||
|
: repository = repository ?? ProductRepository();
|
||||||
|
|
||||||
|
final ProductRepository repository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -22,7 +26,7 @@ class ReceipityApp extends StatelessWidget {
|
|||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
darkTheme: AppTheme.dark(),
|
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:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
||||||
import 'package:image_picker/image_picker.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';
|
import 'utils/constants.dart';
|
||||||
|
|
||||||
class ScanScreen extends StatefulWidget {
|
class ScanScreen extends StatefulWidget {
|
||||||
const ScanScreen({super.key});
|
const ScanScreen({super.key, required this.repository});
|
||||||
|
|
||||||
|
final ProductRepository repository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ScanScreen> createState() => _ScanScreenState();
|
State<ScanScreen> createState() => _ScanScreenState();
|
||||||
@@ -99,6 +105,32 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
setState(() => _busy = false);
|
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
|
@override
|
||||||
@@ -106,7 +138,16 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
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(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -130,6 +171,13 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
icon: const Icon(Icons.receipt_long_outlined),
|
icon: const Icon(Icons.receipt_long_outlined),
|
||||||
label: const Text('Use sample receipt'),
|
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) ...[
|
if (_busy) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const LinearProgressIndicator(),
|
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
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -33,6 +41,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
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:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -49,6 +65,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.5+5"
|
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:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -65,6 +89,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.3"
|
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:
|
file_selector_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -144,6 +176,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.17.1"
|
version: "0.17.1"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -224,6 +264,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.2"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -256,6 +320,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "6.1.0"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -288,14 +360,86 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
path:
|
objective_c:
|
||||||
dependency: transitive
|
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:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
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:
|
plugin_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -304,6 +448,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -317,6 +477,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
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:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -341,6 +541,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
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:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -389,6 +597,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
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:
|
sdks:
|
||||||
dart: ">=3.13.2 <4.0.0"
|
dart: ">=3.13.2 <4.0.0"
|
||||||
flutter: ">=3.44.0"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ dependencies:
|
|||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
image_picker: ^1.2.3
|
image_picker: ^1.2.3
|
||||||
google_mlkit_text_recognition: ^0.17.1
|
google_mlkit_text_recognition: ^0.17.1
|
||||||
|
path: ^1.9.1
|
||||||
|
path_provider: ^2.1.5
|
||||||
|
sqflite: ^2.4.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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:flutter_test/flutter_test.dart';
|
||||||
import 'package:receipity/main.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() {
|
void main() {
|
||||||
testWidgets('scan screen shows capture actions', (tester) async {
|
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('Receipity'), findsOneWidget);
|
||||||
expect(find.text('Take photo'), findsOneWidget);
|
expect(find.text('Take photo'), findsOneWidget);
|
||||||
@@ -14,4 +18,29 @@ void main() {
|
|||||||
findsOneWidget,
|
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