Compare commits

...
2 Commits
Author SHA1 Message Date
davmar 3b698a3667 parser logic added 2026-08-30 12:24:29 +02:00
davmar 35d5c90d81 fix OCR in apx 2026-08-29 18:02:35 +02:00
31 changed files with 3054 additions and 358 deletions
+4
View File
@@ -34,6 +34,10 @@ android {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
// Flutter turns R8 on for release. That strips ML Kit classes
// discovered via manifest metadata / JNI, so OCR fails in the APK.
isMinifyEnabled = false
isShrinkResources = false
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro", "proguard-rules.pro",
+15 -10
View File
@@ -1,10 +1,15 @@
# ML Kit text recognition ships Latin only; the plugin still references # ML Kit text recognition is wired up through manifest metadata, reflection,
# optional script packs that are not in this app. # and JNI. R8 otherwise strips those classes and OCR fails in release APKs.
-dontwarn com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions$Builder -keep class com.google.mlkit.** { *; }
-dontwarn com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions -keep class com.google.android.gms.internal.mlkit_** { *; }
-dontwarn com.google.mlkit.vision.text.devanagari.DevanagariTextRecognizerOptions$Builder -keep class com.google.android.gms.internal.mlkit_vision_** { *; }
-dontwarn com.google.mlkit.vision.text.devanagari.DevanagariTextRecognizerOptions -keep class com.google.android.libraries.vision.** { *; }
-dontwarn com.google.mlkit.vision.text.japanese.JapaneseTextRecognizerOptions$Builder -keep class com.google_mlkit_commons.** { *; }
-dontwarn com.google.mlkit.vision.text.japanese.JapaneseTextRecognizerOptions -keep class com.google_mlkit_text_recognition.** { *; }
-dontwarn com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions$Builder -keep class * implements com.google.firebase.components.ComponentRegistrar { *; }
-dontwarn com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions
# The Flutter plugin references optional script packs that this app does not ship.
-dontwarn com.google.mlkit.vision.text.chinese.**
-dontwarn com.google.mlkit.vision.text.devanagari.**
-dontwarn com.google.mlkit.vision.text.japanese.**
-dontwarn com.google.mlkit.vision.text.korean.**
+2
View File
@@ -1,5 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
# Flutter enables R8 for release by default. That breaks on-device ML Kit OCR.
shrink=false
# This newDsl flag was added by the Flutter template # This newDsl flag was added by the Flutter template
android.newDsl=false android.newDsl=false
# This builtInKotlin flag was added by the Flutter template # This builtInKotlin flag was added by the Flutter template
+168
View File
@@ -0,0 +1,168 @@
# Receipt parsing (implementation)
This is the working guide for Receipity's receipt parser. Use it when a new
shop's receipt does not parse well.
The original design notes live in [`receipt-parsing-spec.md`](../receipt-parsing-spec.md).
Keywords and regexes live in [`receipt-parsing-keywords.json`](../receipt-parsing-keywords.json)
and are bundled as a Flutter asset.
---
## What the parser produces
For each receipt:
| Field | Meaning |
|---|---|
| `items[].name` | Product name |
| `items[].quantity` | Pack count when `N X unit` was found (otherwise omitted) |
| `items[].unitPrice` | Price of one pack when quantity is set |
| `items[].discount` | Negative amount folded into the line (promos) |
| `items[].price` | **Line total after discount** (this is what the trip saves) |
| `store` | Best-effort store from the header + `known_stores` |
| `receiptTotal` | First grand-total amount after the items |
| `totalDiscount` | Receipt-level `Totaal korting` (and similar) |
| `validationPassed` | Item sum matches `receiptTotal` within €0.01 |
If validation fails, Review shows: *Parsed totals do not match the receipt.*
---
## Pipeline
1. **Preprocess** each OCR line: trim, collapse spaces, drop empty lines.
2. **Choose a layout**
- **Sequential** (default): name, price, quantity and discount appear in
reading order. Jumbo (printed), Lidl, Kruidvat, World Toko.
- **Column OCR fallback:** ML Kit sometimes dumps every name, then every
amount. Detected when there is a run of 4+ price-only lines *and* enough
name-like lines. Used for the Jumbo sample dump in tests.
3. **Classify** each line (first match wins) using the keyword JSON.
4. **Build items** (pending name, then quantity/discount/price).
5. **Validate** against the receipt total when one was found.
Do **not** treat weight in the name (`900g`, `500ml`) as quantity. Only `N X`
next to a price is a quantity.
---
## Line types
Priority is the same as the spec:
1. Separators / column headers (`OMSCHRIJVING`, `Artikel`, `Prijs`, …)
2. Tax / BTW tables (`BTW`, `Bedr.Excl`, `B 9%`)
3. Payment (`Betaald`, `VISA`, `PIN`, …) — also matches OCR `Betaal d:`
4. Noise / footer (URLs, opening hours, thank-you lines)
5. Receipt-level discount total (`Totaal korting`)
6. Subtotal / tax-inclusive total (ignored for items)
7. Grand total (`Totaal`, `Total`) — stops sequential item parsing when an
amount is on the same line
8. Discount (`ACTIE`, `KORTING`, `In prijs verlaagd`, or a negative amount)
9. Quantity modifier (`2 X 2,79`, optional `PER STUK`, optional line total)
10. Item (name + price at end; optional tax letter `B`/`C` is stripped)
11. Price-only
12. Name-only (starts a pending item — Jumbo multi-pack)
### Quantity variants
| Variant | Example | Store |
|---|---|---|
| **A** — name, then `N X unit` | `AARDBEIEN` / `2 X 3,99` | Jumbo, World Toko |
| **B** — name and `N X unit` on one line | `ZEEPTABLET NEUTRAL 2 X 3,99 PER STUK 7,98` | Kruidvat |
The JSON standalone quantity regex is extended in code so a right-hand
line total is allowed: `2 X 2,79 5,58`.
### Discounts
A discount is attached to the **current / previous item** and subtracted from
`price`. Receipt-wide totals (`Totaal korting`) are not products.
---
## How to add a new shop
Work in this order. You usually only need step 1.
### 1. Keywords (`receipt-parsing-keywords.json`)
| Add to | When |
|---|---|
| `known_stores.list` | Header contains a new banner name |
| `column_header_keywords.list` | New table titles (`Omschrijving`, `Amount`, …) |
| `discount_keywords.list` | Promo wording (`Bonus`, `Sparing`, …) |
| `total_keywords.*` | Other words for subtotal / grand total |
| `payment_keywords.list` | Card brands, `Betaald met …` |
| `tax_breakdown_keywords.list` | VAT table labels |
| `noise_footer_keywords.list` | Loyalty, hours, slogans that were parsed as items |
| `quantity_modifier_pattern` | Only if `N X` looks different (`2x`, `2 *`, `à`) |
Matching rules implemented in code (not only the JSON comment):
- Keywords of **3 characters or fewer** use a **word boundary** (`PIN` must
not match `SPINAZIE`).
- Column headers and item-count lines must **be the whole line** (so `Prijs`
as a header does not hide Lidl's `In prijs verlaagd`).
After editing the JSON, rebuild the app (it is loaded as an asset at startup).
Tests load the same file from the project root.
### 2. Store quirks table
Add a row here **and** in `receipt-parsing-spec.md` section 6:
| Store | Layout | Notes |
|---|---|---|
| Jumbo | A | `ACTIE <name>` + negative amount. Tax letter `B`/`C` far right. OCR may split names vs amounts → column fallback. |
| Lidl | sequential | `In prijs verlaagd -0,50`. Ignore `Bedr.Excl` / `B 9%` rows. |
| Kruidvat | B | `N X price PER STUK` on the product line. `KORTING …` may be one line later. |
| World Toko | A / simple | `Artikel` / `Prijs` headers. Weight in the name is not quantity. |
| *(new shop)* | A / B / columns | Short description of the odd lines |
### 3. Canonical store name
If OCR should pre-select the supermarket on Review:
- Add the banner string to `known_stores.list`
- Add an alias in `lib/utils/supermarkets.dart` (`kSupermarketAliases`)
- Map the uppercase banner in `ReceiptParser._canonicalStore`
### 4. Fixture test
1. Scan a real receipt, copy **Raw OCR text** from Review.
2. Add a test in `test/receipt_parser_test.dart` with that dump.
3. Assert product names, a couple of prices, quantity lines, and that headers /
totals / payment lines are **not** items.
Keep the raw dump in the test (or a `test/fixtures/` file) so the next person
can see the real layout.
### 5. Only then change parser code
Change `lib/services/receipt_parser.dart` if the new layout is a **new
structure** (not just new words): extra columns, quantity written as
`2 stuks à 1,50`, discounts *above* the product, etc.
Prefer a small, documented branch over a one-off special case for one shop.
---
## Files
| File | Role |
|---|---|
| `receipt-parsing-keywords.json` | Editable keyword / regex config |
| `receipt-parsing-spec.md` | Design: classification order, item algorithm |
| `lib/services/receipt_parse_config.dart` | Loads JSON, keyword matching |
| `lib/services/receipt_parser.dart` | Classify → items → validate |
| `test/receipt_parser_test.dart` | Layout fixtures |
---
## Out of scope (parser)
- Product categories
- Matching names to the existing catalog
- Translating receipts; add another keyword list if you need a second language
+181
View File
@@ -0,0 +1,181 @@
/// Drag a crop box over the receipt photo before OCR runs.
library;
import 'dart:io';
import 'dart:typed_data';
import 'package:crop_your_image/crop_your_image.dart';
import 'package:flutter/material.dart';
class CropReceiptScreen extends StatefulWidget {
const CropReceiptScreen({super.key, required this.imagePath});
final String imagePath;
@override
State<CropReceiptScreen> createState() => _CropReceiptScreenState();
}
class _CropReceiptScreenState extends State<CropReceiptScreen> {
final CropController _controller = CropController();
Uint8List? _image;
String? _error;
bool _ready = false;
bool _cropping = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final bytes = await File(widget.imagePath).readAsBytes();
if (!mounted) return;
setState(() => _image = bytes);
} catch (_) {
if (!mounted) return;
setState(() => _error = 'Could not open this photo.');
}
}
void _onCropped(CropResult result) {
switch (result) {
case CropSuccess(:final croppedImage):
_saveAndPop(croppedImage);
case CropFailure():
if (!mounted) return;
setState(() => _cropping = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not crop this photo.')),
);
}
}
Future<void> _saveAndPop(Uint8List bytes) async {
try {
final file = File(
'${Directory.systemTemp.path}/receipity_crop_${DateTime.now().millisecondsSinceEpoch}.jpg',
);
await file.writeAsBytes(bytes, flush: true);
if (!mounted) return;
Navigator.of(context).pop(file.path);
} catch (_) {
if (!mounted) return;
setState(() => _cropping = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not save the cropped photo.')),
);
}
}
void _confirm() {
if (!_ready || _cropping) return;
setState(() => _cropping = true);
_controller.crop();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return PopScope(
canPop: !_cropping,
child: Scaffold(
appBar: AppBar(title: const Text('Crop receipt')),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: _editor(theme)),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Drag the corners over the text you want to read. Pinch to zoom.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _ready && !_cropping ? _confirm : null,
icon: const Icon(Icons.check),
label: Text(_cropping ? 'Cropping…' : 'Use this area'),
),
],
),
),
),
],
),
),
);
}
Widget _editor(ThemeData theme) {
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
_error!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
),
);
}
final image = _image;
if (image == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
fit: StackFit.expand,
children: [
Crop(
image: image,
controller: _controller,
onCropped: _onCropped,
interactive: true,
filterQuality: FilterQuality.medium,
baseColor: theme.colorScheme.surface,
maskColor: Colors.black.withValues(alpha: 0.55),
radius: 8,
progressIndicator: const Center(child: CircularProgressIndicator()),
initialRectBuilder: InitialRectBuilder.withBuilder((
viewportRect,
imageRect,
) {
final dx = imageRect.width * 0.06;
final dy = imageRect.height * 0.06;
return Rect.fromLTRB(
imageRect.left + dx,
imageRect.top + dy,
imageRect.right - dx,
imageRect.bottom - dy,
);
}),
cornerDotBuilder: (size, _) =>
DotControl(color: theme.colorScheme.primary),
onStatusChanged: (status) {
final ready = status == CropStatus.ready;
if (ready == _ready) return;
setState(() => _ready = ready);
},
),
if (_cropping)
const ColoredBox(
color: Color(0x66000000),
child: Center(child: CircularProgressIndicator()),
),
],
);
}
}
+20 -11
View File
@@ -1,4 +1,4 @@
/// Bottom navigation: products, scan, and shop log. /// Bottom navigation: products, lists, add, and shop log.
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -8,6 +8,7 @@ import 'scan_screen.dart';
import 'services/app_settings.dart'; import 'services/app_settings.dart';
import 'services/product_repository.dart'; import 'services/product_repository.dart';
import 'shop_log_screen.dart'; import 'shop_log_screen.dart';
import 'shopping_lists_screen.dart';
class HomeShell extends StatefulWidget { class HomeShell extends StatefulWidget {
const HomeShell({super.key, required this.repository}); const HomeShell({super.key, required this.repository});
@@ -19,10 +20,10 @@ class HomeShell extends StatefulWidget {
} }
class _HomeShellState extends State<HomeShell> { class _HomeShellState extends State<HomeShell> {
static const _scanIndex = 1; static const _addIndex = 2;
static const _shopLogIndex = 2; static const _shopLogIndex = 3;
int _index = _scanIndex; int _index = _addIndex;
int _revision = 0; int _revision = 0;
AppSettings? _settings; AppSettings? _settings;
int _seenGeneration = 0; int _seenGeneration = 0;
@@ -59,9 +60,8 @@ class _HomeShellState extends State<HomeShell> {
}); });
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context)
const SnackBar(content: Text('Shopping trip saved.')), .showSnackBar(const SnackBar(content: Text('Shopping trip saved.')));
);
}); });
} }
@@ -73,7 +73,11 @@ class _HomeShellState extends State<HomeShell> {
key: ValueKey('products-$_revision'), key: ValueKey('products-$_revision'),
repository: widget.repository, repository: widget.repository,
), ),
1 => ScanScreen( 1 => ShoppingListsScreen(
key: ValueKey('lists-$_revision'),
repository: widget.repository,
),
2 => ScanScreen(
repository: widget.repository, repository: widget.repository,
onTripSaved: _onTripSaved, onTripSaved: _onTripSaved,
), ),
@@ -92,9 +96,14 @@ class _HomeShellState extends State<HomeShell> {
label: 'Products', label: 'Products',
), ),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.photo_camera_outlined), icon: Icon(Icons.checklist_outlined),
selectedIcon: Icon(Icons.photo_camera), selectedIcon: Icon(Icons.checklist),
label: 'Scan', label: 'Lists',
),
NavigationDestination(
icon: Icon(Icons.add),
selectedIcon: Icon(Icons.add_circle),
label: 'Add',
), ),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.receipt_long_outlined), icon: Icon(Icons.receipt_long_outlined),
+3 -1
View File
@@ -6,11 +6,13 @@ import 'package:flutter/material.dart';
import 'home_shell.dart'; import 'home_shell.dart';
import 'services/app_settings.dart'; import 'services/app_settings.dart';
import 'services/product_repository.dart'; import 'services/product_repository.dart';
import 'services/receipt_parser.dart';
import 'utils/app_theme.dart'; import 'utils/app_theme.dart';
import 'utils/constants.dart'; import 'utils/constants.dart';
void main() { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await ReceiptParser.loadConfig();
runApp(ReceipityApp()); runApp(ReceipityApp());
} }
+8
View File
@@ -8,20 +8,27 @@ class LineItem {
required this.price, required this.price,
this.quantity, this.quantity,
this.unitPrice, this.unitPrice,
this.discount = 0,
}); });
final String id; final String id;
final String name; final String name;
/// Line total after any discount.
final double price; final double price;
final int? quantity; final int? quantity;
final double? unitPrice; final double? unitPrice;
/// Negative amount when a discount was folded into [price].
final double discount;
LineItem copyWith({ LineItem copyWith({
String? id, String? id,
String? name, String? name,
double? price, double? price,
int? quantity, int? quantity,
double? unitPrice, double? unitPrice,
double? discount,
bool clearQuantity = false, bool clearQuantity = false,
}) { }) {
return LineItem( return LineItem(
@@ -30,6 +37,7 @@ class LineItem {
price: price ?? this.price, price: price ?? this.price,
quantity: clearQuantity ? null : (quantity ?? this.quantity), quantity: clearQuantity ? null : (quantity ?? this.quantity),
unitPrice: clearQuantity ? null : (unitPrice ?? this.unitPrice), unitPrice: clearQuantity ? null : (unitPrice ?? this.unitPrice),
discount: discount ?? this.discount,
); );
} }
} }
+57
View File
@@ -0,0 +1,57 @@
/// A named list of things to buy.
library;
class ShoppingList {
const ShoppingList({
required this.id,
required this.name,
required this.itemCount,
required this.checkedCount,
required this.updatedAt,
});
final int id;
final String name;
final int itemCount;
final int checkedCount;
final DateTime updatedAt;
factory ShoppingList.fromMap(Map<String, Object?> map) {
return ShoppingList(
id: map['id']! as int,
name: map['name']! as String,
itemCount: (map['item_count'] as num?)?.toInt() ?? 0,
checkedCount: (map['checked_count'] as num?)?.toInt() ?? 0,
updatedAt: DateTime.parse(map['updated_at']! as String),
);
}
}
class ShoppingListItem {
const ShoppingListItem({
required this.id,
required this.listId,
required this.name,
required this.checked,
required this.sortOrder,
this.productId,
});
final int id;
final int listId;
final String name;
final bool checked;
final int sortOrder;
final int? productId;
factory ShoppingListItem.fromMap(Map<String, Object?> map) {
return ShoppingListItem(
id: map['id']! as int,
listId: map['list_id']! as int,
name: map['name']! as String,
checked: (map['checked'] as int? ?? 0) == 1,
sortOrder: (map['sort_order'] as num?)?.toInt() ?? 0,
productId: map['product_id'] as int?,
);
}
}
+46 -117
View File
@@ -9,6 +9,7 @@ import 'services/product_repository.dart';
import 'utils/dates.dart'; import 'utils/dates.dart';
import 'utils/money.dart'; import 'utils/money.dart';
import 'utils/supermarkets.dart'; import 'utils/supermarkets.dart';
import 'widgets/item_edit_sheet.dart';
import 'widgets/store_tag.dart'; import 'widgets/store_tag.dart';
class ReviewScreen extends StatefulWidget { class ReviewScreen extends StatefulWidget {
@@ -19,6 +20,8 @@ class ReviewScreen extends StatefulWidget {
required this.repository, required this.repository,
this.imagePath, this.imagePath,
this.supermarketNames = kSupermarkets, this.supermarketNames = kSupermarkets,
this.needsReview = false,
this.detectedStore,
}); });
final List<LineItem> items; final List<LineItem> items;
@@ -26,6 +29,8 @@ class ReviewScreen extends StatefulWidget {
final ProductRepository repository; final ProductRepository repository;
final String? imagePath; final String? imagePath;
final List<String> supermarketNames; final List<String> supermarketNames;
final bool needsReview;
final String? detectedStore;
@override @override
State<ReviewScreen> createState() => _ReviewScreenState(); State<ReviewScreen> createState() => _ReviewScreenState();
@@ -49,6 +54,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
); );
if (detected != null) { if (detected != null) {
_supermarket = detected; _supermarket = detected;
} else if (widget.detectedStore != null) {
_supermarket = widget.detectedStore;
} }
} }
@@ -63,12 +70,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
} }
Future<void> _edit({LineItem? existing}) async { Future<void> _edit({LineItem? existing}) async {
final result = await showModalBottomSheet<LineItem>( final result = await showItemEditSheet(context, item: existing);
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => _ItemEditSheet(item: existing),
);
if (result == null || !mounted) return; if (result == null || !mounted) return;
setState(() { setState(() {
@@ -97,9 +99,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
Future<void> _confirm() async { Future<void> _confirm() async {
final supermarket = _resolvedSupermarket; final supermarket = _resolvedSupermarket;
if (supermarket == null) { if (supermarket == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context)
const SnackBar(content: Text('Select a supermarket.')), .showSnackBar(const SnackBar(content: Text('Select a supermarket.')));
);
return; return;
} }
if (_items.isEmpty) { if (_items.isEmpty) {
@@ -142,7 +143,13 @@ class _ReviewScreenState extends State<ReviewScreen> {
} }
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Review items')), appBar: AppBar(
title: Text(
widget.rawText.isEmpty && widget.imagePath == null
? 'New trip'
: 'Review items',
),
),
body: Column( body: Column(
children: [ children: [
Padding( Padding(
@@ -150,13 +157,27 @@ class _ReviewScreenState extends State<ReviewScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (widget.needsReview) ...[
Material(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Parsed totals do not match the receipt. Check the items before saving.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
),
const SizedBox(height: 12),
],
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
key: ValueKey(_supermarket), key: ValueKey(_supermarket),
initialValue: _supermarket, initialValue: _supermarket,
hint: const Text('Select supermarket'), hint: const Text('Select supermarket'),
decoration: const InputDecoration( decoration: const InputDecoration(labelText: 'Supermarket'),
labelText: 'Supermarket',
),
items: [ items: [
for (final store in names) for (final store in names)
DropdownMenuItem( DropdownMenuItem(
@@ -177,9 +198,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
TextField( TextField(
enabled: !_saving, enabled: !_saving,
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,
decoration: const InputDecoration( decoration: const InputDecoration(labelText: 'Store name'),
labelText: 'Store name',
),
onChanged: (value) => _customStore = value, onChanged: (value) => _customStore = value,
), ),
], ],
@@ -198,7 +217,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
child: _items.isEmpty child: _items.isEmpty
? Center( ? Center(
child: Text( child: Text(
'No line items found.\nAdd anything the parser missed.', 'No line items yet.\nAdd products for this trip.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge, style: theme.textTheme.bodyLarge,
), ),
@@ -231,11 +250,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
}, },
child: ListTile( child: ListTile(
title: Text(item.name), title: Text(item.name),
subtitle: item.quantity == null subtitle: _itemSubtitle(item),
? null
: Text(
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
),
trailing: Text( trailing: Text(
formatEuro(item.price), formatEuro(item.price),
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium,
@@ -274,9 +289,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
FilledButton( FilledButton(
onPressed: _saving ? null : _confirm, onPressed: _saving ? null : _confirm,
child: Text( child: Text(
_saving _saving ? 'Saving…' : 'Save trip · ${formatEuro(total)}',
? 'Saving…'
: 'Save trip · ${formatEuro(total)}',
), ),
), ),
], ],
@@ -287,98 +300,14 @@ class _ReviewScreenState extends State<ReviewScreen> {
), ),
); );
} }
}
class _ItemEditSheet extends StatefulWidget { Widget? _itemSubtitle(LineItem item) {
const _ItemEditSheet({this.item}); final parts = [
if (item.quantity != null)
final LineItem? item; '${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
if (item.discount != 0) 'Discount ${formatEuro(item.discount.abs())}',
@override ];
State<_ItemEditSheet> createState() => _ItemEditSheetState(); if (parts.isEmpty) return null;
} return Text(parts.join(' · '));
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'),
),
],
),
);
} }
} }
+62 -24
View File
@@ -8,6 +8,8 @@ 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 'crop_receipt_screen.dart';
import 'models/line_item.dart';
import 'review_screen.dart'; import 'review_screen.dart';
import 'services/app_settings.dart'; import 'services/app_settings.dart';
import 'services/product_repository.dart'; import 'services/product_repository.dart';
@@ -17,11 +19,7 @@ import 'utils/supermarkets.dart';
import 'widgets/settings_button.dart'; import 'widgets/settings_button.dart';
class ScanScreen extends StatefulWidget { class ScanScreen extends StatefulWidget {
const ScanScreen({ const ScanScreen({super.key, required this.repository, this.onTripSaved});
super.key,
required this.repository,
this.onTripSaved,
});
final ProductRepository repository; final ProductRepository repository;
final VoidCallback? onTripSaved; final VoidCallback? onTripSaved;
@@ -51,7 +49,7 @@ class _ScanScreenState extends State<ScanScreen> {
if (!mounted || response.isEmpty) return; if (!mounted || response.isEmpty) return;
final file = response.file; final file = response.file;
if (file != null) { if (file != null) {
await _runOcr(file.path); await _cropThenOcr(file.path);
} }
} on UnimplementedError { } on UnimplementedError {
// Desktop / platforms that do not support lost-data recovery. // Desktop / platforms that do not support lost-data recovery.
@@ -59,12 +57,9 @@ class _ScanScreenState extends State<ScanScreen> {
} }
Future<void> _capture(ImageSource source) async { Future<void> _capture(ImageSource source) async {
final file = await _picker.pickImage( final file = await _picker.pickImage(source: source, imageQuality: 95);
source: source,
imageQuality: 95,
);
if (file == null || !mounted) return; if (file == null || !mounted) return;
await _runOcr(file.path); await _cropThenOcr(file.path);
} }
Future<void> _scanSampleReceipt() async { Future<void> _scanSampleReceipt() async {
@@ -75,7 +70,7 @@ class _ScanScreenState extends State<ScanScreen> {
); );
await file.writeAsBytes(data.buffer.asUint8List(), flush: true); await file.writeAsBytes(data.buffer.asUint8List(), flush: true);
if (!mounted) return; if (!mounted) return;
await _runOcr(file.path); await _cropThenOcr(file.path);
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -84,6 +79,17 @@ class _ScanScreenState extends State<ScanScreen> {
} }
} }
Future<void> _cropThenOcr(String imagePath) async {
final croppedPath = await Navigator.of(context).push<String>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => CropReceiptScreen(imagePath: imagePath),
),
);
if (croppedPath == null || !mounted) return;
await _runOcr(croppedPath);
}
Future<void> _runOcr(String imagePath) async { Future<void> _runOcr(String imagePath) async {
setState(() { setState(() {
_busy = true; _busy = true;
@@ -103,10 +109,17 @@ class _ScanScreenState extends State<ScanScreen> {
_error = 'No text found in this image.'; _error = 'No text found in this image.';
} }
}); });
} catch (error) { } on PlatformException catch (error) {
debugPrint('OCR failed: ${error.code} ${error.message}');
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_error = 'Could not read text from this image.'; _error = _ocrFailureMessage(error.message);
});
} catch (error) {
debugPrint('OCR failed: $error');
if (!mounted) return;
setState(() {
_error = _ocrFailureMessage(error.toString());
}); });
} finally { } finally {
await recognizer.close(); await recognizer.close();
@@ -120,8 +133,26 @@ class _ScanScreenState extends State<ScanScreen> {
} }
} }
Future<void> _openReview(String rawText) async { Future<void> _enterManually() async {
final items = ReceiptParser.parse(rawText); await _openReview('', items: const []);
}
String _ocrFailureMessage(String? detail) {
const fallback = 'Could not read text from this image.';
if (detail == null || detail.trim().isEmpty) return fallback;
return '$fallback\n$detail';
}
Future<void> _openReview(
String rawText, {
List<LineItem>? items,
String? imagePath,
}) async {
final parsedItems = items;
ReceiptParseResult? parsed;
if (parsedItems == null) {
parsed = ReceiptParser.parseReceipt(rawText);
}
if (!mounted) return; if (!mounted) return;
final names = final names =
SettingsScope.maybeOf(context)?.supermarkets.map((s) => s.name) ?? SettingsScope.maybeOf(context)?.supermarkets.map((s) => s.name) ??
@@ -129,11 +160,13 @@ class _ScanScreenState extends State<ScanScreen> {
final saved = await Navigator.of(context).push<bool>( final saved = await Navigator.of(context).push<bool>(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => ReviewScreen( builder: (_) => ReviewScreen(
items: items, items: parsedItems ?? parsed!.items,
rawText: rawText, rawText: rawText,
repository: widget.repository, repository: widget.repository,
imagePath: _imagePath, imagePath: imagePath ?? (items == null ? _imagePath : null),
supermarketNames: names.toList(), supermarketNames: names.toList(),
needsReview: parsed != null && !parsed.validationPassed,
detectedStore: parsed?.store,
), ),
), ),
); );
@@ -169,6 +202,12 @@ class _ScanScreenState extends State<ScanScreen> {
label: const Text('Pick from gallery'), label: const Text('Pick from gallery'),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _busy ? null : _enterManually,
icon: const Icon(Icons.edit_note_outlined),
label: const Text('Enter manually'),
),
const SizedBox(height: 8),
TextButton.icon( TextButton.icon(
onPressed: _busy ? null : _scanSampleReceipt, onPressed: _busy ? null : _scanSampleReceipt,
icon: const Icon(Icons.receipt_long_outlined), icon: const Icon(Icons.receipt_long_outlined),
@@ -185,13 +224,12 @@ class _ScanScreenState extends State<ScanScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
const LinearProgressIndicator(), const LinearProgressIndicator(),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text('Reading text…', style: theme.textTheme.bodyMedium),
'Reading text…',
style: theme.textTheme.bodyMedium,
),
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
Expanded(child: _ResultPane(text: _extractedText, error: _error)), Expanded(
child: _ResultPane(text: _extractedText, error: _error),
),
], ],
), ),
), ),
@@ -223,7 +261,7 @@ class _ResultPane extends StatelessWidget {
if (text.isEmpty) { if (text.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'Take a photo of a shopping receipt, or pick one from the gallery.\n\nConfirmed trips show up in Shop log.', 'Scan a receipt or enter a trip by hand.\n\nConfirmed trips show up in Shop log.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge, style: theme.textTheme.bodyLarge,
), ),
+9 -1
View File
@@ -3,7 +3,7 @@ library;
import 'dart:convert'; import 'dart:convert';
const int kBackupVersion = 1; const int kBackupVersion = 2;
class BackupDocument { class BackupDocument {
const BackupDocument({ const BackupDocument({
@@ -14,6 +14,8 @@ class BackupDocument {
required this.products, required this.products,
required this.shops, required this.shops,
required this.shopItems, required this.shopItems,
required this.shoppingLists,
required this.shoppingListItems,
required this.images, required this.images,
}); });
@@ -24,6 +26,8 @@ class BackupDocument {
final List<Map<String, Object?>> products; final List<Map<String, Object?>> products;
final List<Map<String, Object?>> shops; final List<Map<String, Object?>> shops;
final List<Map<String, Object?>> shopItems; final List<Map<String, Object?>> shopItems;
final List<Map<String, Object?>> shoppingLists;
final List<Map<String, Object?>> shoppingListItems;
final Map<String, String> images; final Map<String, String> images;
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
@@ -35,6 +39,8 @@ class BackupDocument {
'products': products, 'products': products,
'shops': shops, 'shops': shops,
'shopItems': shopItems, 'shopItems': shopItems,
'shoppingLists': shoppingLists,
'shoppingListItems': shoppingListItems,
'images': images, 'images': images,
}; };
@@ -60,6 +66,8 @@ class BackupDocument {
products: _maps(decoded['products']), products: _maps(decoded['products']),
shops: _maps(decoded['shops']), shops: _maps(decoded['shops']),
shopItems: _maps(decoded['shopItems']), shopItems: _maps(decoded['shopItems']),
shoppingLists: _maps(decoded['shoppingLists']),
shoppingListItems: _maps(decoded['shoppingListItems']),
images: _strings(decoded['images']), images: _strings(decoded['images']),
); );
} }
+9 -5
View File
@@ -57,6 +57,8 @@ class BackupService {
products: rewrittenProducts, products: rewrittenProducts,
shops: rewrittenShops, shops: rewrittenShops,
shopItems: await repository.dumpTable('shop_items'), shopItems: await repository.dumpTable('shop_items'),
shoppingLists: await repository.dumpTable('shopping_lists'),
shoppingListItems: await repository.dumpTable('shopping_list_items'),
images: images, images: images,
); );
} }
@@ -114,7 +116,10 @@ class BackupService {
final now = DateTime.now().toIso8601String(); final now = DateTime.now().toIso8601String();
for (final row in document.products) { for (final row in document.products) {
final copy = Map<String, Object?>.from(row); final copy = Map<String, Object?>.from(row);
copy['image_path'] = await _restoreImage(copy['image_path'], document.images); copy['image_path'] = await _restoreImage(
copy['image_path'],
document.images,
);
copy['created_at'] ??= now; copy['created_at'] ??= now;
copy['updated_at'] ??= copy['created_at']; copy['updated_at'] ??= copy['created_at'];
if (copy['normalized_name'] == null && copy['name'] is String) { if (copy['normalized_name'] == null && copy['name'] is String) {
@@ -141,14 +146,13 @@ class BackupService {
products: products, products: products,
shops: shops, shops: shops,
shopItems: document.shopItems, shopItems: document.shopItems,
shoppingLists: document.shoppingLists,
shoppingListItems: document.shoppingListItems,
darkMode: document.darkMode, darkMode: document.darkMode,
); );
} }
Future<String?> _restoreImage( Future<String?> _restoreImage(Object? key, Map<String, String> images) async {
Object? key,
Map<String, String> images,
) async {
if (key is! String || key.isEmpty) return null; if (key is! String || key.isEmpty) return null;
final encoded = images[key]; final encoded = images[key];
if (encoded == null) return null; if (encoded == null) return null;
+306 -18
View File
@@ -11,6 +11,7 @@ import '../models/line_item.dart';
import '../models/product.dart'; import '../models/product.dart';
import '../models/purchase.dart'; import '../models/purchase.dart';
import '../models/shop.dart'; import '../models/shop.dart';
import '../models/shopping_list.dart';
import '../models/supermarket.dart'; import '../models/supermarket.dart';
import '../utils/money.dart'; import '../utils/money.dart';
import '../utils/supermarkets.dart'; import '../utils/supermarkets.dart';
@@ -27,15 +28,20 @@ class ProductRepository {
} }
Future<Database> _open() async { Future<Database> _open() async {
final path = databasePath ?? final path =
databasePath ??
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db'); p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
return openDatabase( return openDatabase(
path, path,
version: 4, version: 5,
onConfigure: (db) async {
await db.execute('PRAGMA foreign_keys = ON');
},
onCreate: (db, version) async { onCreate: (db, version) async {
await _createProducts(db); await _createProducts(db);
await _createShopTables(db); await _createShopTables(db);
await _createSettingsTables(db); await _createSettingsTables(db);
await _createShoppingListTables(db);
await _seedSupermarkets(db); await _seedSupermarkets(db);
}, },
onUpgrade: (db, oldVersion, newVersion) async { onUpgrade: (db, oldVersion, newVersion) async {
@@ -55,6 +61,9 @@ class ProductRepository {
if (oldVersion < 4) { if (oldVersion < 4) {
await db.execute('ALTER TABLE products ADD COLUMN category TEXT'); await db.execute('ALTER TABLE products ADD COLUMN category TEXT');
} }
if (oldVersion < 5) {
await _createShoppingListTables(db);
}
}, },
); );
} }
@@ -133,6 +142,29 @@ class ProductRepository {
'''); ''');
} }
Future<void> _createShoppingListTables(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS shopping_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE IF NOT EXISTS shopping_list_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL,
product_id INTEGER,
name TEXT NOT NULL,
checked INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (list_id) REFERENCES shopping_lists(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id)
)
''');
}
static String normalizeName(String name) => static String normalizeName(String name) =>
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' '); name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
@@ -315,13 +347,11 @@ class ProductRepository {
Future<List<String>> getUsedCategories() async { Future<List<String>> getUsedCategories() async {
final db = await _database; final db = await _database;
final rows = await db.rawQuery( final rows = await db.rawQuery('''
'''
SELECT DISTINCT category FROM products SELECT DISTINCT category FROM products
WHERE category IS NOT NULL AND TRIM(category) != '' WHERE category IS NOT NULL AND TRIM(category) != ''
ORDER BY category COLLATE NOCASE ASC ORDER BY category COLLATE NOCASE ASC
''', ''');
);
return [ return [
for (final row in rows) for (final row in rows)
if (row['category'] is String) row['category']! as String, if (row['category'] is String) row['category']! as String,
@@ -455,6 +485,238 @@ class ProductRepository {
return rows.map(ShopItem.fromMap).toList(); return rows.map(ShopItem.fromMap).toList();
} }
/// Removes a trip and its line items. Catalog products are left unchanged.
Future<void> deleteShop(int id) async {
final shop = await getShop(id);
final db = await _database;
await db.transaction((txn) async {
await txn.delete('shop_items', where: 'shop_id = ?', whereArgs: [id]);
await txn.delete('shops', where: 'id = ?', whereArgs: [id]);
});
final path = shop?.receiptImagePath;
if (path == null || path.isEmpty) return;
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
/// Removes one trip line. Catalog products are left unchanged.
Future<void> deleteShopItem(int id) async {
final db = await _database;
final rows = await db.query(
'shop_items',
columns: ['shop_id'],
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return;
final shopId = rows.first['shop_id']! as int;
await db.delete('shop_items', where: 'id = ?', whereArgs: [id]);
await _recalculateShopTotal(shopId);
}
Future<void> updateShopItem({
required int id,
String? name,
double? price,
int? quantity,
double? unitPrice,
}) async {
final db = await _database;
final rows = await db.query(
'shop_items',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return;
final shopId = rows.first['shop_id']! as int;
final values = <String, Object?>{};
if (name != null) values['name'] = name.trim();
if (price != null) values['price'] = price;
if (quantity != null) values['quantity'] = quantity;
if (unitPrice != null) values['unit_price'] = unitPrice;
if (values.isEmpty) return;
await db.update('shop_items', values, where: 'id = ?', whereArgs: [id]);
await _recalculateShopTotal(shopId);
}
Future<void> _recalculateShopTotal(int shopId) async {
final db = await _database;
final rows = await db.rawQuery(
'SELECT COALESCE(SUM(price), 0) AS total FROM shop_items WHERE shop_id = ?',
[shopId],
);
final total = roundMoney((rows.first['total'] as num).toDouble());
await db.update(
'shops',
{'total': total},
where: 'id = ?',
whereArgs: [shopId],
);
}
Future<List<ShoppingList>> getShoppingLists() async {
final db = await _database;
final rows = await db.rawQuery('''
SELECT
shopping_lists.id,
shopping_lists.name,
shopping_lists.updated_at,
COUNT(shopping_list_items.id) AS item_count,
COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count
FROM shopping_lists
LEFT JOIN shopping_list_items
ON shopping_list_items.list_id = shopping_lists.id
GROUP BY shopping_lists.id
ORDER BY shopping_lists.updated_at DESC, shopping_lists.id DESC
''');
return rows.map(ShoppingList.fromMap).toList();
}
Future<ShoppingList?> getShoppingList(int id) async {
final db = await _database;
final rows = await db.rawQuery(
'''
SELECT
shopping_lists.id,
shopping_lists.name,
shopping_lists.updated_at,
COUNT(shopping_list_items.id) AS item_count,
COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count
FROM shopping_lists
LEFT JOIN shopping_list_items
ON shopping_list_items.list_id = shopping_lists.id
WHERE shopping_lists.id = ?
GROUP BY shopping_lists.id
''',
[id],
);
if (rows.isEmpty) return null;
return ShoppingList.fromMap(rows.first);
}
Future<int> createShoppingList(String name) async {
final db = await _database;
final now = DateTime.now().toIso8601String();
return db.insert('shopping_lists', {
'name': name.trim(),
'created_at': now,
'updated_at': now,
});
}
Future<void> renameShoppingList(int id, String name) async {
final db = await _database;
await db.update(
'shopping_lists',
{'name': name.trim(), 'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> deleteShoppingList(int id) async {
final db = await _database;
await db.transaction((txn) async {
await txn.delete(
'shopping_list_items',
where: 'list_id = ?',
whereArgs: [id],
);
await txn.delete('shopping_lists', where: 'id = ?', whereArgs: [id]);
});
}
Future<List<ShoppingListItem>> getShoppingListItems(int listId) async {
final db = await _database;
final rows = await db.query(
'shopping_list_items',
where: 'list_id = ?',
whereArgs: [listId],
orderBy: 'checked ASC, sort_order ASC, id ASC',
);
return rows.map(ShoppingListItem.fromMap).toList();
}
Future<int> addShoppingListItem({
required int listId,
required String name,
int? productId,
}) async {
final db = await _database;
final max = Sqflite.firstIntValue(
await db.rawQuery(
'SELECT MAX(sort_order) FROM shopping_list_items WHERE list_id = ?',
[listId],
),
);
final id = await db.insert('shopping_list_items', {
'list_id': listId,
'product_id': productId,
'name': name.trim(),
'checked': 0,
'sort_order': (max ?? -1) + 1,
});
await _touchShoppingList(listId);
return id;
}
Future<void> updateShoppingListItem({
required int id,
String? name,
bool? checked,
int? productId,
}) async {
final db = await _database;
final rows = await db.query(
'shopping_list_items',
columns: ['list_id'],
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return;
final values = <String, Object?>{};
if (name != null) values['name'] = name.trim();
if (checked != null) values['checked'] = checked ? 1 : 0;
if (productId != null) values['product_id'] = productId;
if (values.isEmpty) return;
await db.update(
'shopping_list_items',
values,
where: 'id = ?',
whereArgs: [id],
);
await _touchShoppingList(rows.first['list_id']! as int);
}
Future<void> deleteShoppingListItem(int id) async {
final db = await _database;
final rows = await db.query(
'shopping_list_items',
columns: ['list_id'],
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return;
await db.delete('shopping_list_items', where: 'id = ?', whereArgs: [id]);
await _touchShoppingList(rows.first['list_id']! as int);
}
Future<void> _touchShoppingList(int id) async {
final db = await _database;
await db.update(
'shopping_lists',
{'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
}
Future<bool> getDarkMode() async { Future<bool> getDarkMode() async {
final db = await _database; final db = await _database;
final rows = await db.query( final rows = await db.query(
@@ -469,16 +731,18 @@ class ProductRepository {
Future<void> setDarkMode(bool enabled) async { Future<void> setDarkMode(bool enabled) async {
final db = await _database; final db = await _database;
await db.insert( await db.insert('app_settings', {
'app_settings', 'key': 'dark_mode',
{'key': 'dark_mode', 'value': enabled ? '1' : '0'}, 'value': enabled ? '1' : '0',
conflictAlgorithm: ConflictAlgorithm.replace, }, conflictAlgorithm: ConflictAlgorithm.replace);
);
} }
Future<List<Supermarket>> getSupermarkets() async { Future<List<Supermarket>> getSupermarkets() async {
final db = await _database; final db = await _database;
final rows = await db.query('supermarkets', orderBy: 'sort_order ASC, id ASC'); final rows = await db.query(
'supermarkets',
orderBy: 'sort_order ASC, id ASC',
);
if (rows.isEmpty) { if (rows.isEmpty) {
await _seedSupermarkets(db); await _seedSupermarkets(db);
return getSupermarkets(); return getSupermarkets();
@@ -579,9 +843,13 @@ class ProductRepository {
required List<Map<String, Object?>> shops, required List<Map<String, Object?>> shops,
required List<Map<String, Object?>> shopItems, required List<Map<String, Object?>> shopItems,
required bool darkMode, required bool darkMode,
List<Map<String, Object?>> shoppingLists = const [],
List<Map<String, Object?>> shoppingListItems = const [],
}) async { }) async {
final db = await _database; final db = await _database;
await db.transaction((txn) async { await db.transaction((txn) async {
await txn.delete('shopping_list_items');
await txn.delete('shopping_lists');
await txn.delete('shop_items'); await txn.delete('shop_items');
await txn.delete('shops'); await txn.delete('shops');
await txn.delete('products'); await txn.delete('products');
@@ -600,6 +868,15 @@ class ProductRepository {
for (final row in shopItems) { for (final row in shopItems) {
await txn.insert('shop_items', _sqlRow(row, _shopItemColumns)); await txn.insert('shop_items', _sqlRow(row, _shopItemColumns));
} }
for (final row in shoppingLists) {
await txn.insert('shopping_lists', _sqlRow(row, _shoppingListColumns));
}
for (final row in shoppingListItems) {
await txn.insert(
'shopping_list_items',
_sqlRow(row, _shoppingListItemColumns),
);
}
await txn.insert('app_settings', { await txn.insert('app_settings', {
'key': 'dark_mode', 'key': 'dark_mode',
'value': darkMode ? '1' : '0', 'value': darkMode ? '1' : '0',
@@ -609,15 +886,12 @@ class ProductRepository {
await _resetSequence(txn, 'products'); await _resetSequence(txn, 'products');
await _resetSequence(txn, 'shops'); await _resetSequence(txn, 'shops');
await _resetSequence(txn, 'shop_items'); await _resetSequence(txn, 'shop_items');
await _resetSequence(txn, 'shopping_lists');
await _resetSequence(txn, 'shopping_list_items');
}); });
} }
static const _supermarketColumns = { static const _supermarketColumns = {'id', 'name', 'color', 'sort_order'};
'id',
'name',
'color',
'sort_order',
};
static const _productColumns = { static const _productColumns = {
'id', 'id',
'name', 'name',
@@ -649,6 +923,20 @@ class ProductRepository {
'quantity', 'quantity',
'unit_price', 'unit_price',
}; };
static const _shoppingListColumns = {
'id',
'name',
'created_at',
'updated_at',
};
static const _shoppingListItemColumns = {
'id',
'list_id',
'product_id',
'name',
'checked',
'sort_order',
};
Map<String, Object?> _sqlRow( Map<String, Object?> _sqlRow(
Map<String, Object?> source, Map<String, Object?> source,
+179
View File
@@ -0,0 +1,179 @@
/// Keyword and regex config loaded from receipt-parsing-keywords.json.
library;
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
class ReceiptParseConfig {
ReceiptParseConfig({
required this.knownStores,
required this.columnHeaderKeywords,
required this.separatorPattern,
required this.quantityStandalone,
required this.quantityInline,
required this.discountKeywords,
required this.negativeAmount,
required this.totalDiscountKeywords,
required this.subtotalKeywords,
required this.grandTotalKeywords,
required this.taxInclusiveTotalKeywords,
required this.paymentKeywords,
required this.taxKeywords,
required this.categoryRow,
required this.trailingCategoryLetter,
required this.noiseKeywords,
required this.itemCountKeywords,
});
final List<String> knownStores;
final List<String> columnHeaderKeywords;
final RegExp separatorPattern;
final RegExp quantityStandalone;
final RegExp quantityInline;
final List<String> discountKeywords;
final RegExp negativeAmount;
final List<String> totalDiscountKeywords;
final List<String> subtotalKeywords;
final List<String> grandTotalKeywords;
final List<String> taxInclusiveTotalKeywords;
final List<String> paymentKeywords;
final List<String> taxKeywords;
final RegExp categoryRow;
final RegExp trailingCategoryLetter;
final List<String> noiseKeywords;
final List<String> itemCountKeywords;
factory ReceiptParseConfig.fromJson(Map<String, dynamic> json) {
List<String> list(String section, [String key = 'list']) {
final block = json[section];
if (block is! Map) return const [];
final value = block[key];
if (value is! List) return const [];
return [
for (final item in value)
if (item is String) item,
];
}
RegExp rx(String section, String key, {bool caseInsensitive = true}) {
final block = json[section];
final value = block is Map ? block[key] : null;
final source = value is String ? value : '^\$';
return RegExp(source, caseSensitive: !caseInsensitive);
}
return ReceiptParseConfig(
knownStores: list('known_stores'),
columnHeaderKeywords: list('column_header_keywords'),
separatorPattern: rx('column_header_keywords', 'separator_pattern'),
quantityStandalone: rx(
'quantity_modifier_pattern',
'regex',
caseInsensitive: true,
),
quantityInline: rx(
'quantity_modifier_pattern',
'inline_regex',
caseInsensitive: true,
),
discountKeywords: list('discount_keywords'),
negativeAmount: rx('discount_keywords', 'negative_amount_pattern'),
totalDiscountKeywords: list(
'discount_keywords',
'receipt_level_total_discount_keywords',
),
subtotalKeywords: list('total_keywords', 'subtotal_keywords'),
grandTotalKeywords: list('total_keywords', 'grand_total_keywords'),
taxInclusiveTotalKeywords: list(
'total_keywords',
'tax_inclusive_total_keywords',
),
paymentKeywords: list('payment_keywords'),
taxKeywords: list('tax_breakdown_keywords'),
categoryRow: rx('tax_breakdown_keywords', 'category_row_pattern'),
trailingCategoryLetter: rx(
'tax_breakdown_keywords',
'trailing_category_letter_pattern',
),
noiseKeywords: list('noise_footer_keywords'),
itemCountKeywords: list('line_item_count_keywords'),
);
}
static const assetPath = 'receipt-parsing-keywords.json';
/// True if [line] matches a config keyword.
///
/// Short tokens (3 characters or fewer) use a word boundary so `PIN` does
/// not match `SPINAZIE`. Longer tokens use a case-insensitive substring,
/// and a letter-only compact form so OCR splits like `Betaal d:` still
/// match `BETAALD`.
bool matchesKeyword(String line, String keyword) {
final upper = line.toUpperCase();
final key = keyword.toUpperCase();
if (key.length <= 3) {
return RegExp('\\b${RegExp.escape(key)}\\b').hasMatch(upper);
}
if (upper.contains(key)) return true;
final compactLine = upper.replaceAll(RegExp(r'[^A-Z0-9]'), '');
final compactKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
return compactKey.length >= 4 && compactLine.contains(compactKey);
}
bool isStandaloneKeywordLine(String line, List<String> keywords) {
final compact = line.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
for (final keyword in keywords) {
final key = keyword.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
if (key.isEmpty) continue;
if (compact == key) return true;
if (compact.startsWith(key) && compact.length <= key.length + 3) {
return true;
}
}
return false;
}
bool matchesAny(String line, List<String> keywords) {
for (final keyword in keywords) {
if (matchesKeyword(line, keyword)) return true;
}
return false;
}
}
ReceiptParseConfig? _loadedConfig;
ReceiptParseConfig get receiptParseConfig {
if (_loadedConfig != null) return _loadedConfig!;
final file = File(ReceiptParseConfig.assetPath);
if (file.existsSync()) {
_loadedConfig = ReceiptParseConfig.fromJson(
jsonDecode(file.readAsStringSync()) as Map<String, dynamic>,
);
return _loadedConfig!;
}
throw StateError(
'Receipt keyword config was not loaded. Call ReceiptParser.loadConfig().',
);
}
void setReceiptParseConfig(ReceiptParseConfig config) {
_loadedConfig = config;
}
Future<void> loadReceiptParseConfig() async {
try {
final raw = await rootBundle.loadString(ReceiptParseConfig.assetPath);
_loadedConfig = ReceiptParseConfig.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
} catch (_) {
final file = File(ReceiptParseConfig.assetPath);
if (!file.existsSync()) rethrow;
_loadedConfig = ReceiptParseConfig.fromJson(
jsonDecode(file.readAsStringSync()) as Map<String, dynamic>,
);
}
}
+499 -113
View File
@@ -1,57 +1,31 @@
/// Turns raw OCR text into [LineItem]s using line-end prices and a keyword filter. /// Turns raw OCR text into [LineItem]s using receipt-parsing-keywords.json.
library; library;
import '../models/line_item.dart'; import '../models/line_item.dart';
import '../utils/money.dart'; import '../utils/money.dart';
import 'receipt_parse_config.dart';
/// Headers, payments, and totals — never treated as products. export 'receipt_parse_config.dart' show loadReceiptParseConfig;
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. /// Result of a full receipt parse, including totals used for review.
const Set<String> kDropKeywords = { class ReceiptParseResult {
'DISCOUNT', const ReceiptParseResult({
'KORTING', required this.items,
'ACTIE', this.store,
}; this.receiptTotal,
this.totalDiscount = 0,
this.validationPassed = true,
});
final _separatorPattern = RegExp(r'^[=\-_.:\s]+$'); final List<LineItem> items;
final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b'); final String? store;
final _phonePattern = RegExp(r'^\d{8,}$'); final double? receiptTotal;
final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}$"); final double totalDiscount;
final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false); final bool validationPassed;
}
class _QtyLine { class _Qty {
const _QtyLine({required this.count, required this.unitPrice, this.total}); const _Qty({required this.count, required this.unitPrice, this.total});
final int count; final int count;
final double unitPrice; final double unitPrice;
@@ -60,6 +34,59 @@ class _QtyLine {
double get lineTotal => total ?? roundMoney(count * unitPrice); double get lineTotal => total ?? roundMoney(count * unitPrice);
} }
class _Pending {
_Pending({
required this.name,
this.unitPrice,
this.quantity = 1,
this.lineTotal,
});
String name;
double? unitPrice;
int quantity;
double? lineTotal;
double discount = 0;
}
enum _Kind {
skip,
item,
nameOnly,
quantity,
discount,
grandTotal,
totalDiscount,
priceOnly,
}
class _Line {
const _Line({
required this.kind,
required this.raw,
this.name,
this.amount,
this.qty,
});
final _Kind kind;
final String raw;
final String? name;
final double? amount;
final _Qty? qty;
}
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}[A-Za-z]?$");
final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false);
/// Jumbo-style OCR often adds a line total after `N X unit`.
final _qtyWithTotal = RegExp(
r'^\s*(\d+)\s*[xX×]\s*(-?\d+[,.]\d{2})(?:\s+(?:PER\s+STUK|PER\s+KG|ST\.?|STUKS?))?(?:\s+(-?\d+[,.]\d{2}))?\s*$',
caseSensitive: false,
);
class ReceiptParser { class ReceiptParser {
ReceiptParser._(); ReceiptParser._();
@@ -67,74 +94,198 @@ class ReceiptParser {
static String _id() => 'item-${++_nextId}'; static String _id() => 'item-${++_nextId}';
/// Parses [rawText] into product rows. static Future<void> loadConfig() => loadReceiptParseConfig();
///
/// Prefers same-line `name … price` (with a following `2 x 1.50` line when static List<LineItem> parse(String rawText) => parseReceipt(rawText).items;
/// 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 ReceiptParseResult parseReceipt(String rawText) {
static List<LineItem> parse(String rawText) { final config = receiptParseConfig;
final lines = rawText final lines = rawText
.split('\n') .split('\n')
.map((line) => line.trim()) .map(_preprocess)
.where((line) => line.isNotEmpty) .where((line) => line.isNotEmpty)
.toList(); .toList();
final inline = _parseInline(lines); if (_looksLikeColumnOcr(lines, config)) {
final columns = _parseColumns(lines); return _parseColumns(lines, config, rawText);
final chosen = columns.length > inline.length ? columns : inline; }
return chosen.where(_keepItem).toList(); return _parseSequential(lines, config, rawText);
} }
static List<LineItem> _parseInline(List<String> lines) { static String _preprocess(String line) =>
line.trim().replaceAll(RegExp(r'\s+'), ' ');
static String _stripTax(String line, ReceiptParseConfig config) {
return line.replaceFirst(config.trailingCategoryLetter, '').trim();
}
static bool _looksLikeColumnOcr(
List<String> lines,
ReceiptParseConfig config,
) {
var run = 0;
var maxRun = 0;
var prices = 0;
var names = 0;
for (final line in lines) {
final stripped = _stripTax(line, config);
if (isPriceOnly(stripped)) {
prices++;
run++;
if (run > maxRun) maxRun = run;
} else {
run = 0;
if (_parseQty(stripped, config) == null &&
!_isIgnorable(stripped, config)) {
names++;
}
}
}
return maxRun >= 4 && prices >= 4 && names >= 4;
}
static ReceiptParseResult _parseSequential(
List<String> lines,
ReceiptParseConfig config,
String rawText,
) {
final items = <LineItem>[]; final items = <LineItem>[];
_Pending? pending;
for (var i = 0; i < lines.length; i++) { var receiptTotal = _readAmountAfterKeyword(
final line = lines[i]; lines,
if (_shouldSkipLine(line) || isPriceOnly(line)) continue; config,
config.grandTotalKeywords,
final qty = _parseQty(line); skipIf: (line) => config.matchesAny(line, [
if (qty != null) continue; ...config.totalDiscountKeywords,
...config.taxInclusiveTotalKeywords,
final trailing = trailingPricePattern.firstMatch(line); ...config.subtotalKeywords,
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++; var totalDiscount = 0.0;
var stop = false;
void flush() {
final current = pending;
pending = null;
if (current == null) return;
final total = current.lineTotal ?? current.unitPrice;
if (total == null) return;
items.add(_toItem(current, total));
} }
return items; for (var i = 0; i < lines.length && !stop; i++) {
final classified = _classify(lines[i], config);
switch (classified.kind) {
case _Kind.skip:
continue;
case _Kind.item:
flush();
pending = _Pending(
name: classified.name!,
unitPrice: classified.amount,
quantity: classified.qty?.count ?? 1,
lineTotal: classified.qty == null
? classified.amount
: classified.qty!.lineTotal,
);
if (classified.qty != null) {
pending!.unitPrice = classified.qty!.unitPrice;
}
case _Kind.nameOnly:
flush();
pending = _Pending(name: classified.name ?? classified.raw);
case _Kind.quantity:
final qty = classified.qty!;
if (pending != null) {
pending!
..quantity = qty.count
..unitPrice = qty.unitPrice
..lineTotal = qty.lineTotal;
}
case _Kind.discount:
var amount = classified.amount;
if (amount == null && i + 1 < lines.length) {
amount = tryParsePrice(_stripTax(lines[i + 1], config));
if (amount != null) i++;
}
if (amount == null) continue;
if (amount > 0) amount = -amount;
if (pending != null) {
pending!.discount += amount;
pending!.lineTotal = roundMoney(
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
);
} else {
totalDiscount += amount;
}
case _Kind.totalDiscount:
var amount = classified.amount;
if (amount == null && i + 1 < lines.length) {
amount = tryParsePrice(_stripTax(lines[i + 1], config));
if (amount != null) i++;
}
if (amount != null) {
totalDiscount += amount > 0 ? -amount : amount;
}
case _Kind.grandTotal:
flush();
if (classified.amount != null) {
receiptTotal = classified.amount;
stop = true;
}
case _Kind.priceOnly:
final amount = classified.amount!;
if (amount < 0 && pending != null) {
pending!.discount += amount;
pending!.lineTotal = roundMoney(
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
);
}
}
}
flush();
return _result(
items: items,
rawText: rawText,
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
);
} }
static List<LineItem> _parseColumns(List<String> lines) { static ReceiptParseResult _parseColumns(
List<String> lines,
ReceiptParseConfig config,
String rawText,
) {
final names = <({String name, int? quantity, double? unitPrice})>[]; final names = <({String name, int? quantity, double? unitPrice})>[];
final prices = <double>[]; final prices = <double>[];
final discountSlots = <int>[];
final discounts = <double>[];
for (final line in lines) { for (final raw in lines) {
final line = _stripTax(raw, config);
if (isPriceOnly(line)) { if (isPriceOnly(line)) {
final price = tryParsePrice(line); final price = tryParsePrice(line);
if (price != null) prices.add(price); if (price == null) continue;
if (price < 0) {
discounts.add(price);
} else {
prices.add(price);
}
continue;
}
if (_isIgnorable(line, config)) continue;
if (config.matchesAny(line, [
...config.grandTotalKeywords,
...config.subtotalKeywords,
...config.taxInclusiveTotalKeywords,
...config.totalDiscountKeywords,
])) {
continue; continue;
} }
if (_shouldSkipLine(line)) continue;
final qty = _parseQty(line); final qty = _parseQty(line, config);
if (qty != null) { if (qty != null) {
if (names.isNotEmpty) { if (names.isNotEmpty) {
final last = names.removeLast(); final last = names.removeLast();
@@ -147,11 +298,16 @@ class ReceiptParser {
continue; continue;
} }
if (config.matchesAny(line, config.discountKeywords)) {
if (names.isNotEmpty) discountSlots.add(names.length - 1);
continue;
}
names.add((name: line, quantity: null, unitPrice: null)); names.add((name: line, quantity: null, unitPrice: null));
} }
final count = names.length < prices.length ? names.length : prices.length; final count = names.length < prices.length ? names.length : prices.length;
return [ final items = <LineItem>[
for (var i = 0; i < count; i++) for (var i = 0; i < count; i++)
LineItem( LineItem(
id: _id(), id: _id(),
@@ -161,44 +317,274 @@ class ReceiptParser {
unitPrice: names[i].unitPrice, unitPrice: names[i].unitPrice,
), ),
]; ];
for (var i = 0; i < discountSlots.length && i < discounts.length; i++) {
final index = discountSlots[i];
if (index < 0 || index >= items.length) continue;
final item = items[index];
final discount = discounts[i];
items[index] = item.copyWith(
price: roundMoney(item.price + discount),
discount: roundMoney(item.discount + discount),
);
} }
static bool _keepItem(LineItem item) { final receiptTotal = _readAmountAfterKeyword(
if (item.price < 0) return false; lines,
if (item.name.trim().isEmpty) return false; config,
return !_containsKeyword(item.name, kDropKeywords); config.grandTotalKeywords,
skipIf: (line) => config.matchesAny(line, [
...config.totalDiscountKeywords,
...config.taxInclusiveTotalKeywords,
...config.subtotalKeywords,
]),
);
var totalDiscount = discounts.fold<double>(0, (sum, value) => sum + value);
totalDiscount +=
_readAmountAfterKeyword(lines, config, config.totalDiscountKeywords) ??
0;
return _result(
items: items,
rawText: rawText,
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
);
} }
static bool _shouldSkipLine(String line) { static LineItem _toItem(_Pending pending, double total) {
if (_separatorPattern.hasMatch(line)) return true; return LineItem(
id: _id(),
name: pending.name,
price: roundMoney(total),
quantity: pending.quantity == 1 ? null : pending.quantity,
unitPrice: pending.quantity == 1 ? null : pending.unitPrice,
discount: pending.discount == 0 ? 0 : roundMoney(pending.discount),
);
}
static ReceiptParseResult _result({
required List<LineItem> items,
required String rawText,
double? receiptTotal,
double totalDiscount = 0,
}) {
final kept = items
.where((item) => item.name.trim().isNotEmpty && item.price > 0)
.toList();
final sum = roundMoney(
kept.fold<double>(0, (total, item) => total + item.price),
);
final passed =
receiptTotal == null ||
(sum - receiptTotal).abs() <= 0.01 ||
(sum + totalDiscount - receiptTotal).abs() <= 0.01;
return ReceiptParseResult(
items: kept,
store: _detectStore(rawText),
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
validationPassed: passed,
);
}
static String? _detectStore(String rawText) {
final config = receiptParseConfig;
final header = rawText
.split('\n')
.map(_preprocess)
.where((line) => line.isNotEmpty)
.take(8)
.join('\n')
.toUpperCase();
final stores = [...config.knownStores]
..sort((a, b) => b.length.compareTo(a.length));
for (final store in stores) {
if (RegExp('\\b${RegExp.escape(store)}\\b').hasMatch(header)) {
return _canonicalStore(store);
}
}
return null;
}
static String _canonicalStore(String raw) {
const aliases = {
'AH': 'Albert Heijn',
'ALBERT HEIJN': 'Albert Heijn',
'JUMBO': 'Jumbo',
'LIDL': 'Lidl',
'KRUIDVAT': 'Kruidvat',
'ALDI': 'Aldi',
'PLUS': 'Plus',
'DIRK': 'Dirk',
'COOP': 'Coop',
'SPAR': 'SPAR',
'VOMAR': 'Vomar',
'EKOPLAZA': 'Ekoplaza',
'HOOGVLIET': 'Hoogvliet',
'ACTION': 'Action',
'ETOS': 'Etos',
'WORLD TOKO': 'World Toko',
};
return aliases[raw.toUpperCase()] ?? raw;
}
static double? _readAmountAfterKeyword(
List<String> lines,
ReceiptParseConfig config,
List<String> keywords, {
bool Function(String line)? skipIf,
}) {
for (var i = 0; i < lines.length; i++) {
final line = _stripTax(lines[i], config);
if (!config.matchesAny(line, keywords)) continue;
if (skipIf != null && skipIf(line)) continue;
final trailing = _trailingPrice(line, config);
if (trailing != null) return trailing;
if (i + 1 < lines.length) {
final next = tryParsePrice(_stripTax(lines[i + 1], config));
if (next != null) return next;
}
}
return null;
}
static _Line _classify(String raw, ReceiptParseConfig config) {
final line = _stripTax(raw, config);
if (_isIgnorable(line, config)) {
return _Line(kind: _Kind.skip, raw: line);
}
if (config.matchesAny(line, config.totalDiscountKeywords)) {
return _Line(
kind: _Kind.totalDiscount,
raw: line,
amount: _trailingPrice(line, config),
);
}
if (config.matchesAny(line, config.taxInclusiveTotalKeywords) ||
config.matchesAny(line, config.subtotalKeywords)) {
return _Line(kind: _Kind.skip, raw: line);
}
if (config.matchesAny(line, config.grandTotalKeywords)) {
return _Line(
kind: _Kind.grandTotal,
raw: line,
amount: _trailingPrice(line, config),
);
}
final qty = _parseQty(line, config);
if (qty != null) {
return _Line(kind: _Kind.quantity, raw: line, qty: qty);
}
final isDiscount =
config.matchesAny(line, config.discountKeywords) ||
config.negativeAmount.hasMatch(line);
if (isDiscount &&
(config.matchesAny(line, config.discountKeywords) ||
isPriceOnly(line))) {
return _Line(
kind: _Kind.discount,
raw: line,
name: line,
amount: _trailingPrice(line, config) ?? tryParsePrice(line),
);
}
if (isPriceOnly(line)) {
return _Line(
kind: _Kind.priceOnly,
raw: line,
amount: tryParsePrice(line),
);
}
final inline = config.quantityInline.firstMatch(line);
final trailing = _trailingPrice(line, config);
if (trailing != null) {
var name = line.replaceFirst(RegExp(r'\s+-?\d+[.,]\d{2}\s*$'), '').trim();
_Qty? inlineQty;
if (inline != null) {
name = line.substring(0, inline.start).trim();
final count = int.tryParse(inline.group(1)!);
final unit = tryParsePrice(inline.group(2)!);
if (count != null && unit != null) {
inlineQty = _Qty(count: count, unitPrice: unit, total: trailing);
}
}
if (name.isEmpty || _isIgnorable(name, config)) {
return _Line(kind: _Kind.skip, raw: line);
}
return _Line(
kind: _Kind.item,
raw: line,
name: name,
amount: trailing,
qty: inlineQty,
);
}
if (inline != null) {
final name = line.substring(0, inline.start).trim();
final count = int.tryParse(inline.group(1)!);
final unit = tryParsePrice(inline.group(2)!);
if (name.isNotEmpty && count != null && unit != null) {
return _Line(
kind: _Kind.item,
raw: line,
name: name,
amount: roundMoney(count * unit),
qty: _Qty(count: count, unitPrice: unit),
);
}
}
return _Line(kind: _Kind.nameOnly, raw: line, name: line);
}
static bool _isIgnorable(String line, ReceiptParseConfig config) {
if (config.separatorPattern.hasMatch(line)) return true;
if (_urlPattern.hasMatch(line)) return true; if (_urlPattern.hasMatch(line)) return true;
if (_postalPattern.hasMatch(line)) return true; if (_postalPattern.hasMatch(line)) return true;
if (_phonePattern.hasMatch(line.replaceAll(RegExp(r'\s'), ''))) { if (_phonePattern.hasMatch(line.replaceAll(RegExp(r'\s'), ''))) {
return true; return true;
} }
if (_streetPattern.hasMatch(line)) return true; if (_streetPattern.hasMatch(line)) return true;
if (_containsKeyword(line, kHeaderKeywords)) return true; if (config.categoryRow.hasMatch(line)) return true;
return false; if (config.isStandaloneKeywordLine(line, config.columnHeaderKeywords)) {
}
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 true;
} }
if (config.isStandaloneKeywordLine(line, config.itemCountKeywords)) {
return true;
} }
if (config.matchesAny(line, config.taxKeywords)) return true;
if (config.matchesAny(line, config.paymentKeywords)) return true;
if (config.matchesAny(line, config.noiseKeywords)) return true;
return false; return false;
} }
static _QtyLine? _parseQty(String line) { static double? _trailingPrice(String line, ReceiptParseConfig config) {
final match = quantityPattern.firstMatch(line.trim()); final match = RegExp(r'(-?\d+[.,]\d{2})\s*$').firstMatch(line);
if (match == null) return null;
return tryParsePrice(match.group(1)!);
}
static _Qty? _parseQty(String line, ReceiptParseConfig config) {
final match =
_qtyWithTotal.firstMatch(line) ??
config.quantityStandalone.firstMatch(line);
if (match == null) return null; if (match == null) return null;
final count = int.tryParse(match.group(1)!); final count = int.tryParse(match.group(1)!);
final unit = tryParsePrice(match.group(2)!); final unit = tryParsePrice(match.group(2)!);
if (count == null || count < 1 || unit == null) return null; if (count == null || count < 1 || unit == null) return null;
final totalRaw = match.group(3); double? total;
final total = totalRaw == null ? null : tryParsePrice(totalRaw); if (match.groupCount >= 3) {
return _QtyLine(count: count, unitPrice: unit, total: total); final raw = match.group(3);
if (raw != null && RegExp(r'^-?\d+[.,]\d{2}$').hasMatch(raw)) {
total = tryParsePrice(raw);
}
}
return _Qty(count: count, unitPrice: unit, total: total);
} }
} }
+90 -11
View File
@@ -5,11 +5,14 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'models/line_item.dart';
import 'models/shop.dart'; import 'models/shop.dart';
import 'product_detail_screen.dart'; import 'product_detail_screen.dart';
import 'services/product_repository.dart'; import 'services/product_repository.dart';
import 'utils/dates.dart'; import 'utils/dates.dart';
import 'utils/money.dart'; import 'utils/money.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/item_edit_sheet.dart';
class ShopDetailScreen extends StatefulWidget { class ShopDetailScreen extends StatefulWidget {
const ShopDetailScreen({ const ShopDetailScreen({
@@ -27,6 +30,7 @@ class ShopDetailScreen extends StatefulWidget {
class _ShopDetailScreenState extends State<ShopDetailScreen> { class _ShopDetailScreenState extends State<ShopDetailScreen> {
late Future<({Shop shop, List<ShopItem> items})> _future; late Future<({Shop shop, List<ShopItem> items})> _future;
bool _editing = false;
@override @override
void initState() { void initState() {
@@ -43,6 +47,10 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
return (shop: shop, items: items); return (shop: shop, items: items);
} }
void _reload() {
setState(() => _future = _load());
}
Future<void> _openReceipt(File file) async { Future<void> _openReceipt(File file) async {
await showDialog<void>( await showDialog<void>(
context: context, context: context,
@@ -68,12 +76,66 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
); );
} }
Future<void> _editItem(ShopItem item) async {
final result = await showItemEditSheet(
context,
item: LineItem(
id: '${item.id}',
name: item.name,
price: item.price,
quantity: item.quantity,
unitPrice: item.unitPrice,
),
);
if (result == null || !mounted) return;
await widget.repository.updateShopItem(
id: item.id,
name: result.name,
price: result.price,
quantity: result.quantity,
unitPrice: result.unitPrice,
);
if (mounted) _reload();
}
Future<void> _removeItem(ShopItem item) async {
final confirmed = await confirmAction(
context,
title: 'Remove item?',
message:
'"${item.name}" will be removed from this trip. The product stays in your catalog.',
confirmLabel: 'Remove',
);
if (!confirmed || !mounted) return;
await widget.repository.deleteShopItem(item.id);
if (mounted) _reload();
}
Widget? _itemSubtitle(ShopItem item) {
final parts = [
if (item.quantity != null)
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
if (_editing) formatEuro(item.price),
];
if (parts.isEmpty) return null;
return Text(parts.join(' · '));
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Shopping trip')), appBar: AppBar(
title: const Text('Shopping trip'),
actions: [
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
],
),
body: FutureBuilder( body: FutureBuilder(
future: _future, future: _future,
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -92,10 +154,8 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
final shop = snapshot.data!.shop; final shop = snapshot.data!.shop;
final items = snapshot.data!.items; final items = snapshot.data!.items;
final receipt = shop.receiptImagePath; final receipt = shop.receiptImagePath;
final receiptFile = final receiptFile = receipt == null ? null : File(receipt);
receipt == null ? null : File(receipt); final hasReceipt = receiptFile != null && receiptFile.existsSync();
final hasReceipt =
receiptFile != null && receiptFile.existsSync();
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
@@ -125,20 +185,39 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
Text('Products', style: theme.textTheme.titleMedium), Text('Products', style: theme.textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
if (items.isEmpty)
Text(
'No items on this trip.',
style: theme.textTheme.bodyLarge,
),
for (final item in items) for (final item in items)
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text(item.name), title: Text(item.name),
subtitle: item.quantity == null subtitle: _itemSubtitle(item),
? null trailing: _editing
: Text( ? Row(
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}', mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
visualDensity: VisualDensity.compact,
onPressed: () => _editItem(item),
icon: const Icon(Icons.edit_outlined),
), ),
trailing: Text( IconButton(
tooltip: 'Remove',
visualDensity: VisualDensity.compact,
onPressed: () => _removeItem(item),
icon: const Icon(Icons.delete_outline),
),
],
)
: Text(
formatEuro(item.price), formatEuro(item.price),
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium,
), ),
onTap: () => _openProduct(item.productId), onTap: _editing ? null : () => _openProduct(item.productId),
), ),
const Divider(), const Divider(),
ListTile( ListTile(
+32 -8
View File
@@ -11,6 +11,7 @@ import 'services/product_repository.dart';
import 'shop_detail_screen.dart'; import 'shop_detail_screen.dart';
import 'utils/dates.dart'; import 'utils/dates.dart';
import 'utils/money.dart'; import 'utils/money.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/settings_button.dart'; import 'widgets/settings_button.dart';
import 'widgets/store_tag.dart'; import 'widgets/store_tag.dart';
@@ -25,6 +26,7 @@ class ShopLogScreen extends StatefulWidget {
class _ShopLogScreenState extends State<ShopLogScreen> { class _ShopLogScreenState extends State<ShopLogScreen> {
late Future<List<Shop>> _future; late Future<List<Shop>> _future;
bool _editing = false;
@override @override
void initState() { void initState() {
@@ -39,15 +41,24 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
Future<void> _openShop(Shop shop) async { Future<void> _openShop(Shop shop) async {
await Navigator.of(context).push<void>( await Navigator.of(context).push<void>(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => ShopDetailScreen( builder: (_) =>
repository: widget.repository, ShopDetailScreen(repository: widget.repository, shopId: shop.id),
shopId: shop.id,
),
), ),
); );
if (mounted) _reload(); if (mounted) _reload();
} }
Future<void> _removeShop(Shop shop) async {
final confirmed = await confirmAction(
context,
title: 'Remove shopping trip?',
message: 'This trip will be removed from the shop log. Products from this trip stay in your catalog.',
);
if (!confirmed || !mounted) return;
await widget.repository.deleteShop(shop.id);
if (mounted) _reload();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -56,7 +67,14 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Shop log'), title: const Text('Shop log'),
actions: const [SettingsButton()], actions: [
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
const SettingsButton(),
],
), ),
body: FutureBuilder<List<Shop>>( body: FutureBuilder<List<Shop>>(
future: _future, future: _future,
@@ -77,7 +95,7 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
if (shops.isEmpty) { if (shops.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'Scan a receipt to log a shopping trip.', 'Scan a receipt or enter a trip to log it.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge, style: theme.textTheme.bodyLarge,
), ),
@@ -103,11 +121,17 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
count == 1 ? '1 product' : '$count products', count == 1 ? '1 product' : '$count products',
].join(' · '), ].join(' · '),
), ),
trailing: Text( trailing: _editing
? IconButton(
tooltip: 'Remove',
onPressed: () => _removeShop(shop),
icon: const Icon(Icons.delete_outline),
)
: Text(
formatEuro(shop.total), formatEuro(shop.total),
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium,
), ),
onTap: () => _openShop(shop), onTap: _editing ? null : () => _openShop(shop),
); );
}, },
); );
+316
View File
@@ -0,0 +1,316 @@
/// Items on one shopping list, with check-off and edit/remove.
library;
import 'package:flutter/material.dart';
import 'models/product.dart';
import 'models/shopping_list.dart';
import 'product_detail_screen.dart';
import 'services/product_repository.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/name_dialog.dart';
class ShoppingListDetailScreen extends StatefulWidget {
const ShoppingListDetailScreen({
super.key,
required this.repository,
required this.listId,
});
final ProductRepository repository;
final int listId;
@override
State<ShoppingListDetailScreen> createState() =>
_ShoppingListDetailScreenState();
}
class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
late Future<({ShoppingList list, List<ShoppingListItem> items})> _future;
bool _editing = false;
@override
void initState() {
super.initState();
_future = _load();
}
Future<({ShoppingList list, List<ShoppingListItem> items})> _load() async {
final list = await widget.repository.getShoppingList(widget.listId);
if (list == null) {
throw StateError('List not found');
}
final items = await widget.repository.getShoppingListItems(widget.listId);
return (list: list, items: items);
}
void _reload() {
setState(() => _future = _load());
}
Future<void> _rename(ShoppingList list) async {
final name = await showNameDialog(
context,
title: 'Rename list',
initial: list.name,
label: 'List name',
);
if (name == null || !mounted) return;
await widget.repository.renameShoppingList(widget.listId, name);
if (mounted) _reload();
}
Future<void> _openAddSheet() async {
final products = await widget.repository.getAll();
if (!mounted) return;
final result = await showModalBottomSheet<({String name, int? productId})>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => _AddListItemSheet(products: products),
);
if (result == null || !mounted) return;
await widget.repository.addShoppingListItem(
listId: widget.listId,
name: result.name,
productId: result.productId,
);
if (mounted) _reload();
}
Future<void> _editItem(ShoppingListItem item) async {
final name = await showNameDialog(
context,
title: 'Edit item',
initial: item.name,
label: 'Item',
);
if (name == null || !mounted) return;
await widget.repository.updateShoppingListItem(id: item.id, name: name);
if (mounted) _reload();
}
Future<void> _removeItem(ShoppingListItem item) async {
final confirmed = await confirmAction(
context,
title: 'Remove item?',
message: '"${item.name}" will be removed from this list.',
confirmLabel: 'Remove',
);
if (!confirmed || !mounted) return;
await widget.repository.deleteShoppingListItem(item.id);
if (mounted) _reload();
}
Future<void> _toggle(ShoppingListItem item) async {
await widget.repository.updateShoppingListItem(
id: item.id,
checked: !item.checked,
);
if (mounted) _reload();
}
Future<void> _openProduct(int productId) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => ProductDetailScreen(
repository: widget.repository,
productId: productId,
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: FutureBuilder(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Text(
'Could not load this list.',
style: theme.textTheme.bodyLarge,
),
),
);
}
if (!snapshot.hasData) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: CircularProgressIndicator()),
);
}
final list = snapshot.data!.list;
final items = snapshot.data!.items;
return Scaffold(
appBar: AppBar(
title: Text(list.name),
actions: [
IconButton(
tooltip: 'Rename',
onPressed: () => _rename(list),
icon: const Icon(Icons.drive_file_rename_outline),
),
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _openAddSheet,
tooltip: 'Add item',
child: const Icon(Icons.add),
),
body: items.isEmpty
? Center(
child: Text(
'Add things to buy.',
style: theme.textTheme.bodyLarge,
),
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
leading: Checkbox(
value: item.checked,
onChanged: _editing ? null : (_) => _toggle(item),
),
title: Text(
item.name,
style: item.checked
? theme.textTheme.bodyLarge?.copyWith(
decoration: TextDecoration.lineThrough,
color: theme.colorScheme.onSurfaceVariant,
)
: null,
),
trailing: _editing
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
onPressed: () => _editItem(item),
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Remove',
onPressed: () => _removeItem(item),
icon: const Icon(Icons.delete_outline),
),
],
)
: item.productId == null
? null
: IconButton(
tooltip: 'Product',
onPressed: () => _openProduct(item.productId!),
icon: const Icon(Icons.chevron_right),
),
onTap: _editing ? null : () => _toggle(item),
);
},
),
);
},
),
);
}
}
class _AddListItemSheet extends StatefulWidget {
const _AddListItemSheet({required this.products});
final List<Product> products;
@override
State<_AddListItemSheet> createState() => _AddListItemSheetState();
}
class _AddListItemSheetState extends State<_AddListItemSheet> {
final TextEditingController _controller = TextEditingController();
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
List<Product> get _matches {
final query = _controller.text.trim().toLowerCase();
if (query.isEmpty) {
return widget.products.take(8).toList();
}
return widget.products
.where((product) => product.name.toLowerCase().contains(query))
.take(8)
.toList();
}
void _add({String? name, int? productId}) {
final value = (name ?? _controller.text).trim();
if (value.isEmpty) {
setState(() => _error = 'Enter an item name.');
return;
}
Navigator.of(context).pop((name: value, productId: productId));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
final matches = _matches;
return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Add item', style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
TextField(
controller: _controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(labelText: 'Item name'),
onChanged: (_) => setState(() => _error = null),
onSubmitted: (_) => _add(),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
],
if (matches.isNotEmpty) ...[
const SizedBox(height: 12),
Text('From products', style: theme.textTheme.labelMedium),
const SizedBox(height: 4),
for (final product in matches)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(product.name),
onTap: () => _add(name: product.name, productId: product.id),
),
],
const SizedBox(height: 8),
FilledButton(onPressed: () => _add(), child: const Text('Add')),
],
),
);
}
}
+151
View File
@@ -0,0 +1,151 @@
/// Named shopping lists of things to buy.
library;
import 'package:flutter/material.dart';
import 'models/shopping_list.dart';
import 'services/product_repository.dart';
import 'shopping_list_detail_screen.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/name_dialog.dart';
import 'widgets/settings_button.dart';
class ShoppingListsScreen extends StatefulWidget {
const ShoppingListsScreen({super.key, required this.repository});
final ProductRepository repository;
@override
State<ShoppingListsScreen> createState() => _ShoppingListsScreenState();
}
class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
late Future<List<ShoppingList>> _future;
bool _editing = false;
@override
void initState() {
super.initState();
_future = widget.repository.getShoppingLists();
}
void _reload() {
setState(() => _future = widget.repository.getShoppingLists());
}
Future<void> _create() async {
final name = await showNameDialog(
context,
title: 'New shopping list',
label: 'List name',
confirmLabel: 'Create',
);
if (name == null || !mounted) return;
final id = await widget.repository.createShoppingList(name);
if (!mounted) return;
await _openList(id);
}
Future<void> _openList(int id) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) =>
ShoppingListDetailScreen(repository: widget.repository, listId: id),
),
);
if (mounted) _reload();
}
Future<void> _removeList(ShoppingList list) async {
final confirmed = await confirmAction(
context,
title: 'Delete shopping list?',
message: '"${list.name}" and its items will be deleted.',
);
if (!confirmed || !mounted) return;
await widget.repository.deleteShoppingList(list.id);
if (mounted) _reload();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Shopping lists'),
actions: [
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
const SettingsButton(),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _create,
tooltip: 'New list',
child: const Icon(Icons.add),
),
body: FutureBuilder<List<ShoppingList>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text(
'Could not load shopping lists.',
style: theme.textTheme.bodyLarge,
),
);
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final lists = snapshot.data!;
if (lists.isEmpty) {
return Center(
child: Text(
'Create a list for your next shop.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
itemCount: lists.length,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final list = lists[index];
final remaining = list.itemCount - list.checkedCount;
return ListTile(
leading: CircleAvatar(child: Text('${list.itemCount}')),
title: Text(list.name),
subtitle: Text(
list.itemCount == 0
? 'Empty'
: remaining == 0
? 'All done'
: remaining == 1
? '1 item left'
: '$remaining items left',
),
trailing: _editing
? IconButton(
tooltip: 'Delete',
onPressed: () => _removeList(list),
icon: const Icon(Icons.delete_outline),
)
: const Icon(Icons.chevron_right),
onTap: _editing ? null : () => _openList(list.id),
);
},
);
},
),
);
}
}
+17 -8
View File
@@ -17,13 +17,18 @@ final List<Supermarket> kDefaultSupermarkets = [
const Supermarket(name: 'Nettorama', colorValue: 0xFFE87722, sortOrder: 8), const Supermarket(name: 'Nettorama', colorValue: 0xFFE87722, sortOrder: 8),
const Supermarket(name: 'Hoogvliet', colorValue: 0xFFD32F2F, sortOrder: 9), const Supermarket(name: 'Hoogvliet', colorValue: 0xFFD32F2F, sortOrder: 9),
const Supermarket(name: 'Picnic', colorValue: 0xFFE31C5F, sortOrder: 10), const Supermarket(name: 'Picnic', colorValue: 0xFFE31C5F, sortOrder: 10),
const Supermarket(name: kOtherSupermarket, colorValue: 0xFF78909C, sortOrder: 11), const Supermarket(
name: kOtherSupermarket,
colorValue: 0xFF78909C,
sortOrder: 11,
),
]; ];
const Map<String, List<String>> kSupermarketAliases = { const Map<String, List<String>> kSupermarketAliases = {
'Albert Heijn': ['ALBERT HEIJN', 'AH TO GO', 'AH XL'], 'Albert Heijn': ['ALBERT HEIJN', 'AH TO GO', 'AH XL'],
'Jumbo': ['JUMBO'], 'Jumbo': ['JUMBO'],
'Lidl': ['LIDL'], 'Lidl': ['LIDL'],
'Kruidvat': ['KRUIDVAT'],
'Aldi': ['ALDI'], 'Aldi': ['ALDI'],
'Plus': ['PLUS'], 'Plus': ['PLUS'],
'Dirk': ['DIRK'], 'Dirk': ['DIRK'],
@@ -31,6 +36,10 @@ const Map<String, List<String>> kSupermarketAliases = {
'SPAR': ['SPAR'], 'SPAR': ['SPAR'],
'Nettorama': ['NETTORAMA'], 'Nettorama': ['NETTORAMA'],
'Hoogvliet': ['HOOGVLIET'], 'Hoogvliet': ['HOOGVLIET'],
'Ekoplaza': ['EKOPLAZA'],
'Action': ['ACTION'],
'Etos': ['ETOS'],
'World Toko': ['WORLD TOKO'],
'Picnic': ['PICNIC'], 'Picnic': ['PICNIC'],
}; };
@@ -51,20 +60,20 @@ const List<String> kSupermarkets = [
]; ];
/// Returns a supermarket name if it appears in [rawText]. /// Returns a supermarket name if it appears in [rawText].
String? detectSupermarket( String? detectSupermarket(String rawText, {Iterable<String> names = const []}) {
String rawText, {
Iterable<String> names = const [],
}) {
final upper = rawText.toUpperCase(); final upper = rawText.toUpperCase();
final extra = names final extra =
names
.map((name) => name.trim()) .map((name) => name.trim())
.where((name) => name.isNotEmpty && name != kOtherSupermarket) .where((name) => name.isNotEmpty && name != kOtherSupermarket)
.toList() .toList()
..sort((a, b) => b.length.compareTo(a.length)); ..sort((a, b) => b.length.compareTo(a.length));
for (final name in extra) { for (final name in extra) {
if (RegExp('\\b${RegExp.escape(name)}\\b', caseSensitive: false) if (RegExp(
.hasMatch(rawText)) { '\\b${RegExp.escape(name)}\\b',
caseSensitive: false,
).hasMatch(rawText)) {
return name; return name;
} }
} }
+32
View File
@@ -0,0 +1,32 @@
/// Confirm a destructive action before it runs.
library;
import 'package:flutter/material.dart';
Future<bool> confirmAction(
BuildContext context, {
required String title,
required String message,
String confirmLabel = 'Delete',
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmLabel),
),
],
);
},
);
return result == true;
}
+113
View File
@@ -0,0 +1,113 @@
/// Bottom sheet to add or edit a priced line item.
library;
import 'package:flutter/material.dart';
import '../models/line_item.dart';
import '../utils/money.dart';
Future<LineItem?> showItemEditSheet(BuildContext context, {LineItem? item}) {
return showModalBottomSheet<LineItem>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => ItemEditSheet(item: item),
);
}
class ItemEditSheet extends StatefulWidget {
const ItemEditSheet({super.key, 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;
}
final rounded = roundMoney(price);
final quantity = widget.item?.quantity;
Navigator.of(context).pop(
LineItem(
id:
widget.item?.id ??
'manual-${DateTime.now().microsecondsSinceEpoch}',
name: name,
price: rounded,
quantity: quantity,
unitPrice: quantity != null && quantity > 0
? roundMoney(rounded / quantity)
: 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')),
],
),
);
}
}
+101
View File
@@ -0,0 +1,101 @@
/// Name prompt that owns its text controller for the dialog lifetime.
library;
import 'package:flutter/material.dart';
Future<String?> showNameDialog(
BuildContext context, {
required String title,
String? initial,
String label = 'Name',
String confirmLabel = 'Save',
}) {
return showDialog<String>(
context: context,
builder: (context) => _NameDialog(
title: title,
initial: initial ?? '',
label: label,
confirmLabel: confirmLabel,
),
);
}
class _NameDialog extends StatefulWidget {
const _NameDialog({
required this.title,
required this.initial,
required this.label,
required this.confirmLabel,
});
final String title;
final String initial;
final String label;
final String confirmLabel;
@override
State<_NameDialog> createState() => _NameDialogState();
}
class _NameDialogState extends State<_NameDialog> {
late final TextEditingController _controller;
String? _error;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initial);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _save() {
final name = _controller.text.trim();
if (name.isEmpty) {
setState(() => _error = 'Enter a name.');
return;
}
Navigator.of(context).pop(name);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: widget.label),
onSubmitted: (_) => _save(),
),
if (_error != null) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(onPressed: _save, child: Text(widget.confirmLabel)),
],
);
}
}
+32
View File
@@ -9,6 +9,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.3" version: "1.0.3"
archive:
dependency: transitive
description:
name: archive
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
url: "https://pub.dev"
source: hosted
version: "4.2.0"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -65,6 +73,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
crop_your_image:
dependency: "direct main"
description:
name: crop_your_image
sha256: "14c8977b11a009dc5e73e0f6522970f93363e38183f1b2ffefe1676dc9c3f49d"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
cross_file: cross_file:
dependency: transitive dependency: transitive
description: description:
@@ -280,6 +296,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
url: "https://pub.dev"
source: hosted
version: "4.9.2"
image_picker: image_picker:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -536,6 +560,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.dev"
source: hosted
version: "6.5.2"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
+2
View File
@@ -18,6 +18,7 @@ dependencies:
sqflite: ^2.4.2 sqflite: ^2.4.2
file_picker: ^12.1.2 file_picker: ^12.1.2
share_plus: ^13.3.0 share_plus: ^13.3.0
crop_your_image: ^2.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -28,3 +29,4 @@ flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/receipt.jpeg - assets/receipt.jpeg
- receipt-parsing-keywords.json
+186
View File
@@ -0,0 +1,186 @@
{
"_comment": "Keyword/pattern config for the receipt parser. See docs/receipt-parsing.md to add a new shop. Match rules are implemented in receipt_parse_config.dart (short tokens use word boundaries; longer tokens use substring + compact OCR form).",
"known_stores": {
"_comment": "Used to detect store name from the header block (first 1-5 lines). Match against the STORE_HEADER lines.",
"list": [
"JUMBO",
"LIDL",
"KRUIDVAT",
"ALBERT HEIJN",
"AH",
"ALDI",
"PLUS",
"DIRK",
"COOP",
"SPAR",
"VOMAR",
"EKOPLAZA",
"HOOGVLIET",
"ACTION",
"ETOS",
"WORLD TOKO"
],
"fallback_rule": "If no known store name matches, take the first non-address, non-phone-number line in the header block as a best-effort store name (free text)."
},
"column_header_keywords": {
"_comment": "Lines matching these are table headers, not data — ignore entirely.",
"list": [
"OMSCHRIJVING",
"BEDRAG IN",
"ARTIKEL",
"PRIJS",
"AANTAL ARTIKEL",
"BESCHRIJVING"
],
"separator_pattern": "^[=\\-#]{3,}$"
},
"quantity_modifier_pattern": {
"_comment": "Regex to detect an 'N X price' line, optionally followed by a unit word. Group 1 = quantity, group 2 = unit price.",
"regex": "^\\s*(\\d+)\\s*[xX]\\s*(\\d+[,.]\\d{2})\\s*(PER\\s+STUK|PER\\s+KG|ST\\.?|STUKS?)?\\s*$",
"inline_regex": "(\\d+)\\s*[xX]\\s*(\\d+[,.]\\d{2})\\s*(PER\\s+STUK|PER\\s+KG)?",
"note": "Use 'regex' for standalone quantity lines (Jumbo-style). Use 'inline_regex' to detect the same pattern embedded within a product-name line (Kruidvat-style)."
},
"discount_keywords": {
"_comment": "Lines matching ANY of these AND carrying a negative amount are discounts, not products.",
"list": [
"ACTIE",
"KORTING",
"IN PRIJS VERLAAGD",
"BONUSKORTING",
"KASSAKORTING",
"ZEGELKORTING",
"REDUCTIE",
"AANBIEDING"
],
"negative_amount_pattern": "-\\d+[,.]\\d{2}",
"receipt_level_total_discount_keywords": [
"TOTAAL KORTING",
"TOTALE KORTING",
"TOTAAL BESPARING"
]
},
"total_keywords": {
"_comment": "Order matters for disambiguation — check subtotal_keywords before grand_total_keywords, since 'SUBTOTAAL' also contains 'TOTAAL'.",
"subtotal_keywords": [
"SUBTOTAAL",
"SUB TOTAAL"
],
"grand_total_keywords": [
"TOTAAL",
"TOTAL"
],
"tax_inclusive_total_keywords": [
"TOTAAL INCL. BTW",
"TOTAAL INCL BTW",
"TOTAAL EXCL. BTW",
"TOTAAL EXCL BTW"
],
"rule": "The FIRST grand_total match encountered after the last item line is the authoritative receipt total. Any match containing a discount keyword (see discount_keywords.receipt_level_total_discount_keywords) is the total discount amount, not the grand total — check discount keywords first."
},
"payment_keywords": {
"_comment": "Lines matching these mark the start of the payment block. Everything from the first match onward (until noise/footer) should be excluded from item parsing, though some fields can be stored as receipt metadata.",
"list": [
"BETAALD",
"BETALING",
"VISA",
"MASTERCARD",
"MAESTRO",
"V PAY",
"PIN",
"CONTACTLOZE BETALING",
"AKKOORD",
"KAARTNR",
"KAART:",
"TRANSACTIE",
"TERMINAL",
"MERCHANT",
"AUTH. CODE",
"AUTH CODE",
"KAARTHOUDER",
"CVM",
"CHIP",
"PAY",
"IDEAL"
],
"metadata_fields_to_extract": [
"payment_method (e.g. VISA, MASTERCARD, PIN, iDEAL)",
"transaction_id",
"auth_code",
"terminal_id"
]
},
"tax_breakdown_keywords": {
"_comment": "Lines describing BTW/tax categories, not products. Also used to detect the trailing tax-category letter appended to some item lines.",
"list": [
"BTW",
"BTW-CODE",
"BEDR.EXCL",
"BEDR.INCL",
"BEDRAG EXCL",
"BEDRAG INCL"
],
"category_row_pattern": "^[A-Z]\\s+\\d+%",
"trailing_category_letter_pattern": "\\s[A-Z]$",
"note": "trailing_category_letter_pattern strips a single trailing tax-category letter (e.g. ' B' or ' C') from the end of an item line if present, before extracting the price. Do not treat it as part of the price."
},
"noise_footer_keywords": {
"_comment": "Lines matching these (or appearing after the payment block) are noise — ignore entirely.",
"list": [
"BEWAAR",
"KASSABON",
"RETOURNEREN",
"RETOUR",
"DAGEN MET JE BON",
"WWW.",
".COM",
".NL",
"OPENINGSTIJDEN",
"MAANDAG",
"DINSDAG",
"WOENSDAG",
"DONDERDAG",
"VRIJDAG",
"ZATERDAG",
"ZONDAG",
"FACEBOOK",
"INSTAGRAM",
"BEDANKT",
"TOT ZIENS",
"MEDEWERKER",
"KASSA",
"TRANS",
"STORE",
"POS",
"REGISTREER",
"PLUS-APP",
"BESPAAR MEER",
"ZELF SERVICE",
"PRIVACY STATEMENT",
"KOOPZEGELS"
]
},
"quantity_and_price_general": {
"price_pattern": "\\d+[,.]\\d{2}",
"decimal_separator": ",",
"note": "Some receipts (e.g. World Toko) show weight-based items like '900g', '500ml', '400g' as part of the product name — these are NOT quantities/units for the parser's purposes, just part of the descriptive name. Only treat a leading integer directly adjacent to 'X' or 'x' as a quantity."
},
"line_item_count_keywords": {
"_comment": "Some receipts show an item count line near the payment block, useful as a validation cross-check (does parsed item count match this?).",
"list": [
"AANTAL ARTIKEL",
"AANTAL ARTIKELEN",
"ART.",
"ARTIKELEN"
]
}
}
+278
View File
@@ -0,0 +1,278 @@
# Receipt Parsing Logic Specification
**Implementation guide (how to add a shop):** [`docs/receipt-parsing.md`](docs/receipt-parsing.md)
**Keyword config:** [`receipt-parsing-keywords.json`](receipt-parsing-keywords.json)
Purpose: define rule-based (non-AI, regex/heuristic) logic for turning raw
OCR text lines from a supermarket receipt into structured data: items,
quantities, unit prices, line totals, discounts, and receipt totals.
This spec is based on analysis of real receipts from multiple Dutch stores
(Jumbo, Lidl, Kruidvat, World Toko), which use at least four different line
layouts for the same underlying data. The parser must handle all of them
without assuming a single fixed format.
---
## 1. Input assumptions
- Input is raw OCR text, one line per detected text line, in top-to-bottom
order as it appeared on the receipt.
- Prices use European format: comma as decimal separator (e.g. `2,89`),
period as thousands separator if present (rare on receipts this size).
- OCR may introduce noise: misread characters, extra/missing spaces,
occasional dropped or merged lines. The parser should be tolerant of
minor spacing irregularities but does not need to do spell-correction.
## 2. Preprocessing (run before classification)
1. Trim leading/trailing whitespace on every line; collapse multiple
internal spaces to one.
2. Normalize decimal separators: treat `,` as the decimal point in any
number matching `\d+[,.]\d{2}`. Convert to a standard `.` internally
for numeric storage, but remember the original format was comma-based
(useful for locale-aware re-parsing if needed).
3. Drop fully empty lines.
4. Do NOT drop short lines outright (e.g. a lone `BROCCOLI` or a lone
`2 X 0,95`) — both are meaningful in different receipt layouts.
## 3. Core line classification
Classify every line into one of these categories, in this priority order
(check top-down, first match wins):
### 3.1 STORE_HEADER
- The first 15 lines of the receipt, before the first recognizable
item/price line appears.
- Often contains a store name in large/stylized text (e.g. "JUMBO",
"Lidl", "Kruidvat"), an address, phone number, or website.
- Heuristic: lines before the first line matching an ITEM or
QUANTITY_MODIFIER pattern (see below), or before a line matching
`OMSCHRIJVING` / `Artikel` / similar column-header keywords.
- Extract the store name from this block using a known store-name list
(maintain and grow this list — seen so far: JUMBO, LIDL, KRUIDVAT,
ALBERT HEIJN, ALDI, and independent/ethnic grocers whose name appears
as free text, e.g. "World Toko" — for these, take the first non-address
line as a best-effort store name).
### 3.2 COLUMN_HEADER (ignore, not an item)
- Lines that are just table headers, not data.
- Match keywords: `OMSCHRIJVING`, `BEDRAG IN`, `Artikel`, `Prijs`,
`Aantal Artikel`, or lines that are purely `=`, `-`, or `#` separator
characters.
### 3.3 ITEM (product line)
- A line containing a product name AND a price at or near the end of
the line.
- Price pattern: `\d+[,.]\d{2}` anchored near line end (allow trailing
single-letter suffixes like ` B` or ` C` — see section 6, BTW category
codes — these are not part of the price).
- Everything before the matched price is the product name (trim
trailing spaces).
- This is the "complete" case: name + price on the same line, quantity
of 1 implied unless a QUANTITY_MODIFIER line follows (section 3.4).
### 3.4 QUANTITY_MODIFIER (sub-line, belongs to previous or next ITEM)
- A line matching the pattern: `<int> X <price>` optionally followed by
a unit word (e.g. `PER STUK`), e.g.:
- `2 X 2,79`
- `2 X 3,99 PER STUK`
- This line does NOT introduce a new product — it modifies the item on
an adjacent line.
- Two observed variants — the parser must detect which applies:
- **Variant A (Jumbo-style):** product name appears on its own line
with NO price. The next line is `N X unit_price`. The line total
(`N × unit_price`) appears as its own value either on the same
`N X price` line's right-hand column, or as a separate line
immediately after. Associate this quantity/total back to the
product name line above it.
- **Variant B (Kruidvat-style):** product name and `N X unit_price`
appear on the SAME line, description-style (e.g.
`ZEEPTABLET NEUTRAL 2 X 3,99 PER STUK`), with the computed total
price in a separate right-aligned amount column/line.
- In both variants: `quantity = N`, `unit_price = price`,
`line_total = N * unit_price` (compute this yourself, then cross-check
against any total value found nearby — if they don't match within
0.01, flag the item for manual review rather than guessing).
### 3.5 DISCOUNT
- A line representing a price reduction, not a product.
- Detection rules (match ANY of):
- Line starts with or contains `ACTIE` (Jumbo pattern), e.g.
`ACTIE AARDBEIEN` with an associated negative amount.
- Line contains `KORTING` (Kruidvat/general Dutch for "discount"),
e.g. `KORTING NEUTRAL ZE.HP`.
- Line contains `In prijs verlaagd` (Lidl pattern — "price reduced").
- The associated amount is negative (prefixed with `-`), e.g. `-2,00`,
`-1,55`, `-0,21`, `-0,50`.
- A discount line should be linked to the item immediately preceding it
when possible (the discount usually applies to the last product
listed above it). Store it as a `discount` field on that item rather
than as a separate negative "product."
- If a discount line cannot be confidently linked to a specific item
(e.g. it's a receipt-wide discount near the total, like
`Totaal korting: -2,21`), store it as a receipt-level
`total_discount` field instead.
### 3.6 SUBTOTAL / TOTAL
- Lines matching keywords: `Totaal`, `SUBTOTAAL`, `Total`, `TOTAAL`.
- There may be MULTIPLE lines containing "Totaal" on one receipt
(e.g. a running subtotal, then a final Totaal, then
"Totaal korting" near the bottom, then "Totaal incl. BTW"). Rules:
- The FIRST large bold "Totaal" line following the last item is the
**receipt grand total** — use this as the authoritative total to
validate your parsed items against.
- Any "Totaal korting" line is the **sum of all discounts** — use
this as a cross-check: sum of all `discount` fields you extracted
should equal this value (within 0.01).
- "Totaal incl. BTW" / "Totaal excl. BTW" relate to tax breakdown,
not needed for item-level data, but can be stored as metadata.
### 3.7 PAYMENT (ignore for item extraction, optionally store as metadata)
- Lines matching: `Betaald`, `BETALING`, `VISA`, `Mastercard`, `PIN`,
`Contactloze betaling`, `AKKOORD`, `Kaartnr`, `Transactie`,
`Terminal`, `Merchant`, `Auth. code`, `Kaart`.
- These lines and everything below/around them (transaction IDs, auth
codes, terminal numbers) should be excluded from item parsing
entirely. Treat "first payment-related line after the last Totaal
line" as the end of the item-relevant section of the receipt.
### 3.8 TAX / BTW BREAKDOWN (ignore for item extraction)
- Lines matching: `BTW`, `Bedr.Excl`, `Bedr.Incl`, a table with columns
like `B 9%` / `C 21%`, `BTW-Code`.
- These describe tax categories, not individual products. Some product
lines end in a single letter (`B` or `C`) marking their tax category
— strip this letter before/after price extraction, don't treat it as
part of the price or name.
### 3.9 NOISE / FOOTER (ignore entirely)
- Loyalty program mentions, website URLs, "Bewaar uw kassabon",
return policy text, barcodes represented as text/numbers, store
hours, social media handles, "Bedankt en graag tot ziens",
promotional taglines. Match a broad keyword/pattern list and treat
anything after the payment block as low-priority/ignorable by default.
## 4. Item construction algorithm (pseudocode)
```
items = []
pending_item = None # name found, price/qty not yet resolved
for line in classified_lines:
if line.type == ITEM:
if pending_item:
items.append(pending_item) # close out previous item first
pending_item = { name: line.name, unit_price: line.price,
quantity: 1, line_total: line.price,
discount: 0 }
elif line.type == QUANTITY_MODIFIER:
if pending_item and pending_item.unit_price is None:
# Variant A: name-only line was pending, this line completes it
pending_item.quantity = line.qty
pending_item.unit_price = line.price
pending_item.line_total = line.qty * line.price
elif pending_item:
# Variant B: name+price already set, this line refines/
# confirms it (overwrite quantity/unit price, recompute total)
pending_item.quantity = line.qty
pending_item.unit_price = line.price
pending_item.line_total = line.qty * line.price
else:
# Orphan quantity line with no preceding item — flag for
# manual review rather than silently dropping
flag_for_review(line)
elif line.type == DISCOUNT:
if pending_item:
pending_item.discount += line.amount # amount is negative
pending_item.line_total += line.amount
else:
receipt.total_discount += line.amount
elif line.type in (SUBTOTAL, TOTAL):
if pending_item:
items.append(pending_item)
pending_item = None
if is_grand_total(line):
receipt.total = line.amount
break # stop item parsing here
# catch any trailing pending item if receipt ended oddly
if pending_item:
items.append(pending_item)
```
## 5. Validation pass (do this after parsing, before showing results to user)
1. Sum `line_total` across all parsed items.
2. Add `receipt.total_discount` if it wasn't already folded into
individual item discounts.
3. Compare the result to `receipt.total` (the grand total line).
4. If they match within 0.01 → high confidence, no flags needed.
5. If they don't match → mark the WHOLE receipt as "needs review" and
surface it clearly in the review/edit screen (don't just flag
individual lines — a mismatch means something in the whole parse is
probably off, e.g. a missed line or a misclassified discount).
## 6. Known store-specific quirks to encode as configurable rules
Keep these as a lookup table / config, not hardcoded logic, so new
stores can be added without rewriting the parser:
| Store | Quirk |
|---|---|
| Jumbo | Multi-unit items: name on own line, then `N X price` line, then total. Discounts prefixed `ACTIE <name>` with negative amount. Tax category letter (`B`/`C`) sometimes appended to far-right column, not the price. |
| Lidl | Discounts appear as `In prijs verlaagd -0,50` style lines. Tax breakdown at the bottom uses `Bedr.Excl` / `BTW` / `Bedr.Incl` labels with `B 9%` / `C 21%` category rows — do not parse these rows as products. |
| Kruidvat | Multi-unit items combine name + `N X price PER STUK` on one line. `KORTING <description>` lines carry the discount, may appear one or two lines after the item, not always immediately after. |
| World Toko | Simple `Artikel` / `Prijs` two-column layout, mostly one item per line with price directly alongside; some multi-unit items still use a following `N X price` line (Variant A). |
Add new rows to this table as new receipt formats are encountered — this
is meant to grow with real-world use rather than be exhaustive on day one.
## 7. Output schema (what the parser should produce per receipt)
```json
{
"store": "Jumbo",
"date": "2026-08-30",
"items": [
{
"name": "Jumbo Bio Volle Melk",
"quantity": 1,
"unit_price": 2.09,
"discount": 0.00,
"line_total": 2.09
},
{
"name": "Biologisch Ei 6 st",
"quantity": 2,
"unit_price": 2.79,
"discount": 0.00,
"line_total": 5.58
},
{
"name": "Aardbeien",
"quantity": 2,
"unit_price": 3.99,
"discount": -2.00,
"line_total": 5.98
}
],
"total_discount": -2.21,
"receipt_total": 82.99,
"validation_passed": true
}
```
## 8. Explicit non-goals for this parser
- No attempt to categorize items (groceries vs household, etc.) —
out of scope.
- No attempt to normalize/deduplicate product names across receipts at
parse time (e.g. matching "Jumbo Bio Volle Melk" to a previous
purchase) — that's a separate matching step done after storage, not
part of this parsing spec.
- No language detection/translation — assume Dutch keyword lists for
now (`ACTIE`, `KORTING`, `Totaal`, `Betaald`, etc.), but structure the
keyword lists as swappable config so other languages can be added.
+42 -2
View File
@@ -3,8 +3,8 @@ import 'package:receipity/services/backup_format.dart';
void main() { void main() {
test('round-trips a backup document', () { test('round-trips a backup document', () {
final original = const BackupDocument( const original = BackupDocument(
version: 1, version: 2,
exportedAt: '2026-08-29T12:00:00.000', exportedAt: '2026-08-29T12:00:00.000',
darkMode: true, darkMode: true,
supermarkets: [ supermarkets: [
@@ -46,6 +46,24 @@ void main() {
'unit_price': 1.5, 'unit_price': 1.5,
}, },
], ],
shoppingLists: [
{
'id': 1,
'name': 'Weekly',
'created_at': '2026-08-29T12:00:00.000',
'updated_at': '2026-08-29T12:00:00.000',
},
],
shoppingListItems: [
{
'id': 1,
'list_id': 1,
'product_id': 1,
'name': 'Milk',
'checked': 0,
'sort_order': 0,
},
],
images: {'products/product_1.jpg': 'abc123'}, images: {'products/product_1.jpg': 'abc123'},
); );
@@ -54,9 +72,31 @@ void main() {
expect(parsed.products.single['name'], 'Milk'); expect(parsed.products.single['name'], 'Milk');
expect(parsed.shops.single['id'], 8); expect(parsed.shops.single['id'], 8);
expect(parsed.shopItems.single['product_id'], 1); expect(parsed.shopItems.single['product_id'], 1);
expect(parsed.shoppingLists.single['name'], 'Weekly');
expect(parsed.shoppingListItems.single['name'], 'Milk');
expect(parsed.images['products/product_1.jpg'], 'abc123'); expect(parsed.images['products/product_1.jpg'], 'abc123');
}); });
test('accepts a version 1 backup without shopping lists', () {
const json = '''
{
"format": "receipity-backup",
"version": 1,
"exportedAt": "2026-08-29T12:00:00.000",
"darkMode": false,
"supermarkets": [],
"products": [],
"shops": [],
"shopItems": [],
"images": {}
}
''';
final parsed = BackupDocument.parse(json);
expect(parsed.version, 1);
expect(parsed.shoppingLists, isEmpty);
expect(parsed.shoppingListItems, isEmpty);
});
test('rejects a file that is not a Receipity backup', () { test('rejects a file that is not a Receipity backup', () {
expect( expect(
() => BackupDocument.parse('{"hello":"world"}'), () => BackupDocument.parse('{"hello":"world"}'),
+56 -8
View File
@@ -44,17 +44,59 @@ BROODSTICKS OLIJF
expect(items.single.quantity, 4); expect(items.single.quantity, 4);
}); });
test('drops discount rows and Dutch totals', () { test('folds a discount into the previous item', () {
final items = ReceiptParser.parse(''' final result = ReceiptParser.parseReceipt('''
AARDBEIEN 7,98 AARDBEIEN 7,98
ACTIE AARDBEIEN -2,00 ACTIE AARDBEIEN -2,00
Totaal 82,99 Totaal 5,98
VISA 82,99 VISA 5,98
Totaal korting: -2,21 Totaal korting: -2,00
'''); ''');
expect(items.map((item) => item.name), ['AARDBEIEN']); expect(result.items.map((item) => item.name), ['AARDBEIEN']);
expect(items.single.price, 7.98); expect(result.items.single.price, 5.98);
expect(result.items.single.discount, -2.00);
expect(result.validationPassed, isTrue);
});
test('parses Kruidvat-style inline quantity', () {
final items = ReceiptParser.parse('''
ZEEPTABLET NEUTRAL 2 X 3,99 PER STUK 7,98
KORTING NEUTRAL ZE.HP -1,00
Totaal 6,98
''');
expect(items, hasLength(1));
expect(items.single.name, 'ZEEPTABLET NEUTRAL');
expect(items.single.quantity, 2);
expect(items.single.unitPrice, 3.99);
expect(items.single.price, 6.98);
});
test('parses Lidl-style price-reduction lines', () {
final items = ReceiptParser.parse('''
MELK 1,09 B
In prijs verlaagd -0,50
Totaal 0,59
''');
expect(items, hasLength(1));
expect(items.single.name, 'MELK');
expect(items.single.price, 0.59);
});
test('ignores BTW breakdown rows', () {
final items = ReceiptParser.parse('''
MELK 2,09
Totaal 2,09
BTW
B 9% 0,17
C 21% 0,00
Bedr.Excl 1,92
Bedr.Incl 2,09
''');
expect(items.map((item) => item.name), ['MELK']);
}); });
test('pairs a name column with a price column from OCR', () { test('pairs a name column with a price column from OCR', () {
@@ -66,10 +108,16 @@ Totaal korting: -2,21
expect(items.first.name, 'JUMBO BIO VOLLE MELK'); expect(items.first.name, 'JUMBO BIO VOLLE MELK');
expect(items.first.price, 2.09); expect(items.first.price, 2.09);
final eggs = items.firstWhere((item) => item.name.startsWith('BIOLOGISCH EI')); final eggs = items.firstWhere(
(item) => item.name.startsWith('BIOLOGISCH EI'),
);
expect(eggs.quantity, 2); expect(eggs.quantity, 2);
expect(eggs.price, 5.58); expect(eggs.price, 5.58);
final berries = items.firstWhere((item) => item.name == 'AARDBEIEN');
expect(berries.quantity, 2);
expect(berries.price, 5.98);
expect(items.last.name, 'JUMBO PREM LUIER M4'); expect(items.last.name, 'JUMBO PREM LUIER M4');
expect(items.last.price, 6.20); expect(items.last.price, 6.20);
expect(items, hasLength(26)); expect(items, hasLength(26));
+19 -2
View File
@@ -14,16 +14,33 @@ void main() {
expect(find.text('Receipity'), findsOneWidget); expect(find.text('Receipity'), findsOneWidget);
expect(find.text('Take photo'), findsOneWidget); expect(find.text('Take photo'), findsOneWidget);
expect(find.text('Pick from gallery'), findsOneWidget); expect(find.text('Pick from gallery'), findsOneWidget);
expect(find.text('Enter manually'), findsOneWidget);
expect(find.text('Use sample receipt'), findsOneWidget); expect(find.text('Use sample receipt'), findsOneWidget);
expect( expect(
find.textContaining('Take a photo of a shopping receipt'), find.textContaining('Scan a receipt or enter a trip by hand'),
findsOneWidget, findsOneWidget,
); );
expect(find.text('Products'), findsOneWidget); expect(find.text('Products'), findsOneWidget);
expect(find.text('Scan'), findsOneWidget); expect(find.text('Lists'), findsOneWidget);
expect(find.text('Add'), findsOneWidget);
expect(find.text('Shop log'), findsOneWidget); expect(find.text('Shop log'), findsOneWidget);
}); });
testWidgets('enter manually opens a blank trip', (tester) async {
await tester.pumpWidget(ReceipityApp());
await tester.tap(find.text('Enter manually'));
await tester.pumpAndSettle();
expect(find.text('New trip'), findsOneWidget);
expect(find.text('Add item'), findsOneWidget);
});
testWidgets('lists tab shows shopping lists', (tester) async {
await tester.pumpWidget(ReceipityApp());
await tester.tap(find.text('Lists'));
await tester.pump();
expect(find.text('Shopping lists'), findsOneWidget);
});
testWidgets('review screen lists items and can delete one', (tester) async { testWidgets('review screen lists items and can delete one', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(