Compare commits
2
Commits
b3171d921b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b698a3667 | ||
|
|
35d5c90d81 |
@@ -34,6 +34,10 @@ android {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
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(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
|
||||
Vendored
+15
-10
@@ -1,10 +1,15 @@
|
||||
# ML Kit text recognition ships Latin only; the plugin still references
|
||||
# optional script packs that are not in this app.
|
||||
-dontwarn com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions$Builder
|
||||
-dontwarn com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions
|
||||
-dontwarn com.google.mlkit.vision.text.devanagari.DevanagariTextRecognizerOptions$Builder
|
||||
-dontwarn com.google.mlkit.vision.text.devanagari.DevanagariTextRecognizerOptions
|
||||
-dontwarn com.google.mlkit.vision.text.japanese.JapaneseTextRecognizerOptions$Builder
|
||||
-dontwarn com.google.mlkit.vision.text.japanese.JapaneseTextRecognizerOptions
|
||||
-dontwarn com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions$Builder
|
||||
-dontwarn com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions
|
||||
# ML Kit text recognition is wired up through manifest metadata, reflection,
|
||||
# and JNI. R8 otherwise strips those classes and OCR fails in release APKs.
|
||||
-keep class com.google.mlkit.** { *; }
|
||||
-keep class com.google.android.gms.internal.mlkit_** { *; }
|
||||
-keep class com.google.android.gms.internal.mlkit_vision_** { *; }
|
||||
-keep class com.google.android.libraries.vision.** { *; }
|
||||
-keep class com.google_mlkit_commons.** { *; }
|
||||
-keep class com.google_mlkit_text_recognition.** { *; }
|
||||
-keep class * implements com.google.firebase.components.ComponentRegistrar { *; }
|
||||
|
||||
# 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.**
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
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
|
||||
android.newDsl=false
|
||||
# This builtInKotlin flag was added by the Flutter template
|
||||
|
||||
@@ -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
|
||||
@@ -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()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+29
-20
@@ -1,4 +1,4 @@
|
||||
/// Bottom navigation: products, scan, and shop log.
|
||||
/// Bottom navigation: products, lists, add, and shop log.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -8,6 +8,7 @@ import 'scan_screen.dart';
|
||||
import 'services/app_settings.dart';
|
||||
import 'services/product_repository.dart';
|
||||
import 'shop_log_screen.dart';
|
||||
import 'shopping_lists_screen.dart';
|
||||
|
||||
class HomeShell extends StatefulWidget {
|
||||
const HomeShell({super.key, required this.repository});
|
||||
@@ -19,10 +20,10 @@ class HomeShell extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeShellState extends State<HomeShell> {
|
||||
static const _scanIndex = 1;
|
||||
static const _shopLogIndex = 2;
|
||||
static const _addIndex = 2;
|
||||
static const _shopLogIndex = 3;
|
||||
|
||||
int _index = _scanIndex;
|
||||
int _index = _addIndex;
|
||||
int _revision = 0;
|
||||
AppSettings? _settings;
|
||||
int _seenGeneration = 0;
|
||||
@@ -59,9 +60,8 @@ class _HomeShellState extends State<HomeShell> {
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Shopping trip saved.')),
|
||||
);
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Shopping trip saved.')));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,17 +70,21 @@ class _HomeShellState extends State<HomeShell> {
|
||||
return Scaffold(
|
||||
body: switch (_index) {
|
||||
0 => ProductsScreen(
|
||||
key: ValueKey('products-$_revision'),
|
||||
repository: widget.repository,
|
||||
),
|
||||
1 => ScanScreen(
|
||||
repository: widget.repository,
|
||||
onTripSaved: _onTripSaved,
|
||||
),
|
||||
key: ValueKey('products-$_revision'),
|
||||
repository: widget.repository,
|
||||
),
|
||||
1 => ShoppingListsScreen(
|
||||
key: ValueKey('lists-$_revision'),
|
||||
repository: widget.repository,
|
||||
),
|
||||
2 => ScanScreen(
|
||||
repository: widget.repository,
|
||||
onTripSaved: _onTripSaved,
|
||||
),
|
||||
_ => ShopLogScreen(
|
||||
key: ValueKey('shops-$_revision'),
|
||||
repository: widget.repository,
|
||||
),
|
||||
key: ValueKey('shops-$_revision'),
|
||||
repository: widget.repository,
|
||||
),
|
||||
},
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
@@ -92,9 +96,14 @@ class _HomeShellState extends State<HomeShell> {
|
||||
label: 'Products',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.photo_camera_outlined),
|
||||
selectedIcon: Icon(Icons.photo_camera),
|
||||
label: 'Scan',
|
||||
icon: Icon(Icons.checklist_outlined),
|
||||
selectedIcon: Icon(Icons.checklist),
|
||||
label: 'Lists',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.add),
|
||||
selectedIcon: Icon(Icons.add_circle),
|
||||
label: 'Add',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.receipt_long_outlined),
|
||||
|
||||
+4
-2
@@ -6,17 +6,19 @@ import 'package:flutter/material.dart';
|
||||
import 'home_shell.dart';
|
||||
import 'services/app_settings.dart';
|
||||
import 'services/product_repository.dart';
|
||||
import 'services/receipt_parser.dart';
|
||||
import 'utils/app_theme.dart';
|
||||
import 'utils/constants.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await ReceiptParser.loadConfig();
|
||||
runApp(ReceipityApp());
|
||||
}
|
||||
|
||||
class ReceipityApp extends StatefulWidget {
|
||||
ReceipityApp({super.key, ProductRepository? repository})
|
||||
: repository = repository ?? ProductRepository();
|
||||
: repository = repository ?? ProductRepository();
|
||||
|
||||
final ProductRepository repository;
|
||||
|
||||
|
||||
@@ -8,20 +8,27 @@ class LineItem {
|
||||
required this.price,
|
||||
this.quantity,
|
||||
this.unitPrice,
|
||||
this.discount = 0,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
/// Line total after any discount.
|
||||
final double price;
|
||||
final int? quantity;
|
||||
final double? unitPrice;
|
||||
|
||||
/// Negative amount when a discount was folded into [price].
|
||||
final double discount;
|
||||
|
||||
LineItem copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
double? price,
|
||||
int? quantity,
|
||||
double? unitPrice,
|
||||
double? discount,
|
||||
bool clearQuantity = false,
|
||||
}) {
|
||||
return LineItem(
|
||||
@@ -30,6 +37,7 @@ class LineItem {
|
||||
price: price ?? this.price,
|
||||
quantity: clearQuantity ? null : (quantity ?? this.quantity),
|
||||
unitPrice: clearQuantity ? null : (unitPrice ?? this.unitPrice),
|
||||
discount: discount ?? this.discount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -9,6 +9,7 @@ import 'services/product_repository.dart';
|
||||
import 'utils/dates.dart';
|
||||
import 'utils/money.dart';
|
||||
import 'utils/supermarkets.dart';
|
||||
import 'widgets/item_edit_sheet.dart';
|
||||
import 'widgets/store_tag.dart';
|
||||
|
||||
class ReviewScreen extends StatefulWidget {
|
||||
@@ -19,6 +20,8 @@ class ReviewScreen extends StatefulWidget {
|
||||
required this.repository,
|
||||
this.imagePath,
|
||||
this.supermarketNames = kSupermarkets,
|
||||
this.needsReview = false,
|
||||
this.detectedStore,
|
||||
});
|
||||
|
||||
final List<LineItem> items;
|
||||
@@ -26,6 +29,8 @@ class ReviewScreen extends StatefulWidget {
|
||||
final ProductRepository repository;
|
||||
final String? imagePath;
|
||||
final List<String> supermarketNames;
|
||||
final bool needsReview;
|
||||
final String? detectedStore;
|
||||
|
||||
@override
|
||||
State<ReviewScreen> createState() => _ReviewScreenState();
|
||||
@@ -49,6 +54,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
);
|
||||
if (detected != null) {
|
||||
_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 {
|
||||
final result = await showModalBottomSheet<LineItem>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => _ItemEditSheet(item: existing),
|
||||
);
|
||||
final result = await showItemEditSheet(context, item: existing);
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
@@ -97,9 +99,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
Future<void> _confirm() async {
|
||||
final supermarket = _resolvedSupermarket;
|
||||
if (supermarket == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Select a supermarket.')),
|
||||
);
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Select a supermarket.')));
|
||||
return;
|
||||
}
|
||||
if (_items.isEmpty) {
|
||||
@@ -142,7 +143,13 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
}
|
||||
|
||||
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(
|
||||
children: [
|
||||
Padding(
|
||||
@@ -150,13 +157,27 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
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>(
|
||||
key: ValueKey(_supermarket),
|
||||
initialValue: _supermarket,
|
||||
hint: const Text('Select supermarket'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Supermarket',
|
||||
),
|
||||
decoration: const InputDecoration(labelText: 'Supermarket'),
|
||||
items: [
|
||||
for (final store in names)
|
||||
DropdownMenuItem(
|
||||
@@ -177,9 +198,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
TextField(
|
||||
enabled: !_saving,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Store name',
|
||||
),
|
||||
decoration: const InputDecoration(labelText: 'Store name'),
|
||||
onChanged: (value) => _customStore = value,
|
||||
),
|
||||
],
|
||||
@@ -198,7 +217,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
child: _items.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No line items found.\nAdd anything the parser missed.',
|
||||
'No line items yet.\nAdd products for this trip.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
@@ -231,11 +250,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
},
|
||||
child: ListTile(
|
||||
title: Text(item.name),
|
||||
subtitle: item.quantity == null
|
||||
? null
|
||||
: Text(
|
||||
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
|
||||
),
|
||||
subtitle: _itemSubtitle(item),
|
||||
trailing: Text(
|
||||
formatEuro(item.price),
|
||||
style: theme.textTheme.titleMedium,
|
||||
@@ -274,9 +289,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _confirm,
|
||||
child: Text(
|
||||
_saving
|
||||
? 'Saving…'
|
||||
: 'Save trip · ${formatEuro(total)}',
|
||||
_saving ? 'Saving…' : 'Save trip · ${formatEuro(total)}',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -287,98 +300,14 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ItemEditSheet extends StatefulWidget {
|
||||
const _ItemEditSheet({this.item});
|
||||
|
||||
final LineItem? item;
|
||||
|
||||
@override
|
||||
State<_ItemEditSheet> createState() => _ItemEditSheetState();
|
||||
}
|
||||
|
||||
class _ItemEditSheetState extends State<_ItemEditSheet> {
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _priceController;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final item = widget.item;
|
||||
_nameController = TextEditingController(text: item?.name ?? '');
|
||||
_priceController = TextEditingController(
|
||||
text: item == null ? '' : item.price.toStringAsFixed(2),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_priceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final name = _nameController.text.trim();
|
||||
final price = tryParsePrice(_priceController.text);
|
||||
if (name.isEmpty || price == null) {
|
||||
setState(() => _error = 'Enter a product name and a price.');
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(
|
||||
LineItem(
|
||||
id: widget.item?.id ?? 'manual-${DateTime.now().microsecondsSinceEpoch}',
|
||||
name: name,
|
||||
price: roundMoney(price),
|
||||
quantity: widget.item?.quantity,
|
||||
unitPrice: widget.item?.unitPrice,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
widget.item == null ? 'Add item' : 'Edit item',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(labelText: 'Product name'),
|
||||
autofocus: widget.item == null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _priceController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Price'),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
Widget? _itemSubtitle(LineItem item) {
|
||||
final parts = [
|
||||
if (item.quantity != null)
|
||||
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
|
||||
if (item.discount != 0) 'Discount ${formatEuro(item.discount.abs())}',
|
||||
];
|
||||
if (parts.isEmpty) return null;
|
||||
return Text(parts.join(' · '));
|
||||
}
|
||||
}
|
||||
|
||||
+62
-24
@@ -8,6 +8,8 @@ import 'package:flutter/services.dart';
|
||||
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'crop_receipt_screen.dart';
|
||||
import 'models/line_item.dart';
|
||||
import 'review_screen.dart';
|
||||
import 'services/app_settings.dart';
|
||||
import 'services/product_repository.dart';
|
||||
@@ -17,11 +19,7 @@ import 'utils/supermarkets.dart';
|
||||
import 'widgets/settings_button.dart';
|
||||
|
||||
class ScanScreen extends StatefulWidget {
|
||||
const ScanScreen({
|
||||
super.key,
|
||||
required this.repository,
|
||||
this.onTripSaved,
|
||||
});
|
||||
const ScanScreen({super.key, required this.repository, this.onTripSaved});
|
||||
|
||||
final ProductRepository repository;
|
||||
final VoidCallback? onTripSaved;
|
||||
@@ -51,7 +49,7 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
if (!mounted || response.isEmpty) return;
|
||||
final file = response.file;
|
||||
if (file != null) {
|
||||
await _runOcr(file.path);
|
||||
await _cropThenOcr(file.path);
|
||||
}
|
||||
} on UnimplementedError {
|
||||
// Desktop / platforms that do not support lost-data recovery.
|
||||
@@ -59,12 +57,9 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
}
|
||||
|
||||
Future<void> _capture(ImageSource source) async {
|
||||
final file = await _picker.pickImage(
|
||||
source: source,
|
||||
imageQuality: 95,
|
||||
);
|
||||
final file = await _picker.pickImage(source: source, imageQuality: 95);
|
||||
if (file == null || !mounted) return;
|
||||
await _runOcr(file.path);
|
||||
await _cropThenOcr(file.path);
|
||||
}
|
||||
|
||||
Future<void> _scanSampleReceipt() async {
|
||||
@@ -75,7 +70,7 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
);
|
||||
await file.writeAsBytes(data.buffer.asUint8List(), flush: true);
|
||||
if (!mounted) return;
|
||||
await _runOcr(file.path);
|
||||
await _cropThenOcr(file.path);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
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 {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
@@ -103,10 +109,17 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
_error = 'No text found in this image.';
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} on PlatformException catch (error) {
|
||||
debugPrint('OCR failed: ${error.code} ${error.message}');
|
||||
if (!mounted) return;
|
||||
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 {
|
||||
await recognizer.close();
|
||||
@@ -120,8 +133,26 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openReview(String rawText) async {
|
||||
final items = ReceiptParser.parse(rawText);
|
||||
Future<void> _enterManually() async {
|
||||
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;
|
||||
final names =
|
||||
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>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ReviewScreen(
|
||||
items: items,
|
||||
items: parsedItems ?? parsed!.items,
|
||||
rawText: rawText,
|
||||
repository: widget.repository,
|
||||
imagePath: _imagePath,
|
||||
imagePath: imagePath ?? (items == null ? _imagePath : null),
|
||||
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'),
|
||||
),
|
||||
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(
|
||||
onPressed: _busy ? null : _scanSampleReceipt,
|
||||
icon: const Icon(Icons.receipt_long_outlined),
|
||||
@@ -185,13 +224,12 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
const SizedBox(height: 16),
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Reading text…',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
Text('Reading text…', style: theme.textTheme.bodyMedium),
|
||||
],
|
||||
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) {
|
||||
return Center(
|
||||
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,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
|
||||
@@ -3,7 +3,7 @@ library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
const int kBackupVersion = 1;
|
||||
const int kBackupVersion = 2;
|
||||
|
||||
class BackupDocument {
|
||||
const BackupDocument({
|
||||
@@ -14,6 +14,8 @@ class BackupDocument {
|
||||
required this.products,
|
||||
required this.shops,
|
||||
required this.shopItems,
|
||||
required this.shoppingLists,
|
||||
required this.shoppingListItems,
|
||||
required this.images,
|
||||
});
|
||||
|
||||
@@ -24,6 +26,8 @@ class BackupDocument {
|
||||
final List<Map<String, Object?>> products;
|
||||
final List<Map<String, Object?>> shops;
|
||||
final List<Map<String, Object?>> shopItems;
|
||||
final List<Map<String, Object?>> shoppingLists;
|
||||
final List<Map<String, Object?>> shoppingListItems;
|
||||
final Map<String, String> images;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -35,6 +39,8 @@ class BackupDocument {
|
||||
'products': products,
|
||||
'shops': shops,
|
||||
'shopItems': shopItems,
|
||||
'shoppingLists': shoppingLists,
|
||||
'shoppingListItems': shoppingListItems,
|
||||
'images': images,
|
||||
};
|
||||
|
||||
@@ -60,6 +66,8 @@ class BackupDocument {
|
||||
products: _maps(decoded['products']),
|
||||
shops: _maps(decoded['shops']),
|
||||
shopItems: _maps(decoded['shopItems']),
|
||||
shoppingLists: _maps(decoded['shoppingLists']),
|
||||
shoppingListItems: _maps(decoded['shoppingListItems']),
|
||||
images: _strings(decoded['images']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ class BackupService {
|
||||
products: rewrittenProducts,
|
||||
shops: rewrittenShops,
|
||||
shopItems: await repository.dumpTable('shop_items'),
|
||||
shoppingLists: await repository.dumpTable('shopping_lists'),
|
||||
shoppingListItems: await repository.dumpTable('shopping_list_items'),
|
||||
images: images,
|
||||
);
|
||||
}
|
||||
@@ -114,7 +116,10 @@ class BackupService {
|
||||
final now = DateTime.now().toIso8601String();
|
||||
for (final row in document.products) {
|
||||
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['updated_at'] ??= copy['created_at'];
|
||||
if (copy['normalized_name'] == null && copy['name'] is String) {
|
||||
@@ -141,14 +146,13 @@ class BackupService {
|
||||
products: products,
|
||||
shops: shops,
|
||||
shopItems: document.shopItems,
|
||||
shoppingLists: document.shoppingLists,
|
||||
shoppingListItems: document.shoppingListItems,
|
||||
darkMode: document.darkMode,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _restoreImage(
|
||||
Object? key,
|
||||
Map<String, String> images,
|
||||
) async {
|
||||
Future<String?> _restoreImage(Object? key, Map<String, String> images) async {
|
||||
if (key is! String || key.isEmpty) return null;
|
||||
final encoded = images[key];
|
||||
if (encoded == null) return null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../models/line_item.dart';
|
||||
import '../models/product.dart';
|
||||
import '../models/purchase.dart';
|
||||
import '../models/shop.dart';
|
||||
import '../models/shopping_list.dart';
|
||||
import '../models/supermarket.dart';
|
||||
import '../utils/money.dart';
|
||||
import '../utils/supermarkets.dart';
|
||||
@@ -27,15 +28,20 @@ class ProductRepository {
|
||||
}
|
||||
|
||||
Future<Database> _open() async {
|
||||
final path = databasePath ??
|
||||
final path =
|
||||
databasePath ??
|
||||
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
|
||||
return openDatabase(
|
||||
path,
|
||||
version: 4,
|
||||
version: 5,
|
||||
onConfigure: (db) async {
|
||||
await db.execute('PRAGMA foreign_keys = ON');
|
||||
},
|
||||
onCreate: (db, version) async {
|
||||
await _createProducts(db);
|
||||
await _createShopTables(db);
|
||||
await _createSettingsTables(db);
|
||||
await _createShoppingListTables(db);
|
||||
await _seedSupermarkets(db);
|
||||
},
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
@@ -55,6 +61,9 @@ class ProductRepository {
|
||||
if (oldVersion < 4) {
|
||||
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) =>
|
||||
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
|
||||
|
||||
@@ -315,13 +347,11 @@ class ProductRepository {
|
||||
|
||||
Future<List<String>> getUsedCategories() async {
|
||||
final db = await _database;
|
||||
final rows = await db.rawQuery(
|
||||
'''
|
||||
final rows = await db.rawQuery('''
|
||||
SELECT DISTINCT category FROM products
|
||||
WHERE category IS NOT NULL AND TRIM(category) != ''
|
||||
ORDER BY category COLLATE NOCASE ASC
|
||||
''',
|
||||
);
|
||||
''');
|
||||
return [
|
||||
for (final row in rows)
|
||||
if (row['category'] is String) row['category']! as String,
|
||||
@@ -455,6 +485,238 @@ class ProductRepository {
|
||||
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 {
|
||||
final db = await _database;
|
||||
final rows = await db.query(
|
||||
@@ -469,16 +731,18 @@ class ProductRepository {
|
||||
|
||||
Future<void> setDarkMode(bool enabled) async {
|
||||
final db = await _database;
|
||||
await db.insert(
|
||||
'app_settings',
|
||||
{'key': 'dark_mode', 'value': enabled ? '1' : '0'},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
await db.insert('app_settings', {
|
||||
'key': 'dark_mode',
|
||||
'value': enabled ? '1' : '0',
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
Future<List<Supermarket>> getSupermarkets() async {
|
||||
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) {
|
||||
await _seedSupermarkets(db);
|
||||
return getSupermarkets();
|
||||
@@ -579,9 +843,13 @@ class ProductRepository {
|
||||
required List<Map<String, Object?>> shops,
|
||||
required List<Map<String, Object?>> shopItems,
|
||||
required bool darkMode,
|
||||
List<Map<String, Object?>> shoppingLists = const [],
|
||||
List<Map<String, Object?>> shoppingListItems = const [],
|
||||
}) async {
|
||||
final db = await _database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('shopping_list_items');
|
||||
await txn.delete('shopping_lists');
|
||||
await txn.delete('shop_items');
|
||||
await txn.delete('shops');
|
||||
await txn.delete('products');
|
||||
@@ -600,6 +868,15 @@ class ProductRepository {
|
||||
for (final row in shopItems) {
|
||||
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', {
|
||||
'key': 'dark_mode',
|
||||
'value': darkMode ? '1' : '0',
|
||||
@@ -609,15 +886,12 @@ class ProductRepository {
|
||||
await _resetSequence(txn, 'products');
|
||||
await _resetSequence(txn, 'shops');
|
||||
await _resetSequence(txn, 'shop_items');
|
||||
await _resetSequence(txn, 'shopping_lists');
|
||||
await _resetSequence(txn, 'shopping_list_items');
|
||||
});
|
||||
}
|
||||
|
||||
static const _supermarketColumns = {
|
||||
'id',
|
||||
'name',
|
||||
'color',
|
||||
'sort_order',
|
||||
};
|
||||
static const _supermarketColumns = {'id', 'name', 'color', 'sort_order'};
|
||||
static const _productColumns = {
|
||||
'id',
|
||||
'name',
|
||||
@@ -649,6 +923,20 @@ class ProductRepository {
|
||||
'quantity',
|
||||
'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?> source,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
import '../models/line_item.dart';
|
||||
import '../utils/money.dart';
|
||||
import 'receipt_parse_config.dart';
|
||||
|
||||
/// Headers, payments, and totals — never treated as products.
|
||||
const Set<String> kHeaderKeywords = {
|
||||
'TOTAL',
|
||||
'SUBTOTAL',
|
||||
'TOTAAL',
|
||||
'TAX',
|
||||
'VAT',
|
||||
'BTW',
|
||||
'CASH',
|
||||
'CARD',
|
||||
'CHANGE',
|
||||
'VISA',
|
||||
'MASTERCARD',
|
||||
'MAESTRO',
|
||||
'PIN',
|
||||
'DEBIT',
|
||||
'CREDIT',
|
||||
'OMSCHRIJVING',
|
||||
'BEDRAG',
|
||||
'AMOUNT',
|
||||
'DESCRIPTION',
|
||||
'THANK',
|
||||
'BEDANKT',
|
||||
'WISSELGELD',
|
||||
'BETAALD',
|
||||
'BETAAL',
|
||||
'CONTANT',
|
||||
'KASSA',
|
||||
'RECEIPT',
|
||||
'WWW',
|
||||
'HTTP',
|
||||
};
|
||||
export 'receipt_parse_config.dart' show loadReceiptParseConfig;
|
||||
|
||||
/// Discount / promo rows. Paired with a price, then dropped.
|
||||
const Set<String> kDropKeywords = {
|
||||
'DISCOUNT',
|
||||
'KORTING',
|
||||
'ACTIE',
|
||||
};
|
||||
/// Result of a full receipt parse, including totals used for review.
|
||||
class ReceiptParseResult {
|
||||
const ReceiptParseResult({
|
||||
required this.items,
|
||||
this.store,
|
||||
this.receiptTotal,
|
||||
this.totalDiscount = 0,
|
||||
this.validationPassed = true,
|
||||
});
|
||||
|
||||
final _separatorPattern = RegExp(r'^[=\-_.:\s]+$');
|
||||
final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b');
|
||||
final _phonePattern = RegExp(r'^\d{8,}$');
|
||||
final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}$");
|
||||
final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false);
|
||||
final List<LineItem> items;
|
||||
final String? store;
|
||||
final double? receiptTotal;
|
||||
final double totalDiscount;
|
||||
final bool validationPassed;
|
||||
}
|
||||
|
||||
class _QtyLine {
|
||||
const _QtyLine({required this.count, required this.unitPrice, this.total});
|
||||
class _Qty {
|
||||
const _Qty({required this.count, required this.unitPrice, this.total});
|
||||
|
||||
final int count;
|
||||
final double unitPrice;
|
||||
@@ -60,6 +34,59 @@ class _QtyLine {
|
||||
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 {
|
||||
ReceiptParser._();
|
||||
|
||||
@@ -67,74 +94,198 @@ class ReceiptParser {
|
||||
|
||||
static String _id() => 'item-${++_nextId}';
|
||||
|
||||
/// Parses [rawText] into product rows.
|
||||
///
|
||||
/// Prefers same-line `name … price` (with a following `2 x 1.50` line when
|
||||
/// the name has no price). If OCR split the receipt into a name column and a
|
||||
/// price column, falls back to pairing those columns.
|
||||
static List<LineItem> parse(String rawText) {
|
||||
static Future<void> loadConfig() => loadReceiptParseConfig();
|
||||
|
||||
static List<LineItem> parse(String rawText) => parseReceipt(rawText).items;
|
||||
|
||||
static ReceiptParseResult parseReceipt(String rawText) {
|
||||
final config = receiptParseConfig;
|
||||
final lines = rawText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.map(_preprocess)
|
||||
.where((line) => line.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
final inline = _parseInline(lines);
|
||||
final columns = _parseColumns(lines);
|
||||
final chosen = columns.length > inline.length ? columns : inline;
|
||||
return chosen.where(_keepItem).toList();
|
||||
if (_looksLikeColumnOcr(lines, config)) {
|
||||
return _parseColumns(lines, config, rawText);
|
||||
}
|
||||
return _parseSequential(lines, config, rawText);
|
||||
}
|
||||
|
||||
static List<LineItem> _parseInline(List<String> lines) {
|
||||
final items = <LineItem>[];
|
||||
static String _preprocess(String line) =>
|
||||
line.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final line = lines[i];
|
||||
if (_shouldSkipLine(line) || isPriceOnly(line)) continue;
|
||||
static String _stripTax(String line, ReceiptParseConfig config) {
|
||||
return line.replaceFirst(config.trailingCategoryLetter, '').trim();
|
||||
}
|
||||
|
||||
final qty = _parseQty(line);
|
||||
if (qty != null) continue;
|
||||
|
||||
final trailing = trailingPricePattern.firstMatch(line);
|
||||
if (trailing != null) {
|
||||
final name = trailing.group(1)!.trim();
|
||||
final price = tryParsePrice(trailing.group(2)!);
|
||||
if (name.isEmpty || price == null || _shouldSkipLine(name)) continue;
|
||||
items.add(LineItem(id: _id(), name: name, price: price));
|
||||
continue;
|
||||
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;
|
||||
}
|
||||
|
||||
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++;
|
||||
static ReceiptParseResult _parseSequential(
|
||||
List<String> lines,
|
||||
ReceiptParseConfig config,
|
||||
String rawText,
|
||||
) {
|
||||
final items = <LineItem>[];
|
||||
_Pending? pending;
|
||||
var receiptTotal = _readAmountAfterKeyword(
|
||||
lines,
|
||||
config,
|
||||
config.grandTotalKeywords,
|
||||
skipIf: (line) => config.matchesAny(line, [
|
||||
...config.totalDiscountKeywords,
|
||||
...config.taxInclusiveTotalKeywords,
|
||||
...config.subtotalKeywords,
|
||||
]),
|
||||
);
|
||||
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 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)) {
|
||||
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;
|
||||
}
|
||||
if (_shouldSkipLine(line)) continue;
|
||||
|
||||
final qty = _parseQty(line);
|
||||
final qty = _parseQty(line, config);
|
||||
if (qty != null) {
|
||||
if (names.isNotEmpty) {
|
||||
final last = names.removeLast();
|
||||
@@ -147,11 +298,16 @@ class ReceiptParser {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.matchesAny(line, config.discountKeywords)) {
|
||||
if (names.isNotEmpty) discountSlots.add(names.length - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
names.add((name: line, quantity: null, unitPrice: null));
|
||||
}
|
||||
|
||||
final count = names.length < prices.length ? names.length : prices.length;
|
||||
return [
|
||||
final items = <LineItem>[
|
||||
for (var i = 0; i < count; i++)
|
||||
LineItem(
|
||||
id: _id(),
|
||||
@@ -161,44 +317,274 @@ class ReceiptParser {
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
final receiptTotal = _readAmountAfterKeyword(
|
||||
lines,
|
||||
config,
|
||||
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 _keepItem(LineItem item) {
|
||||
if (item.price < 0) return false;
|
||||
if (item.name.trim().isEmpty) return false;
|
||||
return !_containsKeyword(item.name, kDropKeywords);
|
||||
static LineItem _toItem(_Pending pending, double total) {
|
||||
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 bool _shouldSkipLine(String line) {
|
||||
if (_separatorPattern.hasMatch(line)) return true;
|
||||
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 (_postalPattern.hasMatch(line)) return true;
|
||||
if (_phonePattern.hasMatch(line.replaceAll(RegExp(r'\s'), ''))) {
|
||||
return true;
|
||||
}
|
||||
if (_streetPattern.hasMatch(line)) return true;
|
||||
if (_containsKeyword(line, kHeaderKeywords)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool _containsKeyword(String line, Set<String> keywords) {
|
||||
final upper = line.toUpperCase();
|
||||
for (final keyword in keywords) {
|
||||
if (RegExp('\\b${RegExp.escape(keyword)}\\b').hasMatch(upper)) {
|
||||
return true;
|
||||
}
|
||||
if (config.categoryRow.hasMatch(line)) return true;
|
||||
if (config.isStandaloneKeywordLine(line, config.columnHeaderKeywords)) {
|
||||
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;
|
||||
}
|
||||
|
||||
static _QtyLine? _parseQty(String line) {
|
||||
final match = quantityPattern.firstMatch(line.trim());
|
||||
static double? _trailingPrice(String line, ReceiptParseConfig config) {
|
||||
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;
|
||||
final count = int.tryParse(match.group(1)!);
|
||||
final unit = tryParsePrice(match.group(2)!);
|
||||
if (count == null || count < 1 || unit == null) return null;
|
||||
final totalRaw = match.group(3);
|
||||
final total = totalRaw == null ? null : tryParsePrice(totalRaw);
|
||||
return _QtyLine(count: count, unitPrice: unit, total: total);
|
||||
double? total;
|
||||
if (match.groupCount >= 3) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+92
-13
@@ -5,11 +5,14 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'models/line_item.dart';
|
||||
import 'models/shop.dart';
|
||||
import 'product_detail_screen.dart';
|
||||
import 'services/product_repository.dart';
|
||||
import 'utils/dates.dart';
|
||||
import 'utils/money.dart';
|
||||
import 'widgets/confirm_dialog.dart';
|
||||
import 'widgets/item_edit_sheet.dart';
|
||||
|
||||
class ShopDetailScreen extends StatefulWidget {
|
||||
const ShopDetailScreen({
|
||||
@@ -27,6 +30,7 @@ class ShopDetailScreen extends StatefulWidget {
|
||||
|
||||
class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
late Future<({Shop shop, List<ShopItem> items})> _future;
|
||||
bool _editing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -43,6 +47,10 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
return (shop: shop, items: items);
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() => _future = _load());
|
||||
}
|
||||
|
||||
Future<void> _openReceipt(File file) async {
|
||||
await showDialog<void>(
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
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(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
@@ -92,10 +154,8 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
final shop = snapshot.data!.shop;
|
||||
final items = snapshot.data!.items;
|
||||
final receipt = shop.receiptImagePath;
|
||||
final receiptFile =
|
||||
receipt == null ? null : File(receipt);
|
||||
final hasReceipt =
|
||||
receiptFile != null && receiptFile.existsSync();
|
||||
final receiptFile = receipt == null ? null : File(receipt);
|
||||
final hasReceipt = receiptFile != null && receiptFile.existsSync();
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
@@ -125,20 +185,39 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
const SizedBox(height: 24),
|
||||
Text('Products', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
if (items.isEmpty)
|
||||
Text(
|
||||
'No items on this trip.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
for (final item in items)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(item.name),
|
||||
subtitle: item.quantity == null
|
||||
? null
|
||||
subtitle: _itemSubtitle(item),
|
||||
trailing: _editing
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _editItem(item),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Remove',
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _removeItem(item),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text(
|
||||
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
|
||||
formatEuro(item.price),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
trailing: Text(
|
||||
formatEuro(item.price),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
onTap: () => _openProduct(item.productId),
|
||||
onTap: _editing ? null : () => _openProduct(item.productId),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
|
||||
+35
-11
@@ -11,6 +11,7 @@ import 'services/product_repository.dart';
|
||||
import 'shop_detail_screen.dart';
|
||||
import 'utils/dates.dart';
|
||||
import 'utils/money.dart';
|
||||
import 'widgets/confirm_dialog.dart';
|
||||
import 'widgets/settings_button.dart';
|
||||
import 'widgets/store_tag.dart';
|
||||
|
||||
@@ -25,6 +26,7 @@ class ShopLogScreen extends StatefulWidget {
|
||||
|
||||
class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
late Future<List<Shop>> _future;
|
||||
bool _editing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -39,15 +41,24 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
Future<void> _openShop(Shop shop) async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ShopDetailScreen(
|
||||
repository: widget.repository,
|
||||
shopId: shop.id,
|
||||
),
|
||||
builder: (_) =>
|
||||
ShopDetailScreen(repository: widget.repository, shopId: shop.id),
|
||||
),
|
||||
);
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -56,7 +67,14 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
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>>(
|
||||
future: _future,
|
||||
@@ -77,7 +95,7 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
if (shops.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Scan a receipt to log a shopping trip.',
|
||||
'Scan a receipt or enter a trip to log it.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
@@ -103,11 +121,17 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
count == 1 ? '1 product' : '$count products',
|
||||
].join(' · '),
|
||||
),
|
||||
trailing: Text(
|
||||
formatEuro(shop.total),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
onTap: () => _openShop(shop),
|
||||
trailing: _editing
|
||||
? IconButton(
|
||||
tooltip: 'Remove',
|
||||
onPressed: () => _removeShop(shop),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
)
|
||||
: Text(
|
||||
formatEuro(shop.total),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
onTap: _editing ? null : () => _openShop(shop),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+21
-12
@@ -17,13 +17,18 @@ final List<Supermarket> kDefaultSupermarkets = [
|
||||
const Supermarket(name: 'Nettorama', colorValue: 0xFFE87722, sortOrder: 8),
|
||||
const Supermarket(name: 'Hoogvliet', colorValue: 0xFFD32F2F, sortOrder: 9),
|
||||
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 = {
|
||||
'Albert Heijn': ['ALBERT HEIJN', 'AH TO GO', 'AH XL'],
|
||||
'Jumbo': ['JUMBO'],
|
||||
'Lidl': ['LIDL'],
|
||||
'Kruidvat': ['KRUIDVAT'],
|
||||
'Aldi': ['ALDI'],
|
||||
'Plus': ['PLUS'],
|
||||
'Dirk': ['DIRK'],
|
||||
@@ -31,6 +36,10 @@ const Map<String, List<String>> kSupermarketAliases = {
|
||||
'SPAR': ['SPAR'],
|
||||
'Nettorama': ['NETTORAMA'],
|
||||
'Hoogvliet': ['HOOGVLIET'],
|
||||
'Ekoplaza': ['EKOPLAZA'],
|
||||
'Action': ['ACTION'],
|
||||
'Etos': ['ETOS'],
|
||||
'World Toko': ['WORLD TOKO'],
|
||||
'Picnic': ['PICNIC'],
|
||||
};
|
||||
|
||||
@@ -51,20 +60,20 @@ const List<String> kSupermarkets = [
|
||||
];
|
||||
|
||||
/// Returns a supermarket name if it appears in [rawText].
|
||||
String? detectSupermarket(
|
||||
String rawText, {
|
||||
Iterable<String> names = const [],
|
||||
}) {
|
||||
String? detectSupermarket(String rawText, {Iterable<String> names = const []}) {
|
||||
final upper = rawText.toUpperCase();
|
||||
final extra = names
|
||||
.map((name) => name.trim())
|
||||
.where((name) => name.isNotEmpty && name != kOtherSupermarket)
|
||||
.toList()
|
||||
..sort((a, b) => b.length.compareTo(a.length));
|
||||
final extra =
|
||||
names
|
||||
.map((name) => name.trim())
|
||||
.where((name) => name.isNotEmpty && name != kOtherSupermarket)
|
||||
.toList()
|
||||
..sort((a, b) => b.length.compareTo(a.length));
|
||||
|
||||
for (final name in extra) {
|
||||
if (RegExp('\\b${RegExp.escape(name)}\\b', caseSensitive: false)
|
||||
.hasMatch(rawText)) {
|
||||
if (RegExp(
|
||||
'\\b${RegExp.escape(name)}\\b',
|
||||
caseSensitive: false,
|
||||
).hasMatch(rawText)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -65,6 +73,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -280,6 +296,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.2"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -536,6 +560,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.2"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -18,6 +18,7 @@ dependencies:
|
||||
sqflite: ^2.4.2
|
||||
file_picker: ^12.1.2
|
||||
share_plus: ^13.3.0
|
||||
crop_your_image: ^2.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -28,3 +29,4 @@ flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/receipt.jpeg
|
||||
- receipt-parsing-keywords.json
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 1–5 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.
|
||||
@@ -3,8 +3,8 @@ import 'package:receipity/services/backup_format.dart';
|
||||
|
||||
void main() {
|
||||
test('round-trips a backup document', () {
|
||||
final original = const BackupDocument(
|
||||
version: 1,
|
||||
const original = BackupDocument(
|
||||
version: 2,
|
||||
exportedAt: '2026-08-29T12:00:00.000',
|
||||
darkMode: true,
|
||||
supermarkets: [
|
||||
@@ -46,6 +46,24 @@ void main() {
|
||||
'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'},
|
||||
);
|
||||
|
||||
@@ -54,9 +72,31 @@ void main() {
|
||||
expect(parsed.products.single['name'], 'Milk');
|
||||
expect(parsed.shops.single['id'], 8);
|
||||
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');
|
||||
});
|
||||
|
||||
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', () {
|
||||
expect(
|
||||
() => BackupDocument.parse('{"hello":"world"}'),
|
||||
|
||||
@@ -44,17 +44,59 @@ BROODSTICKS OLIJF
|
||||
expect(items.single.quantity, 4);
|
||||
});
|
||||
|
||||
test('drops discount rows and Dutch totals', () {
|
||||
final items = ReceiptParser.parse('''
|
||||
test('folds a discount into the previous item', () {
|
||||
final result = ReceiptParser.parseReceipt('''
|
||||
AARDBEIEN 7,98
|
||||
ACTIE AARDBEIEN -2,00
|
||||
Totaal 82,99
|
||||
VISA 82,99
|
||||
Totaal korting: -2,21
|
||||
Totaal 5,98
|
||||
VISA 5,98
|
||||
Totaal korting: -2,00
|
||||
''');
|
||||
|
||||
expect(items.map((item) => item.name), ['AARDBEIEN']);
|
||||
expect(items.single.price, 7.98);
|
||||
expect(result.items.map((item) => item.name), ['AARDBEIEN']);
|
||||
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', () {
|
||||
@@ -66,10 +108,16 @@ Totaal korting: -2,21
|
||||
expect(items.first.name, 'JUMBO BIO VOLLE MELK');
|
||||
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.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.price, 6.20);
|
||||
expect(items, hasLength(26));
|
||||
|
||||
+19
-2
@@ -14,16 +14,33 @@ void main() {
|
||||
expect(find.text('Receipity'), findsOneWidget);
|
||||
expect(find.text('Take photo'), findsOneWidget);
|
||||
expect(find.text('Pick from gallery'), findsOneWidget);
|
||||
expect(find.text('Enter manually'), findsOneWidget);
|
||||
expect(find.text('Use sample receipt'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('Take a photo of a shopping receipt'),
|
||||
find.textContaining('Scan a receipt or enter a trip by hand'),
|
||||
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);
|
||||
});
|
||||
|
||||
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 {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
|
||||
Reference in New Issue
Block a user