13 Commits
Author SHA1 Message Date
davmar ae42ba8193 improve parsing 3 2026-09-02 13:38:47 +02:00
davmar 98574af903 improve parsing 2 2026-09-02 12:35:41 +02:00
davmar e98fb0e7f4 improve parsing 2026-09-02 12:35:35 +02:00
davmar d532af6111 fix low count of items scanned 2026-09-01 17:52:52 +02:00
gitea cdf46bb12a fixes before build 3 2026-08-31 17:35:50 +02:00
gitea edbfade477 fixes before build 3 2026-08-31 17:35:46 +02:00
gitea b0708101b0 fixes before build 2 2026-08-31 17:05:22 +02:00
gitea 14fcb9a533 fixes before build 2026-08-31 17:03:03 +02:00
gitea 756f9db356 fixes before build 2026-08-31 16:51:31 +02:00
gitea 7692809219 home added 2026-08-31 12:57:17 +02:00
davmar 92581e6d55 new plus button 2026-08-31 09:40:38 +02:00
davmar 3b698a3667 parser logic added 2026-08-30 12:24:29 +02:00
davmar 35d5c90d81 fix OCR in apx 2026-08-29 18:02:35 +02:00
76 changed files with 11041 additions and 1260 deletions
+3 -5
View File
@@ -1,8 +1,6 @@
# Receipity
# Groci
Single-screen Flutter app that proves **capture → OCR**: take a photo of a shopping receipt (or pick one from the gallery), run Google ML Kit Text Recognition, and show the raw extracted text.
No database, no saving, no other screens.
Flutter app for scanning shopping receipts, tracking products and prices, shopping lists, and bonus cards.
## Run
@@ -11,4 +9,4 @@ flutter pub get
flutter run
```
Use a physical Android device or emulator with Google Play. ML Kit text recognition is on-device and does not require a network call.
Use a physical Android device or emulator with Google Play for receipt OCR.
+4
View File
@@ -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",
+15 -10
View File
@@ -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 -1
View File
@@ -2,7 +2,7 @@
<uses-permission android:name="android.permission.CAMERA"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<application
android:label="Receipity"
android:label="Groci"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
+2
View File
@@ -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
+188
View File
@@ -0,0 +1,188 @@
# Receipt parsing (implementation)
This is the working guide for Groci'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, item count matches when printed, and no line needs manual review |
| `items[].sourceBounds` | Normalized rect on the receipt photo for tap-to-highlight in Review |
| `items[].needsManualReview` | Parser could not resolve price/qty confidently |
| `expectedItemCount` | From `Aantal Artikel`-style lines when found |
| `ocrImageWidth` / `ocrImageHeight` | Image size used to normalize bounds |
If validation fails, Review shows a banner asking you to check items before saving.
---
## Pipeline
### Primary: spatial (ML Kit bounding boxes)
Used automatically after scanning a receipt photo.
1. **Extract elements** from ML Kit `RecognizedText` (blocks → lines → elements), keeping each element's pixel bounding box.
2. **Cluster rows** by Y-center (threshold ≈ 1.2× median element height — see `spatial_parsing` in the JSON config).
3. **Classify rows** with column logic: right ~25% of content width is the price column; pure `N X price` rows are quantity modifiers (checked first).
4. **Build items** with a one-row lookahead buffer (Jumbo split layout + Kruidvat inline layout).
5. **Validate** totals, printed item count, and manual-review flags.
Implementation: `ReceiptOcr`, `ReceiptRowBuilder`, `ReceiptSpatialParser` in `lib/services/`.
### Fallback: flat text
`ReceiptParser.parseReceipt(String)` for tests and when only flattened OCR text exists.
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.
3. **Classify** each line (first match wins) using the keyword JSON.
4. **Build items** (pending name, then quantity/discount/price).
5. **Validate** against the receipt total when one was found.
Do **not** treat weight in the name (`900g`, `500ml`) as quantity. Only `N X`
next to a price is a quantity.
---
## Line types
Priority is the same as the spec:
1. Separators / column headers (`OMSCHRIJVING`, `Artikel`, `Prijs`, …)
2. Tax / BTW tables (`BTW`, `Bedr.Excl`, `B 9%`)
3. Payment (`Betaald`, `VISA`, `PIN`, …) — also matches OCR `Betaal d:`
4. Noise / footer (URLs, opening hours, thank-you lines)
5. Receipt-level discount total (`Totaal korting`)
6. Subtotal / tax-inclusive total (ignored for items)
7. Grand total (`Totaal`, `Total`) — stops sequential item parsing when an
amount is on the same line
8. Discount (`ACTIE`, `KORTING`, `In prijs verlaagd`, or a negative amount)
9. Quantity modifier (`2 X 2,79`, optional `PER STUK`, optional line total)
10. Item (name + price at end; optional tax letter `B`/`C` is stripped)
11. Price-only
12. Name-only (starts a pending item — Jumbo multi-pack)
### Quantity variants
| Variant | Example | Store |
|---|---|---|
| **A** — name, then `N X unit` | `AARDBEIEN` / `2 X 3,99` | Jumbo, World Toko |
| **B** — name and `N X unit` on one line | `ZEEPTABLET NEUTRAL 2 X 3,99 PER STUK 7,98` | Kruidvat |
The JSON standalone quantity regex is extended in code so a right-hand
line total is allowed: `2 X 2,79 5,58`.
### Discounts
A discount is attached to the **current / previous item** and subtracted from
`price`. Receipt-wide totals (`Totaal korting`) are not products.
---
## How to add a new shop
Work in this order. You usually only need step 1.
### 1. Keywords (`receipt-parsing-keywords.json`)
| Add to | When |
|---|---|
| `known_stores.list` | Header contains a new banner name |
| `column_header_keywords.list` | New table titles (`Omschrijving`, `Amount`, …) |
| `discount_keywords.list` | Promo wording (`Bonus`, `Sparing`, …) |
| `total_keywords.*` | Other words for subtotal / grand total |
| `payment_keywords.list` | Card brands, `Betaald met …` |
| `tax_breakdown_keywords.list` | VAT table labels |
| `noise_footer_keywords.list` | Loyalty, hours, slogans that were parsed as items |
| `quantity_modifier_pattern` | Only if `N X` looks different (`2x`, `2 *`, `à`) |
Matching rules implemented in code (not only the JSON comment):
- Keywords of **3 characters or fewer** use a **word boundary** (`PIN` must
not match `SPINAZIE`).
- Column headers and item-count lines must **be the whole line** (so `Prijs`
as a header does not hide Lidl's `In prijs verlaagd`).
After editing the JSON, rebuild the app (it is loaded as an asset at startup).
Tests load the same file from the project root.
### 2. Store quirks table
Add a row here **and** in `receipt-parsing-spec.md` section 6:
| Store | Layout | Notes |
|---|---|---|
| Jumbo | A | `ACTIE <name>` + negative amount. Tax letter `B`/`C` far right. OCR may split names vs amounts → column fallback. |
| Lidl | sequential | `In prijs verlaagd -0,50`. Ignore `Bedr.Excl` / `B 9%` rows. |
| Kruidvat | B | `N X price PER STUK` on the product line. `KORTING …` may be one line later. |
| World Toko | A / simple | `Artikel` / `Prijs` headers. Weight in the name is not quantity. |
| *(new shop)* | A / B / columns | Short description of the odd lines |
### 3. Canonical store name
If OCR should pre-select the supermarket on Review:
- Add the banner string to `known_stores.list`
- Add an alias in `lib/utils/supermarkets.dart` (`kSupermarketAliases`)
- Map the uppercase banner in `ReceiptParser._canonicalStore`
### 4. Fixture test
1. Scan a real receipt, copy **Raw OCR text** from Review.
2. Add a test in `test/receipt_parser_test.dart` with that dump.
3. Assert product names, a couple of prices, quantity lines, and that headers /
totals / payment lines are **not** items.
Keep the raw dump in the test (or a `test/fixtures/` file) so the next person
can see the real layout.
### 5. Only then change parser code
Change `lib/services/receipt_parser.dart` if the new layout is a **new
structure** (not just new words): extra columns, quantity written as
`2 stuks à 1,50`, discounts *above* the product, etc.
Prefer a small, documented branch over a one-off special case for one shop.
---
## Files
| File | Role |
|---|---|
| `receipt-parsing-keywords.json` | Editable keyword / regex config |
| `receipt-parsing-spec.md` | Design: classification order, item algorithm |
| `lib/services/receipt_parse_config.dart` | Loads JSON, keyword matching |
| `lib/services/receipt_parser.dart` | Classify → items → validate |
| `test/receipt_parser_test.dart` | Layout fixtures |
---
## Out of scope (parser)
- Product categories
- Matching names to the existing catalog
- Translating receipts; add another keyword list if you need a second language
+181
View File
@@ -0,0 +1,181 @@
/// Drag a crop box over the receipt photo before OCR runs.
library;
import 'dart:io';
import 'dart:typed_data';
import 'package:crop_your_image/crop_your_image.dart';
import 'package:flutter/material.dart';
class CropReceiptScreen extends StatefulWidget {
const CropReceiptScreen({super.key, required this.imagePath});
final String imagePath;
@override
State<CropReceiptScreen> createState() => _CropReceiptScreenState();
}
class _CropReceiptScreenState extends State<CropReceiptScreen> {
final CropController _controller = CropController();
Uint8List? _image;
String? _error;
bool _ready = false;
bool _cropping = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final bytes = await File(widget.imagePath).readAsBytes();
if (!mounted) return;
setState(() => _image = bytes);
} catch (_) {
if (!mounted) return;
setState(() => _error = 'Could not open this photo.');
}
}
void _onCropped(CropResult result) {
switch (result) {
case CropSuccess(:final croppedImage):
_saveAndPop(croppedImage);
case CropFailure():
if (!mounted) return;
setState(() => _cropping = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not crop this photo.')),
);
}
}
Future<void> _saveAndPop(Uint8List bytes) async {
try {
final file = File(
'${Directory.systemTemp.path}/groci_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()),
),
],
);
}
}
+452
View File
@@ -0,0 +1,452 @@
/// Dashboard of catalog, trips, lists, and this month's spend.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'models/app_overview.dart';
import 'models/shop.dart';
import 'models/supermarket.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'shop_detail_screen.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'widgets/profile_button.dart';
import 'widgets/settings_button.dart';
import 'widgets/store_tag.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({
super.key,
required this.repository,
required this.onOpenProducts,
required this.onOpenLists,
required this.onOpenShopLog,
required this.onOpenAdd,
});
final ProductRepository repository;
final VoidCallback onOpenProducts;
final VoidCallback onOpenLists;
final VoidCallback onOpenShopLog;
final VoidCallback onOpenAdd;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
AppOverview? _overview;
List<Supermarket> _stores = const [];
Object? _error;
int _loadToken = 0;
final ImagePicker _picker = ImagePicker();
@override
void initState() {
super.initState();
widget.repository.addListener(_load);
_load();
}
@override
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final token = ++_loadToken;
try {
final overview = await widget.repository.getOverview();
final stores = await widget.repository.getSupermarkets();
final lastTripStores = await widget.repository.getLastTripSupermarkets();
if (!mounted || token != _loadToken) return;
setState(() {
_overview = overview;
_stores = sortStoresByLastTrip(stores, lastTripStores);
_error = null;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
}
Future<void> _openShop(Shop shop) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) =>
ShopDetailScreen(repository: widget.repository, shopId: shop.id),
),
);
if (mounted) _load();
}
Future<void> _openBonusCard(Supermarket store) async {
final path = store.bonusImagePath;
if (path != null && path.isNotEmpty && File(path).existsSync()) {
await _viewBonusCard(path);
return;
}
await _pickBonusCard(store);
}
Future<void> _viewBonusCard(String path) async {
await showDialog<void>(
context: context,
builder: (context) {
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: InteractiveViewer(
child: Image.file(File(path), fit: BoxFit.contain),
),
);
},
);
}
Future<void> _pickBonusCard(Supermarket store) async {
final source = await showModalBottomSheet<ImageSource>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Take photo'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Pick from gallery'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
);
},
);
if (source == null || store.id == null) return;
final file = await _picker.pickImage(source: source, imageQuality: 85);
if (file == null) return;
await widget.repository.setSupermarketBonusCard(store.id!, file.path);
if (mounted) _load();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Home'),
actions: const [ProfileButton(), SettingsButton()],
),
body: _error != null && _overview == null
? Center(
child: Text(
'Could not load an overview.',
style: theme.textTheme.bodyLarge,
),
)
: _overview == null
? const Center(child: CircularProgressIndicator())
: _HomeBody(
overview: _overview!,
stores: _stores,
onOpenProducts: widget.onOpenProducts,
onOpenLists: widget.onOpenLists,
onOpenShopLog: widget.onOpenShopLog,
onOpenAdd: widget.onOpenAdd,
onOpenShop: _openShop,
onOpenBonusCard: _openBonusCard,
),
);
}
}
class _HomeBody extends StatelessWidget {
const _HomeBody({
required this.overview,
required this.stores,
required this.onOpenProducts,
required this.onOpenLists,
required this.onOpenShopLog,
required this.onOpenAdd,
required this.onOpenShop,
required this.onOpenBonusCard,
});
final AppOverview overview;
final List<Supermarket> stores;
final VoidCallback onOpenProducts;
final VoidCallback onOpenLists;
final VoidCallback onOpenShopLog;
final VoidCallback onOpenAdd;
final ValueChanged<Shop> onOpenShop;
final ValueChanged<Supermarket> onOpenBonusCard;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final settingsStores =
SettingsScope.maybeOf(context)?.supermarkets ?? stores;
final empty =
overview.productCount == 0 &&
overview.tripCount == 0 &&
overview.listCount == 0;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Text('Overview', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
if (empty)
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Text(
'Nothing logged yet.',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Scan a receipt or add a trip to see products, lists, and spending here.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onOpenAdd,
icon: const Icon(Icons.add),
label: const Text('Add a trip'),
),
],
),
),
)
else ...[
Row(
children: [
Expanded(
child: _StatCard(
icon: Icons.receipt_long_outlined,
label: formatMonthYear(DateTime.now()),
value: formatEuro(overview.monthTotal),
detail: overview.monthTripCount == 0
? 'No trips yet'
: overview.monthTripCount == 1
? '1 trip'
: '${overview.monthTripCount} trips',
onTap: onOpenShopLog,
),
),
const SizedBox(width: 8),
Expanded(
child: _StatCard(
icon: Icons.inventory_2_outlined,
label: 'Products',
value: '${overview.productCount}',
detail: overview.favouriteCount == 0
? 'No favourites'
: overview.favouriteCount == 1
? '1 favourite'
: '${overview.favouriteCount} favourites',
onTap: onOpenProducts,
),
),
],
),
const SizedBox(height: 8),
_StatCard(
icon: Icons.checklist_outlined,
label: 'Shopping lists',
value: '${overview.listCount}',
detail: overview.listCount == 1 ? '1 list' : 'Open your lists',
onTap: onOpenLists,
),
],
if (stores.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Bonus cards', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 148,
child: ListView.separated(
key: ValueKey(
stores.isEmpty ? 'bonus-empty' : 'bonus-${stores.first.name}',
),
scrollDirection: Axis.horizontal,
itemCount: stores.length,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final store = stores[index];
return _BonusCardTile(
store: store,
stores: settingsStores,
onTap: () => onOpenBonusCard(store),
);
},
),
),
],
if (overview.recentShops.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Recent trips', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
for (final shop in overview.recentShops)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: shop.name == null || shop.name!.isEmpty
? StoreTag(name: shop.supermarket, stores: settingsStores)
: Text(shop.name!),
subtitle: Text(
[
formatDate(shop.shoppedAt),
formatEuro(shop.total),
].join(' · '),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => onOpenShop(shop),
),
),
],
],
);
}
}
class _BonusCardTile extends StatelessWidget {
const _BonusCardTile({
required this.store,
required this.stores,
required this.onTap,
});
final Supermarket store;
final List<Supermarket> stores;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final path = store.bonusImagePath;
final hasCard =
path != null && path.isNotEmpty && File(path).existsSync();
return SizedBox(
width: 220,
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: !hasCard
? ColoredBox(
color: store.color.withValues(alpha: 0.18),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.card_membership_outlined,
color: store.color,
size: 32,
),
const SizedBox(height: 8),
Text(
'Add bonus card',
style: theme.textTheme.bodySmall,
),
],
),
),
)
: Image.file(
File(path),
fit: BoxFit.cover,
width: double.infinity,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
child: Row(
children: [
StoreTag(name: store.name, stores: stores, compact: true),
const Spacer(),
Icon(
hasCard
? Icons.zoom_in
: Icons.camera_alt_outlined,
size: 16,
color: scheme.onSurfaceVariant,
),
],
),
),
],
),
),
),
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({
required this.icon,
required this.label,
required this.value,
required this.detail,
required this.onTap,
});
final IconData icon;
final String label;
final String value;
final String detail;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: scheme.primary),
const SizedBox(height: 12),
Text(label, style: theme.textTheme.bodyMedium),
const SizedBox(height: 4),
Text(value, style: theme.textTheme.titleLarge),
const SizedBox(height: 2),
Text(detail, style: theme.textTheme.bodyMedium),
],
),
),
),
);
}
}
+143 -23
View File
@@ -1,13 +1,15 @@
/// Bottom navigation: products, scan, and shop log.
/// Bottom navigation: home, products, lists, add, and shop log.
library;
import 'package:flutter/material.dart';
import 'home_screen.dart';
import 'products_screen.dart';
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 +21,14 @@ class HomeShell extends StatefulWidget {
}
class _HomeShellState extends State<HomeShell> {
static const _scanIndex = 1;
static const _shopLogIndex = 2;
static const _homeIndex = 0;
static const _productsIndex = 1;
static const _addIndex = 2;
static const _listsIndex = 3;
static const _shopLogIndex = 4;
static const _fabGap = 56.0;
int _index = _scanIndex;
int _index = _homeIndex;
int _revision = 0;
AppSettings? _settings;
int _seenGeneration = 0;
@@ -59,21 +65,39 @@ 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.')));
});
}
void _select(int index) {
if (index == _index) return;
setState(() => _index = index);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
body: switch (_index) {
0 => ProductsScreen(
_homeIndex => HomeScreen(
key: ValueKey('home-$_revision'),
repository: widget.repository,
onOpenProducts: () => _select(_productsIndex),
onOpenLists: () => _select(_listsIndex),
onOpenShopLog: () => _select(_shopLogIndex),
onOpenAdd: () => _select(_addIndex),
),
_productsIndex => ProductsScreen(
key: ValueKey('products-$_revision'),
repository: widget.repository,
),
1 => ScanScreen(
_listsIndex => ShoppingListsScreen(
key: ValueKey('lists-$_revision'),
repository: widget.repository,
),
_addIndex => ScanScreen(
repository: widget.repository,
onTripSaved: _onTripSaved,
),
@@ -82,24 +106,120 @@ class _HomeShellState extends State<HomeShell> {
repository: widget.repository,
),
},
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: (index) => setState(() => _index = index),
destinations: const [
NavigationDestination(
icon: Icon(Icons.inventory_2_outlined),
selectedIcon: Icon(Icons.inventory_2),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
floatingActionButton: FloatingActionButton.small(
onPressed: () => _select(_addIndex),
tooltip: 'Add',
elevation: _index == _addIndex ? 4 : 1,
shape: const CircleBorder(),
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
child: const Icon(Icons.add, size: 24),
),
bottomNavigationBar: BottomAppBar(
shape: const CircularNotchedRectangle(),
notchMargin: 6,
elevation: 2,
height: 52,
padding: EdgeInsets.zero,
color: scheme.surfaceContainer,
child: Row(
children: [
Expanded(
child: Row(
children: [
Expanded(
child: _NavButton(
icon: Icons.home_outlined,
selectedIcon: Icons.home,
label: 'Home',
selected: _index == _homeIndex,
onTap: () => _select(_homeIndex),
),
),
Expanded(
child: _NavButton(
icon: Icons.inventory_2_outlined,
selectedIcon: Icons.inventory_2,
label: 'Products',
selected: _index == _productsIndex,
onTap: () => _select(_productsIndex),
),
NavigationDestination(
icon: Icon(Icons.photo_camera_outlined),
selectedIcon: Icon(Icons.photo_camera),
label: 'Scan',
),
NavigationDestination(
icon: Icon(Icons.receipt_long_outlined),
selectedIcon: Icon(Icons.receipt_long),
],
),
),
const SizedBox(width: _fabGap),
Expanded(
child: Row(
children: [
Expanded(
child: _NavButton(
icon: Icons.checklist_outlined,
selectedIcon: Icons.checklist,
label: 'Lists',
selected: _index == _listsIndex,
onTap: () => _select(_listsIndex),
),
),
Expanded(
child: _NavButton(
icon: Icons.receipt_long_outlined,
selectedIcon: Icons.receipt_long,
label: 'Shop log',
selected: _index == _shopLogIndex,
onTap: () => _select(_shopLogIndex),
),
),
],
),
),
],
),
),
);
}
}
class _NavButton extends StatelessWidget {
const _NavButton({
required this.icon,
required this.selectedIcon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final IconData selectedIcon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final color = selected ? scheme.primary : scheme.onSurfaceVariant;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(selected ? selectedIcon : icon, color: color, size: 20),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: color,
height: 1.1,
fontSize: 10,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
),
],
),
+8 -6
View File
@@ -6,25 +6,27 @@ 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();
runApp(ReceipityApp());
await ReceiptParser.loadConfig();
runApp(GrociApp());
}
class ReceipityApp extends StatefulWidget {
ReceipityApp({super.key, ProductRepository? repository})
class GrociApp extends StatefulWidget {
GrociApp({super.key, ProductRepository? repository})
: repository = repository ?? ProductRepository();
final ProductRepository repository;
@override
State<ReceipityApp> createState() => _ReceipityAppState();
State<GrociApp> createState() => _GrociAppState();
}
class _ReceipityAppState extends State<ReceipityApp> {
class _GrociAppState extends State<GrociApp> {
late final AppSettings _settings;
@override
+24
View File
@@ -0,0 +1,24 @@
/// Counts and recent trips shown on the home dashboard.
library;
import 'shop.dart';
class AppOverview {
const AppOverview({
required this.productCount,
required this.favouriteCount,
required this.tripCount,
required this.monthTripCount,
required this.monthTotal,
required this.listCount,
required this.recentShops,
});
final int productCount;
final int favouriteCount;
final int tripCount;
final int monthTripCount;
final double monthTotal;
final int listCount;
final List<Shop> recentShops;
}
+31
View File
@@ -1,6 +1,8 @@
/// A parsed receipt row shown on the review screen.
library;
import 'receipt_bounds.dart';
class LineItem {
LineItem({
required this.id,
@@ -8,21 +10,47 @@ class LineItem {
required this.price,
this.quantity,
this.unitPrice,
this.discount = 0,
this.sourceBounds,
this.needsManualReview = false,
});
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;
/// Region on the receipt photo (normalized 0–1) used when parsing this line.
final ReceiptBounds? sourceBounds;
/// True when the parser could not confidently resolve price/qty for this line.
final bool needsManualReview;
bool get hasDiscount => discount != 0;
/// List price before a folded discount. Equals [price] when there is none.
double get originalPrice {
if (!hasDiscount) return price;
return ((price - discount) * 100).round() / 100;
}
LineItem copyWith({
String? id,
String? name,
double? price,
int? quantity,
double? unitPrice,
double? discount,
ReceiptBounds? sourceBounds,
bool? needsManualReview,
bool clearQuantity = false,
bool clearSourceBounds = false,
}) {
return LineItem(
id: id ?? this.id,
@@ -30,6 +58,9 @@ class LineItem {
price: price ?? this.price,
quantity: clearQuantity ? null : (quantity ?? this.quantity),
unitPrice: clearQuantity ? null : (unitPrice ?? this.unitPrice),
discount: discount ?? this.discount,
sourceBounds: clearSourceBounds ? null : (sourceBounds ?? this.sourceBounds),
needsManualReview: needsManualReview ?? this.needsManualReview,
);
}
}
+61
View File
@@ -0,0 +1,61 @@
/// One OCR text fragment with pixel bounding box on the receipt image.
library;
class OcrElement {
const OcrElement({
required this.text,
required this.left,
required this.top,
required this.right,
required this.bottom,
});
final String text;
final double left;
final double top;
final double right;
final double bottom;
double get centerX => (left + right) / 2;
double get centerY => (top + bottom) / 2;
double get height => (bottom - top).abs();
double get width => (right - left).abs();
}
/// One ML Kit text line with its word-level elements.
class OcrLine {
const OcrLine({
required this.text,
required this.elements,
required this.left,
required this.top,
required this.right,
required this.bottom,
});
final String text;
final List<OcrElement> elements;
final double left;
final double top;
final double right;
final double bottom;
double get centerY => (top + bottom) / 2;
}
/// All positioned elements from one ML Kit recognition pass.
class OcrDocument {
const OcrDocument({
required this.elements,
required this.lines,
required this.imageWidth,
required this.imageHeight,
required this.flattenedText,
});
final List<OcrElement> elements;
final List<OcrLine> lines;
final double imageWidth;
final double imageHeight;
final String flattenedText;
}
+1 -1
View File
@@ -72,4 +72,4 @@ class Product {
}
}
enum ProductSort { name, preferred }
enum ProductSort { name, preferred, favourite }
+57
View File
@@ -0,0 +1,57 @@
/// Normalized rectangle (0–1) on the receipt photo for review highlights.
library;
class ReceiptBounds {
const ReceiptBounds({
required this.left,
required this.top,
required this.right,
required this.bottom,
});
final double left;
final double top;
final double right;
final double bottom;
bool get isEmpty => right <= left || bottom <= top;
ReceiptBounds union(ReceiptBounds other) {
if (isEmpty) return other;
if (other.isEmpty) return this;
return ReceiptBounds(
left: left < other.left ? left : other.left,
top: top < other.top ? top : other.top,
right: right > other.right ? right : other.right,
bottom: bottom > other.bottom ? bottom : other.bottom,
);
}
static ReceiptBounds? merge(Iterable<ReceiptBounds> bounds) {
ReceiptBounds? merged;
for (final box in bounds) {
if (box.isEmpty) continue;
merged = merged == null ? box : merged.union(box);
}
return merged;
}
static ReceiptBounds fromPixelRect({
required double left,
required double top,
required double right,
required double bottom,
required double imageWidth,
required double imageHeight,
}) {
if (imageWidth <= 0 || imageHeight <= 0) {
return const ReceiptBounds(left: 0, top: 0, right: 0, bottom: 0);
}
return ReceiptBounds(
left: left / imageWidth,
top: top / imageHeight,
right: right / imageWidth,
bottom: bottom / imageHeight,
);
}
}
+39
View File
@@ -0,0 +1,39 @@
/// One visually reconstructed receipt row from clustered OCR elements.
library;
import 'ocr_element.dart';
import 'receipt_bounds.dart';
enum ReceiptRowKind {
skip,
quantityModifier,
nameOnly,
item,
discount,
grandTotal,
totalDiscount,
}
class ReceiptRow {
const ReceiptRow({
required this.kind,
required this.text,
required this.nameText,
this.price,
this.quantity,
this.unitPrice,
this.lineTotal,
this.elements = const [],
this.bounds,
});
final ReceiptRowKind kind;
final String text;
final String nameText;
final double? price;
final int? quantity;
final double? unitPrice;
final double? lineTotal;
final List<OcrElement> elements;
final ReceiptBounds? bounds;
}
+30 -5
View File
@@ -8,22 +8,47 @@ class Shop {
required this.shoppedAt,
required this.total,
required this.itemCount,
this.name,
this.receiptImagePath,
this.tags = const [],
});
final int id;
final String supermarket;
final String? name;
final DateTime shoppedAt;
final double total;
final int itemCount;
final String? receiptImagePath;
final List<String> tags;
String get displayName {
final tripName = name?.trim();
if (tripName != null && tripName.isNotEmpty) return tripName;
return supermarket;
}
Shop copyWith({String? name, bool clearName = false, List<String>? tags}) {
return Shop(
id: id,
supermarket: supermarket,
shoppedAt: shoppedAt,
total: total,
itemCount: itemCount,
receiptImagePath: receiptImagePath,
name: clearName ? null : (name ?? this.name),
tags: tags ?? this.tags,
);
}
factory Shop.fromMap(Map<String, Object?> map) {
final rawName = (map['name'] as String?)?.trim();
return Shop(
id: map['id']! as int,
supermarket: map['supermarket']! as String,
name: (rawName == null || rawName.isEmpty) ? null : rawName,
shoppedAt: DateTime.parse(map['shopped_at']! as String),
total: (map['total']! as num).toDouble(),
total: (map['total'] as num).toDouble(),
itemCount: (map['item_count'] as num?)?.toInt() ?? 0,
receiptImagePath: map['receipt_image_path'] as String?,
);
@@ -34,7 +59,7 @@ class ShopItem {
const ShopItem({
required this.id,
required this.shopId,
required this.productId,
this.productId,
required this.name,
required this.price,
this.quantity,
@@ -43,7 +68,7 @@ class ShopItem {
final int id;
final int shopId;
final int productId;
final int? productId;
final String name;
final double price;
final int? quantity;
@@ -53,9 +78,9 @@ class ShopItem {
return ShopItem(
id: map['id']! as int,
shopId: map['shop_id']! as int,
productId: map['product_id']! as int,
productId: map['product_id'] as int?,
name: map['name']! as String,
price: (map['price']! as num).toDouble(),
price: (map['price'] as num).toDouble(),
quantity: map['quantity'] as int?,
unitPrice: (map['unit_price'] as num?)?.toDouble(),
);
+62
View File
@@ -0,0 +1,62 @@
/// 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.createdAt,
required this.updatedAt,
});
final int id;
final String name;
final int itemCount;
final int checkedCount;
final DateTime createdAt;
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,
createdAt: DateTime.parse(
(map['created_at'] ?? map['updated_at'])! as String,
),
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?,
);
}
}
+34
View File
@@ -9,12 +9,14 @@ class Supermarket {
required this.name,
required this.colorValue,
this.sortOrder = 0,
this.bonusImagePath,
});
final int? id;
final String name;
final int colorValue;
final int sortOrder;
final String? bonusImagePath;
Color get color => Color(colorValue);
@@ -24,6 +26,7 @@ class Supermarket {
name: map['name']! as String,
colorValue: map['color']! as int,
sortOrder: map['sort_order'] as int? ?? 0,
bonusImagePath: map['bonus_image_path'] as String?,
);
}
@@ -32,16 +35,47 @@ class Supermarket {
String? name,
int? colorValue,
int? sortOrder,
String? bonusImagePath,
bool clearBonusImage = false,
}) {
return Supermarket(
id: id ?? this.id,
name: name ?? this.name,
colorValue: colorValue ?? this.colorValue,
sortOrder: sortOrder ?? this.sortOrder,
bonusImagePath: clearBonusImage
? null
: (bonusImagePath ?? this.bonusImagePath),
);
}
}
/// Puts stores with the most recent trip first, then earlier trips, then unused.
List<Supermarket> sortStoresByLastTrip(
List<Supermarket> stores,
Iterable<String> lastTripStores,
) {
final rank = <String, int>{};
var next = 0;
for (final name in lastTripStores) {
final key = name.trim().toUpperCase();
if (key.isEmpty || rank.containsKey(key)) continue;
rank[key] = next++;
}
final sorted = [...stores];
sorted.sort((a, b) {
final aRank = rank[a.name.trim().toUpperCase()] ?? 1 << 20;
final bRank = rank[b.name.trim().toUpperCase()] ?? 1 << 20;
final byTrip = aRank.compareTo(bRank);
if (byTrip != 0) return byTrip;
final byOrder = a.sortOrder.compareTo(b.sortOrder);
if (byOrder != 0) return byOrder;
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
return sorted;
}
/// Palette offered when the user picks a store color.
const List<int> kStoreColorPalette = [
0xFFEEC21B, // Jumbo yellow
+69 -16
View File
@@ -13,6 +13,7 @@ import 'utils/categories.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'utils/supermarkets.dart';
import 'widgets/pick_shopping_list_sheet.dart';
import 'widgets/price_chart.dart';
import 'widgets/product_image.dart';
import 'widgets/store_tag.dart';
@@ -37,6 +38,7 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
late final TextEditingController _notesController;
final ImagePicker _picker = ImagePicker();
bool _ready = false;
bool _leaving = false;
@override
void initState() {
@@ -48,7 +50,7 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
@override
void dispose() {
_saveNotes();
_saveEdits();
_nameController.dispose();
_notesController.dispose();
super.dispose();
@@ -69,7 +71,18 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
}
void _reload() {
setState(() => _future = _load());
setState(() {
_future = _load();
});
}
Future<void> _saveEdits() {
final name = _nameController.text.trim();
return widget.repository.updateProduct(
id: widget.productId,
name: name.isEmpty ? null : name,
notes: _notesController.text.trim(),
);
}
Future<void> _saveName() async {
@@ -86,6 +99,15 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
);
}
Future<void> _leave() async {
if (_leaving) return;
_leaving = true;
try {
await _saveEdits();
} catch (_) {}
if (mounted) Navigator.of(context).pop();
}
Future<void> _toggleFavourite(Product product) async {
await widget.repository.setFavourite(product.id, !product.favourite);
if (mounted) _reload();
@@ -169,6 +191,22 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
if (mounted) _reload();
}
Future<void> _addToList(Product product) async {
final listId = await showPickShoppingListSheet(
context,
repository: widget.repository,
);
if (listId == null || !mounted) return;
await widget.repository.addShoppingListItem(
listId: listId,
name: product.name,
productId: product.id,
);
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Added to shopping list')));
}
Future<void> _pickCategory(Product product) async {
final used = await widget.repository.getUsedCategories();
if (!mounted) return;
@@ -238,10 +276,8 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
Future<void> _openShop(int shopId) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => ShopDetailScreen(
repository: widget.repository,
shopId: shopId,
),
builder: (_) =>
ShopDetailScreen(repository: widget.repository, shopId: shopId),
),
);
if (mounted) _reload();
@@ -250,10 +286,16 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stores = SettingsScope.maybeOf(context)?.supermarkets ??
kDefaultSupermarkets;
final stores =
SettingsScope.maybeOf(context)?.supermarkets ?? kDefaultSupermarkets;
return Scaffold(
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
_leave();
},
child: Scaffold(
appBar: AppBar(title: const Text('Product')),
body: FutureBuilder(
future: _future,
@@ -317,13 +359,15 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
Expanded(
child: TextField(
controller: _nameController,
textCapitalization: TextCapitalization.sentences,
textCapitalization:
TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Name',
isDense: true,
),
onSubmitted: (_) => _saveName(),
onEditingComplete: _saveName,
onTapOutside: (_) => _saveName(),
),
),
IconButton(
@@ -351,7 +395,8 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
child: product.supermarket == null
? Text(
'Add supermarket',
style: theme.textTheme.labelMedium?.copyWith(
style: theme.textTheme.labelMedium
?.copyWith(
color: theme.colorScheme.primary,
),
)
@@ -381,6 +426,15 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
onPressed: () => _pickCategory(product),
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
onPressed: () => _addToList(product),
icon: const Icon(Icons.playlist_add),
label: const Text('Add to list'),
),
),
const SizedBox(height: 24),
Text(
'Price and purchase history',
@@ -405,7 +459,8 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
[
formatDate(purchase.boughtAt),
purchase.supermarket,
if (purchase.quantity != null) '${purchase.quantity}×',
if (purchase.quantity != null)
'${purchase.quantity}×',
].join(' · '),
),
trailing: const Icon(Icons.chevron_right),
@@ -431,6 +486,7 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
);
},
),
),
);
}
}
@@ -477,10 +533,7 @@ class _CategoryNameDialogState extends State<_CategoryNameDialog> {
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _save,
child: const Text('Save'),
),
FilledButton(onPressed: _save, child: const Text('Save')),
],
);
}
+246 -56
View File
@@ -4,12 +4,17 @@ library;
import 'package:flutter/material.dart';
import 'models/product.dart';
import 'models/supermarket.dart';
import 'product_detail_screen.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'utils/fuzzy.dart';
import 'utils/money.dart';
import 'utils/product_names.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/product_image.dart';
import 'widgets/product_name_conflicts.dart';
import 'widgets/profile_button.dart';
import 'widgets/settings_button.dart';
import 'widgets/store_tag.dart';
@@ -24,33 +29,56 @@ class ProductsScreen extends StatefulWidget {
class _ProductsScreenState extends State<ProductsScreen> {
ProductSort _sort = ProductSort.preferred;
bool _favouritesOnly = false;
bool _searching = false;
bool _editing = false;
final TextEditingController _search = TextEditingController();
late Future<List<Product>> _future;
List<Product>? _products;
ProductNameIssues _nameIssues = const ProductNameIssues(
mergeGroups: [],
spellingVariants: [],
);
Object? _error;
int _loadToken = 0;
bool get _hasProducts => _products != null && _products!.isNotEmpty;
@override
void initState() {
super.initState();
_future = _load();
widget.repository.addListener(_load);
_search.addListener(() => setState(() {}));
_load();
}
@override
void dispose() {
widget.repository.removeListener(_load);
_search.dispose();
super.dispose();
}
Future<List<Product>> _load() {
return widget.repository.getAll(
sort: _sort,
favouritesOnly: _favouritesOnly,
);
Future<void> _load() async {
final token = ++_loadToken;
try {
final products = await widget.repository.getAll(sort: _sort);
final nameIssues = await widget.repository.getProductNameIssues();
if (!mounted || token != _loadToken) return;
setState(() {
_products = products;
_nameIssues = nameIssues;
_error = null;
if (products.isEmpty) {
_editing = false;
if (_searching) {
_searching = false;
_search.clear();
}
}
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
void _reload() {
setState(() => _future = _load());
}
List<Product> _filter(List<Product> products) {
@@ -82,13 +110,98 @@ class _ProductsScreenState extends State<ProductsScreen> {
),
),
);
if (mounted) _reload();
if (mounted) _load();
}
Future<void> _removeProduct(Product product) async {
final confirmed = await confirmAction(
context,
title: 'Delete product?',
message:
'"${product.name}" will be removed from your catalog. Trip history keeps the purchase line.',
);
if (!confirmed || !mounted) return;
setState(() {
_products = [
for (final item in _products ?? const <Product>[])
if (item.id != product.id) item,
];
if (_products!.isEmpty) {
_editing = false;
_searching = false;
_search.clear();
}
});
try {
await widget.repository.deleteProduct(product.id);
} catch (_) {
if (mounted) _load();
}
}
Future<void> _removeAllProducts() async {
final confirmed = await confirmAction(
context,
title: 'Delete all products?',
message: 'This removes every product from your catalog. Shopping trips stay in the shop log.',
);
if (!confirmed || !mounted) return;
setState(() {
_products = const [];
_editing = false;
_searching = false;
_search.clear();
});
try {
await widget.repository.deleteAllProducts();
} catch (_) {
if (mounted) _load();
}
}
Set<int> get _issueProductIds => _nameIssues.productIds;
Future<void> _resolveNameIssues({int? focusProductId}) async {
final issues = await widget.repository.getProductNameIssues();
if (!mounted || issues.isEmpty) return;
final resolved = await showProductNameConflictSheet(
context,
repository: widget.repository,
issues: issues,
focusProductId: focusProductId,
);
if (resolved && mounted) _load();
}
Future<void> _toggleFavourite(Product product) async {
final next = !product.favourite;
setState(() {
_products = [
for (final item in _products ?? const <Product>[])
if (item.id == product.id) item.copyWith(favourite: next) else item,
];
if (_sort == ProductSort.favourite && !next) {
_products = [
for (final item in _products!)
if (item.id != product.id) item,
];
if (_products!.isEmpty) _editing = false;
}
});
try {
await widget.repository.setFavourite(product.id, next);
} catch (_) {
if (mounted) _load();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
final products = _products == null
? const <Product>[]
: _filter(_products!);
return Scaffold(
appBar: AppBar(
@@ -106,68 +219,92 @@ class _ProductsScreenState extends State<ProductsScreen> {
)
: const Text('Products'),
actions: [
if (!_editing)
IconButton(
tooltip: _searching ? 'Close search' : 'Search',
onPressed: () {
onPressed: _hasProducts
? () {
setState(() {
_searching = !_searching;
if (!_searching) _search.clear();
});
},
}
: null,
icon: Icon(_searching ? Icons.close : Icons.search),
),
const SettingsButton(),
if (_hasProducts && _nameIssues.isNotEmpty && !_editing)
IconButton(
tooltip: 'Resolve name issues',
onPressed: _resolveNameIssues,
icon: const Icon(kProductNameConflictIcon),
),
if (_hasProducts)
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() {
_editing = !_editing;
if (_editing) {
_searching = false;
_search.clear();
}
}),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
if (_editing)
IconButton(
tooltip: 'Delete all products',
onPressed: _removeAllProducts,
icon: const Icon(Icons.delete_outline),
),
if (!_editing) ...const [ProfileButton(), SettingsButton()],
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
child: Row(
children: [
Expanded(
child: SegmentedButton<ProductSort>(
segments: const [
ButtonSegment(
value: ProductSort.preferred,
label: Text('Preferred'),
),
ButtonSegment(
value: ProductSort.name,
label: Text('A–Z'),
),
ButtonSegment(
value: ProductSort.preferred,
label: Text('Frequent'),
),
ButtonSegment(
value: ProductSort.favourite,
icon: Icon(Icons.star),
label: Text('Star'),
),
],
selected: {_sort},
onSelectionChanged: (selected) {
setState(() {
_sort = selected.first;
_future = _load();
});
_load();
},
),
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: 'Favourites',
isSelected: _favouritesOnly,
onPressed: () {
setState(() {
_favouritesOnly = !_favouritesOnly;
_future = _load();
});
},
icon: Icon(
_favouritesOnly ? Icons.star : Icons.star_outline,
),
if (_nameIssues.isNotEmpty && !_editing)
ProductNameConflictBanner(
issues: _nameIssues,
onTap: _resolveNameIssues,
),
Expanded(child: _buildList(theme, stores, products)),
],
),
),
Expanded(
child: FutureBuilder<List<Product>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
);
}
Widget _buildList(
ThemeData theme,
List<Supermarket> stores,
List<Product> products,
) {
if (_error != null && _products == null) {
return Center(
child: Text(
'Could not load products.',
@@ -175,15 +312,13 @@ class _ProductsScreenState extends State<ProductsScreen> {
),
);
}
if (!snapshot.hasData) {
if (_products == null) {
return const Center(child: CircularProgressIndicator());
}
final products = _filter(snapshot.data!);
if (snapshot.data!.isEmpty) {
if (_products!.isEmpty) {
return Center(
child: Text(
_favouritesOnly
_sort == ProductSort.favourite
? 'Star products to keep them as favourites.'
: 'Confirmed receipt items will show up here.',
textAlign: TextAlign.center,
@@ -207,6 +342,7 @@ class _ProductsScreenState extends State<ProductsScreen> {
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final product = products[index];
final hasIssue = _issueProductIds.contains(product.id);
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
@@ -214,11 +350,59 @@ class _ProductsScreenState extends State<ProductsScreen> {
horizontal: 8,
vertical: 0,
),
leading: ProductImage(
minLeadingWidth: _editing ? 72 : 36,
leading: _editing
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Delete',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
onPressed: () => _removeProduct(product),
icon: const Icon(Icons.delete_outline),
),
IconButton(
tooltip: product.favourite
? 'Remove favourite'
: 'Favourite',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
onPressed: () => _toggleFavourite(product),
icon: Icon(
product.favourite ? Icons.star : Icons.star_outline,
),
),
],
)
: Stack(
clipBehavior: Clip.none,
children: [
ProductImage(
name: product.name,
path: product.imagePath,
size: 36,
),
if (hasIssue && !_editing)
Positioned(
right: -2,
top: -2,
child: ProductNameConflictMarker(
compact: true,
onTap: () =>
_resolveNameIssues(focusProductId: product.id),
),
),
],
),
title: Text(
product.name,
maxLines: 1,
@@ -237,19 +421,25 @@ class _ProductsScreenState extends State<ProductsScreen> {
),
),
),
trailing: Text(
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (hasIssue && !_editing)
Padding(
padding: const EdgeInsets.only(right: 4),
child: ProductNameConflictMarker(
onTap: () => _resolveNameIssues(focusProductId: product.id),
),
),
Text(
formatEuro(product.lastPrice),
style: theme.textTheme.titleMedium,
),
onTap: () => _openProduct(product),
);
},
);
},
),
),
],
),
onTap: _editing ? null : () => _openProduct(product),
);
},
);
}
}
+44
View File
@@ -0,0 +1,44 @@
/// User profile placeholder. Sign-in will be added later.
library;
import 'package:flutter/material.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: ListView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 32),
children: [
CircleAvatar(
radius: 40,
backgroundColor: scheme.secondaryContainer,
child: Icon(
Icons.person_outline,
size: 40,
color: scheme.onSecondaryContainer,
),
),
const SizedBox(height: 16),
Text(
'Guest',
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Sign in, cloud sync, and account settings will show up here later.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
],
),
);
}
}
+307 -137
View File
@@ -1,6 +1,8 @@
/// Editable list of parsed receipt lines before they are saved as a shop trip.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'models/line_item.dart';
@@ -9,7 +11,11 @@ import 'services/product_repository.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'utils/supermarkets.dart';
import 'widgets/discounted_price.dart';
import 'widgets/item_edit_sheet.dart';
import 'widgets/receipt_highlight_overlay.dart';
import 'widgets/store_tag.dart';
import 'widgets/tag_editor.dart';
class ReviewScreen extends StatefulWidget {
const ReviewScreen({
@@ -19,6 +25,10 @@ class ReviewScreen extends StatefulWidget {
required this.repository,
this.imagePath,
this.supermarketNames = kSupermarkets,
this.needsReview = false,
this.detectedStore,
this.ocrImageWidth,
this.ocrImageHeight,
});
final List<LineItem> items;
@@ -26,6 +36,10 @@ class ReviewScreen extends StatefulWidget {
final ProductRepository repository;
final String? imagePath;
final List<String> supermarketNames;
final bool needsReview;
final String? detectedStore;
final double? ocrImageWidth;
final double? ocrImageHeight;
@override
State<ReviewScreen> createState() => _ReviewScreenState();
@@ -37,6 +51,11 @@ class _ReviewScreenState extends State<ReviewScreen> {
String? _supermarket;
String? _customStore;
bool _saving = false;
final _tripName = TextEditingController();
List<String> _tags = [];
List<String> _tagSuggestions = [];
String? _highlightedItemId;
bool _showTripExtras = false;
@override
void initState() {
@@ -49,7 +68,21 @@ class _ReviewScreenState extends State<ReviewScreen> {
);
if (detected != null) {
_supermarket = detected;
} else if (widget.detectedStore != null) {
_supermarket = widget.detectedStore;
}
widget.repository
.getUsedShopTags()
.then((tags) {
if (mounted) setState(() => _tagSuggestions = tags);
})
.catchError((_) {});
}
@override
void dispose() {
_tripName.dispose();
super.dispose();
}
String? get _resolvedSupermarket {
@@ -62,13 +95,13 @@ class _ReviewScreenState extends State<ReviewScreen> {
return _supermarket;
}
bool get _hasReceipt {
final path = widget.imagePath;
return path != null && path.isNotEmpty && File(path).existsSync();
}
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(() {
@@ -77,9 +110,20 @@ class _ReviewScreenState extends State<ReviewScreen> {
} else {
final index = _items.indexWhere((item) => item.id == existing.id);
if (index >= 0) {
_items[index] = result;
_items[index] = result.copyWith(
sourceBounds: existing.sourceBounds,
needsManualReview: false,
);
}
}
_highlightedItemId = null;
});
}
void _toggleHighlight(LineItem item) {
if (item.sourceBounds == null || !_hasReceipt) return;
setState(() {
_highlightedItemId = _highlightedItemId == item.id ? null : item.id;
});
}
@@ -94,12 +138,102 @@ class _ReviewScreenState extends State<ReviewScreen> {
setState(() => _shoppedAt = picked);
}
Future<void> _openReceiptPanel() async {
final path = widget.imagePath;
if (path == null || path.isEmpty) return;
final file = File(path);
if (!file.existsSync()) return;
final highlighted = _highlightedItemId == null
? null
: _items.cast<LineItem?>().firstWhere(
(item) => item?.id == _highlightedItemId,
orElse: () => null,
);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Receipt', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
if (highlighted?.sourceBounds != null)
ReceiptHighlightOverlay(
imagePath: path,
bounds: highlighted!.sourceBounds!,
maxHeight: 360,
)
else
GestureDetector(
onTap: () async {
await showDialog<void>(
context: context,
builder: (ctx) => Dialog(
insetPadding: const EdgeInsets.all(16),
child: InteractiveViewer(
child: Image.file(file, fit: BoxFit.contain),
),
),
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(file, fit: BoxFit.contain, height: 360),
),
),
],
),
),
);
},
);
}
Future<void> _openOcrPanel() async {
if (widget.rawText.isEmpty) return;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Raw OCR text', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.55,
),
child: SingleChildScrollView(
child: SelectableText(widget.rawText),
),
),
],
),
),
);
},
);
}
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) {
@@ -116,6 +250,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
shoppedAt: _shoppedAt,
items: _items,
receiptImagePath: widget.imagePath,
name: _tripName.text,
tags: _tags,
);
if (!mounted) return;
Navigator.of(context).pop(true);
@@ -140,9 +276,23 @@ class _ReviewScreenState extends State<ReviewScreen> {
if (_supermarket != null && !names.contains(_supermarket)) {
names.add(_supermarket!);
}
final flaggedCount =
_items.where((item) => item.needsManualReview).length;
final reviewMessage = flaggedCount > 0
? 'Some lines could not be parsed confidently — '
'${flaggedCount == 1 ? '1 item is' : '$flaggedCount items are'} '
'marked below. Check them against the receipt before saving.'
: 'Item count or totals do not match the receipt. '
'Check the items below before saving.';
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 +300,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(
reviewMessage,
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,38 +341,85 @@ 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,
),
],
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.calendar_today_outlined),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
_showTripExtras
? Icons.expand_less
: Icons.edit_note_outlined,
),
tooltip: _showTripExtras
? 'Hide trip name & tags'
: 'Trip name & tags',
onPressed: _saving
? null
: () => setState(
() => _showTripExtras = !_showTripExtras,
),
),
const Icon(Icons.calendar_today_outlined),
],
),
title: Text(formatDate(_shoppedAt)),
subtitle: const Text('Shopping date'),
onTap: _saving ? null : _pickDate,
),
if (_showTripExtras) ...[
const SizedBox(height: 8),
TextField(
controller: _tripName,
enabled: !_saving,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Trip name',
hintText: 'Optional, e.g. Weekly shop',
),
),
const SizedBox(height: 12),
TagEditor(
tags: _tags,
suggestions: _tagSuggestions,
enabled: !_saving,
onChanged: (tags) => setState(() => _tags = tags),
),
const SizedBox(height: 8),
],
],
),
),
Expanded(
child: _items.isEmpty
? Center(
child: CustomScrollView(
slivers: [
if (_items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: 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,
),
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverList.separated(
itemCount: _items.length,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final item = _items[index];
final flagged = item.needsManualReview;
final onReceipt = _highlightedItemId == item.id;
return Dismissible(
key: ValueKey(item.id),
direction: DismissDirection.endToStart,
@@ -230,35 +441,80 @@ class _ReviewScreenState extends State<ReviewScreen> {
});
},
child: ListTile(
title: Text(item.name),
subtitle: item.quantity == null
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: flagged
? BorderSide(
color: theme.colorScheme.error
.withValues(alpha: 0.55),
)
: BorderSide.none,
),
tileColor: flagged
? theme.colorScheme.errorContainer
.withValues(alpha: 0.35)
: onReceipt
? theme.colorScheme.primaryContainer
.withValues(alpha: 0.35)
: null,
leading: item.sourceBounds != null && _hasReceipt
? IconButton(
icon: Icon(
onReceipt
? Icons.visibility
: Icons.visibility_outlined,
size: 20,
),
tooltip: 'Show on receipt',
onPressed: _saving
? null
: Text(
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
),
trailing: Text(
formatEuro(item.price),
style: theme.textTheme.titleMedium,
),
: () => _toggleHighlight(item),
)
: flagged
? Icon(
Icons.flag_outlined,
color: theme.colorScheme.error,
size: 20,
)
: null,
title: Text(item.name),
subtitle: _itemSubtitle(item, flagged: flagged),
trailing: DiscountedPrice(item: item),
onTap: _saving ? null : () => _edit(existing: item),
),
);
},
),
),
if (widget.rawText.isNotEmpty)
ExpansionTile(
title: const Text('Raw OCR text'),
],
),
),
if (_hasReceipt || widget.rawText.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
child: Row(
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 180),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SelectableText(widget.rawText),
if (_hasReceipt)
Expanded(
child: OutlinedButton.icon(
onPressed: _saving ? null : _openReceiptPanel,
icon: const Icon(Icons.receipt_long_outlined, size: 18),
label: const Text('Receipt'),
),
),
if (_hasReceipt && widget.rawText.isNotEmpty)
const SizedBox(width: 8),
if (widget.rawText.isNotEmpty)
Expanded(
child: OutlinedButton.icon(
onPressed: _saving ? null : _openOcrPanel,
icon: const Icon(Icons.text_snippet_outlined, size: 18),
label: const Text('OCR text'),
),
),
],
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
@@ -274,9 +530,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)}',
),
),
],
@@ -286,99 +540,15 @@ 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, {bool flagged = false}) {
final parts = <String>[];
if (flagged) parts.add('Needs review');
if (item.quantity != null) {
parts.add('${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}');
}
if (parts.isEmpty) return null;
return Text(parts.join(' · '));
}
}
+101 -30
View File
@@ -2,26 +2,26 @@
library;
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
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';
import 'services/receipt_parser.dart';
import 'utils/constants.dart';
import 'utils/supermarkets.dart';
import 'widgets/profile_button.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,31 +51,30 @@ 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.
} on MissingPluginException {
// Widget tests and hosts without the image_picker plugin.
}
}
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 {
try {
final data = await rootBundle.load(kSampleReceiptAsset);
final file = File(
'${Directory.systemTemp.path}/receipity_sample_receipt.jpeg',
'${Directory.systemTemp.path}/groci_sample_receipt.jpeg',
);
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 +83,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;
@@ -93,20 +103,37 @@ class _ScanScreenState extends State<ScanScreen> {
});
final recognizer = TextRecognizer(script: TextRecognitionScript.latin);
RecognizedText? recognized;
double imageWidth = 0;
double imageHeight = 0;
try {
final bytes = await File(imagePath).readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
imageWidth = frame.image.width.toDouble();
imageHeight = frame.image.height.toDouble();
frame.image.dispose();
final inputImage = InputImage.fromFilePath(imagePath);
final recognized = await recognizer.processImage(inputImage);
recognized = await recognizer.processImage(inputImage);
if (!mounted) return;
setState(() {
_extractedText = recognized.text.trim();
_extractedText = recognized!.text.trim();
if (_extractedText.isEmpty) {
_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();
@@ -115,13 +142,48 @@ class _ScanScreenState extends State<ScanScreen> {
}
}
if (mounted && _extractedText.isNotEmpty) {
await _openReview(_extractedText);
if (mounted && _extractedText.isNotEmpty && recognized != null) {
await _openReview(
_extractedText,
imagePath: imagePath,
recognized: recognized,
imageWidth: imageWidth,
imageHeight: imageHeight,
);
}
}
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,
RecognizedText? recognized,
double? imageWidth,
double? imageHeight,
}) async {
final parsedItems = items;
ReceiptParseResult? parsed;
if (parsedItems == null) {
if (recognized != null && imageWidth != null && imageHeight != null) {
parsed = ReceiptParser.parseRecognizedText(
recognized,
imageWidth: imageWidth,
imageHeight: imageHeight,
);
} else {
parsed = ReceiptParser.parseReceipt(rawText);
}
}
if (!mounted) return;
final names =
SettingsScope.maybeOf(context)?.supermarkets.map((s) => s.name) ??
@@ -129,11 +191,15 @@ 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,
ocrImageWidth: parsed?.ocrImageWidth,
ocrImageHeight: parsed?.ocrImageHeight,
),
),
);
@@ -149,7 +215,7 @@ class _ScanScreenState extends State<ScanScreen> {
return Scaffold(
appBar: AppBar(
title: const Text(kAppName),
actions: const [SettingsButton()],
actions: const [ProfileButton(), SettingsButton()],
),
body: SafeArea(
child: Padding(
@@ -169,6 +235,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 +257,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 +294,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,
),
+12 -1
View File
@@ -68,6 +68,15 @@ class AppSettings extends ChangeNotifier {
catalogGeneration++;
notifyListeners();
}
Future<void> wipeAllData() async {
await repository.wipeAllData();
darkMode = false;
supermarkets = List<Supermarket>.from(kDefaultSupermarkets);
catalogGeneration++;
notifyListeners();
await load();
}
}
class SettingsScope extends InheritedNotifier<AppSettings> {
@@ -84,6 +93,8 @@ class SettingsScope extends InheritedNotifier<AppSettings> {
}
static AppSettings? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<SettingsScope>()?.notifier;
return context
.dependOnInheritedWidgetOfExactType<SettingsScope>()
?.notifier;
}
}
+20 -4
View File
@@ -3,7 +3,10 @@ library;
import 'dart:convert';
const int kBackupVersion = 1;
const int kBackupVersion = 3;
const String kBackupFormat = 'groci-backup';
const String kLegacyBackupFormat = 'receipity-backup';
class BackupDocument {
const BackupDocument({
@@ -14,7 +17,10 @@ class BackupDocument {
required this.products,
required this.shops,
required this.shopItems,
required this.shoppingLists,
required this.shoppingListItems,
required this.images,
this.shopTags = const [],
});
final int version;
@@ -24,10 +30,13 @@ 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?>> shopTags;
final List<Map<String, Object?>> shoppingLists;
final List<Map<String, Object?>> shoppingListItems;
final Map<String, String> images;
Map<String, Object?> toJson() => {
'format': 'receipity-backup',
'format': kBackupFormat,
'version': version,
'exportedAt': exportedAt,
'darkMode': darkMode,
@@ -35,6 +44,9 @@ class BackupDocument {
'products': products,
'shops': shops,
'shopItems': shopItems,
'shopTags': shopTags,
'shoppingLists': shoppingLists,
'shoppingListItems': shoppingListItems,
'images': images,
};
@@ -45,8 +57,9 @@ class BackupDocument {
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Backup is not a JSON object.');
}
if (decoded['format'] != 'receipity-backup') {
throw const FormatException('This file is not a Receipity backup.');
final format = decoded['format'];
if (format != kBackupFormat && format != kLegacyBackupFormat) {
throw const FormatException('This file is not a Groci backup.');
}
final version = decoded['version'];
if (version is! int || version < 1 || version > kBackupVersion) {
@@ -60,6 +73,9 @@ class BackupDocument {
products: _maps(decoded['products']),
shops: _maps(decoded['shops']),
shopItems: _maps(decoded['shopItems']),
shopTags: _maps(decoded['shopTags']),
shoppingLists: _maps(decoded['shoppingLists']),
shoppingListItems: _maps(decoded['shoppingListItems']),
images: _strings(decoded['images']),
);
}
+18 -11
View File
@@ -1,4 +1,4 @@
/// Build, share, and restore a Receipity JSON backup.
/// Build, share, and restore a Groci JSON backup.
library;
import 'dart:convert';
@@ -57,6 +57,9 @@ class BackupService {
products: rewrittenProducts,
shops: rewrittenShops,
shopItems: await repository.dumpTable('shop_items'),
shopTags: await repository.dumpTable('shop_tags'),
shoppingLists: await repository.dumpTable('shopping_lists'),
shoppingListItems: await repository.dumpTable('shopping_list_items'),
images: images,
);
}
@@ -65,11 +68,11 @@ class BackupService {
final document = await buildBackup();
final bytes = Uint8List.fromList(utf8.encode(document.encode()));
final stamp = DateTime.now().toIso8601String().split('T').first;
final fileName = 'receipity-backup-$stamp.json';
final fileName = 'groci-backup-$stamp.json';
try {
final saved = await FilePicker.saveFile(
dialogTitle: 'Save Receipity backup',
dialogTitle: 'Save Groci backup',
fileName: fileName,
bytes: bytes,
mimeType: 'application/json',
@@ -87,8 +90,8 @@ class BackupService {
final result = await SharePlus.instance.share(
ShareParams(
files: [XFile(file.path, mimeType: 'application/json')],
subject: 'Receipity backup',
text: 'Receipity backup $stamp',
subject: 'Groci backup',
text: 'Groci backup $stamp',
),
);
return result.status != ShareResultStatus.dismissed;
@@ -96,7 +99,7 @@ class BackupService {
Future<bool> importBackup() async {
final picked = await FilePicker.pickFile(
dialogTitle: 'Import Receipity backup',
dialogTitle: 'Import Groci backup',
type: FileType.custom,
allowedExtensions: const ['json'],
);
@@ -114,12 +117,16 @@ 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) {
copy['normalized_name'] = ProductRepository.normalizeName(
copy['name']! as String,
supermarket: copy['supermarket'] as String?,
);
}
products.add(copy);
@@ -141,14 +148,14 @@ class BackupService {
products: products,
shops: shops,
shopItems: document.shopItems,
shopTags: document.shopTags,
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;
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
/// Extract positioned OCR elements from ML Kit structured output.
library;
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import '../models/ocr_element.dart';
class ReceiptOcr {
ReceiptOcr._();
/// Reads blocks → lines → elements. Falls back to line-level boxes when a
/// line has no element children.
static OcrDocument fromRecognizedText(
RecognizedText recognized, {
required double imageWidth,
required double imageHeight,
}) {
final elements = <OcrElement>[];
final lines = <OcrLine>[];
var maxRight = 0.0;
var maxBottom = 0.0;
for (final block in recognized.blocks) {
for (final line in block.lines) {
final lineElements = <OcrElement>[];
if (line.elements.isNotEmpty) {
for (final element in line.elements) {
final box = element.boundingBox;
lineElements.add(
OcrElement(
text: element.text,
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
),
);
if (box.right > maxRight) maxRight = box.right;
if (box.bottom > maxBottom) maxBottom = box.bottom;
}
} else {
final box = line.boundingBox;
lineElements.add(
OcrElement(
text: line.text,
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
),
);
if (box.right > maxRight) maxRight = box.right;
if (box.bottom > maxBottom) maxBottom = box.bottom;
}
elements.addAll(lineElements);
final box = line.boundingBox;
lines.add(
OcrLine(
text: line.text,
elements: lineElements,
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
),
);
}
}
final width = imageWidth > 0 ? imageWidth : maxRight;
final height = imageHeight > 0 ? imageHeight : maxBottom;
return OcrDocument(
elements: elements,
lines: lines,
imageWidth: width,
imageHeight: height,
flattenedText: recognized.text.trim(),
);
}
}
+205
View File
@@ -0,0 +1,205 @@
/// 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,
this.rowClusterHeightFactor = 0.55,
this.priceColumnWidthFraction = 0.25,
});
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;
final double rowClusterHeightFactor;
final double priceColumnWidthFraction;
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'),
rowClusterHeightFactor: _spatialDouble(
json,
'row_cluster_height_factor',
fallback: 0.55,
),
priceColumnWidthFraction: _spatialDouble(
json,
'price_column_width_fraction',
fallback: 0.25,
),
);
}
static double _spatialDouble(
Map<String, dynamic> json,
String key, {
required double fallback,
}) {
final spatial = json['spatial_parsing'];
if (spatial is! Map) return fallback;
final value = spatial[key];
if (value is num && value > 0) return value.toDouble();
return fallback;
}
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>,
);
}
}
+62 -180
View File
@@ -1,204 +1,86 @@
/// Turns raw OCR text into [LineItem]s using line-end prices and a keyword filter.
/// Receipt parsing entry point: spatial OCR pipeline with text fallback.
library;
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import '../models/line_item.dart';
import '../utils/money.dart';
/// Headers, payments, and totals — never treated as products.
const Set<String> kHeaderKeywords = {
'TOTAL',
'SUBTOTAL',
'TOTAAL',
'TAX',
'VAT',
'BTW',
'CASH',
'CARD',
'CHANGE',
'VISA',
'MASTERCARD',
'MAESTRO',
'PIN',
'DEBIT',
'CREDIT',
'OMSCHRIJVING',
'BEDRAG',
'AMOUNT',
'DESCRIPTION',
'THANK',
'BEDANKT',
'WISSELGELD',
'BETAALD',
'BETAAL',
'CONTANT',
'KASSA',
'RECEIPT',
'WWW',
'HTTP',
};
/// Discount / promo rows. Paired with a price, then dropped.
const Set<String> kDropKeywords = {
'DISCOUNT',
'KORTING',
'ACTIE',
};
final _separatorPattern = RegExp(r'^[=\-_.:\s]+$');
final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b');
final _phonePattern = RegExp(r'^\d{8,}$');
final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}$");
final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false);
class _QtyLine {
const _QtyLine({required this.count, required this.unitPrice, this.total});
final int count;
final double unitPrice;
final double? total;
double get lineTotal => total ?? roundMoney(count * unitPrice);
}
import '../models/ocr_element.dart';
import 'receipt_ocr.dart';
import 'receipt_parse_config.dart';
export 'receipt_parse_config.dart' show loadReceiptParseConfig;
export 'receipt_text_parser.dart' show ReceiptParseResult;
import 'receipt_spatial_parser.dart';
import 'receipt_text_parser.dart';
class ReceiptParser {
ReceiptParser._();
static int _nextId = 0;
static Future<void> loadConfig() => loadReceiptParseConfig();
static String _id() => 'item-${++_nextId}';
static List<LineItem> parse(String rawText) => parseReceipt(rawText).items;
/// Parses [rawText] into product rows.
///
/// Prefers same-line `name … price` (with a following `2 x 1.50` line when
/// the name has no price). If OCR split the receipt into a name column and a
/// price column, falls back to pairing those columns.
static List<LineItem> parse(String rawText) {
final lines = rawText
.split('\n')
.map((line) => line.trim())
.where((line) => line.isNotEmpty)
.toList();
/// Fallback when only flattened OCR text is available (tests, re-parse).
static ReceiptParseResult parseReceipt(String rawText) =>
ReceiptTextParser.parseReceipt(rawText);
final inline = _parseInline(lines);
final columns = _parseColumns(lines);
final chosen = columns.length > inline.length ? columns : inline;
return chosen.where(_keepItem).toList();
}
/// Primary path: positioned ML Kit elements → row clustering → lookahead.
static ReceiptParseResult parseReceiptFromOcr(OcrDocument document) =>
_parseDocument(document);
static List<LineItem> _parseInline(List<String> lines) {
final items = <LineItem>[];
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
if (_shouldSkipLine(line) || isPriceOnly(line)) continue;
final qty = _parseQty(line);
if (qty != null) continue;
final trailing = trailingPricePattern.firstMatch(line);
if (trailing != null) {
final name = trailing.group(1)!.trim();
final price = tryParsePrice(trailing.group(2)!);
if (name.isEmpty || price == null || _shouldSkipLine(name)) continue;
items.add(LineItem(id: _id(), name: name, price: price));
continue;
}
if (i + 1 >= lines.length) continue;
final nextQty = _parseQty(lines[i + 1]);
if (nextQty == null) continue;
items.add(
LineItem(
id: _id(),
name: line,
price: nextQty.lineTotal,
quantity: nextQty.count,
unitPrice: nextQty.unitPrice,
),
static ReceiptParseResult parseRecognizedText(
RecognizedText recognized, {
required double imageWidth,
required double imageHeight,
}) {
final document = ReceiptOcr.fromRecognizedText(
recognized,
imageWidth: imageWidth,
imageHeight: imageHeight,
);
i++;
return _parseDocument(document);
}
return items;
static ReceiptParseResult _parseDocument(OcrDocument document) {
if (ReceiptTextParser.looksLikeColumnOcr(document.flattenedText)) {
return _withImageDimensions(
ReceiptTextParser.parseReceipt(document.flattenedText),
document,
);
}
static List<LineItem> _parseColumns(List<String> lines) {
final names = <({String name, int? quantity, double? unitPrice})>[];
final prices = <double>[];
for (final line in lines) {
if (isPriceOnly(line)) {
final price = tryParsePrice(line);
if (price != null) prices.add(price);
continue;
final spatial = ReceiptSpatialParser.parse(document);
if (_shouldFallbackToText(spatial)) {
return _withImageDimensions(
ReceiptTextParser.parseReceipt(document.flattenedText),
document,
);
}
if (_shouldSkipLine(line)) continue;
final qty = _parseQty(line);
if (qty != null) {
if (names.isNotEmpty) {
final last = names.removeLast();
names.add((
name: last.name,
quantity: qty.count,
unitPrice: qty.unitPrice,
));
}
continue;
return spatial;
}
names.add((name: line, quantity: null, unitPrice: null));
}
final count = names.length < prices.length ? names.length : prices.length;
return [
for (var i = 0; i < count; i++)
LineItem(
id: _id(),
name: names[i].name,
price: prices[i],
quantity: names[i].quantity,
unitPrice: names[i].unitPrice,
),
];
}
static bool _keepItem(LineItem item) {
if (item.price < 0) return false;
if (item.name.trim().isEmpty) return false;
return !_containsKeyword(item.name, kDropKeywords);
}
static bool _shouldSkipLine(String line) {
if (_separatorPattern.hasMatch(line)) return true;
if (_urlPattern.hasMatch(line)) return true;
if (_postalPattern.hasMatch(line)) return true;
if (_phonePattern.hasMatch(line.replaceAll(RegExp(r'\s'), ''))) {
return true;
}
if (_streetPattern.hasMatch(line)) return true;
if (_containsKeyword(line, kHeaderKeywords)) return true;
static bool _shouldFallbackToText(ReceiptParseResult spatial) {
if (spatial.items.isEmpty) return true;
final flagged =
spatial.items.where((item) => item.needsManualReview).length;
if (flagged > 0) return true;
if (spatial.items.any((item) => item.price <= 0)) return true;
if (spatial.receiptTotal != null && !spatial.validationPassed) return true;
return false;
}
static bool _containsKeyword(String line, Set<String> keywords) {
final upper = line.toUpperCase();
for (final keyword in keywords) {
if (RegExp('\\b${RegExp.escape(keyword)}\\b').hasMatch(upper)) {
return true;
}
}
return false;
}
static _QtyLine? _parseQty(String line) {
final match = quantityPattern.firstMatch(line.trim());
if (match == null) return null;
final count = int.tryParse(match.group(1)!);
final unit = tryParsePrice(match.group(2)!);
if (count == null || count < 1 || unit == null) return null;
final totalRaw = match.group(3);
final total = totalRaw == null ? null : tryParsePrice(totalRaw);
return _QtyLine(count: count, unitPrice: unit, total: total);
static ReceiptParseResult _withImageDimensions(
ReceiptParseResult result,
OcrDocument document,
) {
return ReceiptParseResult(
items: result.items,
store: result.store,
receiptTotal: result.receiptTotal,
totalDiscount: result.totalDiscount,
validationPassed: result.validationPassed,
expectedItemCount: result.expectedItemCount,
ocrImageWidth: document.imageWidth,
ocrImageHeight: document.imageHeight,
);
}
}
+362
View File
@@ -0,0 +1,362 @@
/// Cluster OCR elements into rows and classify using column positions.
library;
import '../models/ocr_element.dart';
import '../models/receipt_bounds.dart';
import '../models/receipt_row.dart';
import '../utils/money.dart';
import 'receipt_parse_config.dart';
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);
/// Pure quantity row — checked before any other row classification.
final _pureQuantityRow = RegExp(
r'^\s*(\d+)\s*[xX×]\s*(\d+[,.]\d{2})\s*(PER\s+STUK|PER\s+KG|ST\.?|STUKS?)?\s*$',
caseSensitive: false,
);
final _priceToken = RegExp(r'^-?\d+[,.]\d{2}$');
class ReceiptRowBuilder {
ReceiptRowBuilder(this.config);
final ReceiptParseConfig config;
List<ReceiptRow> buildRows(OcrDocument document) {
if (document.elements.isEmpty) return const [];
final contentWidth = _contentWidth(document.elements);
final contentLeft = _contentLeft(document.elements);
final priceColumnStart =
contentLeft + contentWidth * (1 - config.priceColumnWidthFraction);
// Prefer ML Kit line boundaries — element Y-clustering merges adjacent lines.
if (document.lines.isNotEmpty) {
return [
for (final line in document.lines)
_classifyRow(
line.elements,
priceColumnStart: priceColumnStart,
imageWidth: document.imageWidth,
imageHeight: document.imageHeight,
rowText: line.text,
),
];
}
final rowClusterThreshold = _rowClusterThreshold(document.elements);
final clustered = _clusterByY(document.elements, rowClusterThreshold);
return [
for (final rowElements in clustered)
_classifyRow(
rowElements,
priceColumnStart: priceColumnStart,
imageWidth: document.imageWidth,
imageHeight: document.imageHeight,
),
];
}
double _rowClusterThreshold(List<OcrElement> elements) {
final heights = [
for (final element in elements)
if (element.height > 0) element.height,
]..sort();
if (heights.isEmpty) return 12;
final median = heights[heights.length ~/ 2];
return median * config.rowClusterHeightFactor;
}
List<List<OcrElement>> _clusterByY(
List<OcrElement> elements,
double threshold,
) {
final sorted = [...elements]..sort((a, b) {
final dy = a.centerY.compareTo(b.centerY);
if (dy != 0) return dy;
return a.left.compareTo(b.left);
});
final rows = <List<OcrElement>>[];
List<OcrElement>? current;
double? anchorY;
for (final element in sorted) {
if (current == null) {
current = [element];
anchorY = element.centerY;
continue;
}
if ((element.centerY - anchorY!).abs() <= threshold) {
current.add(element);
} else {
current.sort((a, b) => a.left.compareTo(b.left));
rows.add(current);
current = [element];
anchorY = element.centerY;
}
}
if (current != null) {
current.sort((a, b) => a.left.compareTo(b.left));
rows.add(current);
}
return rows;
}
double _contentWidth(List<OcrElement> elements) {
var minLeft = double.infinity;
var maxRight = 0.0;
for (final element in elements) {
if (element.left < minLeft) minLeft = element.left;
if (element.right > maxRight) maxRight = element.right;
}
if (minLeft == double.infinity) return 1;
return (maxRight - minLeft).clamp(1, double.infinity);
}
double _contentLeft(List<OcrElement> elements) {
var minLeft = double.infinity;
for (final element in elements) {
if (element.left < minLeft) minLeft = element.left;
}
return minLeft == double.infinity ? 0 : minLeft;
}
ReceiptRow _classifyRow(
List<OcrElement> rowElements, {
required double priceColumnStart,
required double imageWidth,
required double imageHeight,
String? rowText,
}) {
final text = rowText ?? rowElements.map((e) => e.text).join(' ').trim();
final normalized = _preprocess(text);
final bounds = _rowBounds(rowElements, imageWidth, imageHeight);
// 4. Quantity modifier — entire row, checked first.
final pureQty = _pureQuantityRow.firstMatch(normalized);
if (pureQty != null) {
final count = int.tryParse(pureQty.group(1)!);
final unit = tryParsePrice(pureQty.group(2)!);
return ReceiptRow(
kind: ReceiptRowKind.quantityModifier,
text: normalized,
nameText: '',
quantity: count,
unitPrice: unit,
lineTotal: count != null && unit != null
? roundMoney(count * unit)
: null,
elements: rowElements,
bounds: bounds,
);
}
if (_isIgnorable(normalized)) {
return ReceiptRow(
kind: ReceiptRowKind.skip,
text: normalized,
nameText: '',
elements: rowElements,
bounds: bounds,
);
}
if (config.matchesAny(normalized, config.totalDiscountKeywords)) {
return ReceiptRow(
kind: ReceiptRowKind.totalDiscount,
text: normalized,
nameText: normalized,
price: _rowPrice(rowElements, priceColumnStart) ??
_trailingPrice(normalized),
elements: rowElements,
bounds: bounds,
);
}
if (config.matchesAny(normalized, config.taxInclusiveTotalKeywords) ||
config.matchesAny(normalized, config.subtotalKeywords)) {
return ReceiptRow(
kind: ReceiptRowKind.skip,
text: normalized,
nameText: '',
elements: rowElements,
bounds: bounds,
);
}
if (config.matchesAny(normalized, config.grandTotalKeywords)) {
return ReceiptRow(
kind: ReceiptRowKind.grandTotal,
text: normalized,
nameText: normalized,
price: _rowPrice(rowElements, priceColumnStart) ??
_trailingPrice(normalized),
elements: rowElements,
bounds: bounds,
);
}
final isDiscount =
config.matchesAny(normalized, config.discountKeywords) ||
config.negativeAmount.hasMatch(normalized);
if (isDiscount) {
return ReceiptRow(
kind: ReceiptRowKind.discount,
text: normalized,
nameText: normalized,
price: _rowPrice(rowElements, priceColumnStart) ??
_trailingPrice(normalized) ??
tryParsePrice(normalized),
elements: rowElements,
bounds: bounds,
);
}
final price = _rowPrice(rowElements, priceColumnStart);
final inlineQty = config.quantityInline.firstMatch(normalized);
var nameText = _nameFromElements(rowElements, priceColumnStart);
if (price != null) {
if (inlineQty != null) {
nameText = normalized.substring(0, inlineQty.start).trim();
final count = int.tryParse(inlineQty.group(1)!);
final unit = tryParsePrice(inlineQty.group(2)!);
if (nameText.isNotEmpty && count != null && unit != null) {
return ReceiptRow(
kind: ReceiptRowKind.item,
text: normalized,
nameText: nameText,
price: price,
quantity: count,
unitPrice: unit,
lineTotal: price,
elements: rowElements,
bounds: bounds,
);
}
}
if (nameText.isEmpty || _isIgnorable(nameText)) {
return ReceiptRow(
kind: ReceiptRowKind.skip,
text: normalized,
nameText: nameText,
elements: rowElements,
bounds: bounds,
);
}
return ReceiptRow(
kind: ReceiptRowKind.item,
text: normalized,
nameText: nameText,
price: price,
lineTotal: price,
elements: rowElements,
bounds: bounds,
);
}
if (nameText.isEmpty) nameText = normalized;
return ReceiptRow(
kind: ReceiptRowKind.nameOnly,
text: normalized,
nameText: nameText,
elements: rowElements,
bounds: bounds,
);
}
double? _rowPrice(List<OcrElement> elements, double priceColumnStart) {
double? best;
var bestX = -1.0;
for (final element in elements) {
if (element.centerX < priceColumnStart) continue;
final token = element.text.trim().replaceAll(
config.trailingCategoryLetter,
'',
);
if (!_priceToken.hasMatch(token)) continue;
final value = tryParsePrice(token);
if (value == null) continue;
if (element.centerX >= bestX) {
bestX = element.centerX;
best = value;
}
}
return best;
}
String _nameFromElements(List<OcrElement> elements, double priceColumnStart) {
final parts = <String>[];
for (final element in elements) {
if (element.centerX >= priceColumnStart &&
_priceToken.hasMatch(element.text.trim())) {
continue;
}
final cleaned = element.text
.replaceFirst(config.trailingCategoryLetter, '')
.trim();
if (cleaned.isNotEmpty) parts.add(cleaned);
}
return _preprocess(parts.join(' '));
}
ReceiptBounds? _rowBounds(
List<OcrElement> elements,
double imageWidth,
double imageHeight,
) {
if (elements.isEmpty || imageWidth <= 0 || imageHeight <= 0) return null;
var left = double.infinity;
var top = double.infinity;
var right = 0.0;
var bottom = 0.0;
for (final element in elements) {
if (element.left < left) left = element.left;
if (element.top < top) top = element.top;
if (element.right > right) right = element.right;
if (element.bottom > bottom) bottom = element.bottom;
}
return ReceiptBounds.fromPixelRect(
left: left,
top: top,
right: right,
bottom: bottom,
imageWidth: imageWidth,
imageHeight: imageHeight,
);
}
String _preprocess(String line) =>
line.trim().replaceAll(RegExp(r'\s+'), ' ');
double? _trailingPrice(String line) {
final stripped = line.replaceFirst(config.trailingCategoryLetter, '').trim();
final match = RegExp(r'(-?\d+[.,]\d{2})\s*$').firstMatch(stripped);
if (match == null) return null;
return tryParsePrice(match.group(1)!);
}
bool _isIgnorable(String line) {
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 (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;
}
}
+274
View File
@@ -0,0 +1,274 @@
/// Spatial receipt parser: row clustering, column logic, and lookahead buffer.
library;
import '../models/line_item.dart';
import '../models/ocr_element.dart';
import '../models/receipt_bounds.dart';
import '../models/receipt_row.dart';
import '../utils/money.dart';
import 'receipt_parse_config.dart';
import 'receipt_row_builder.dart';
import 'receipt_text_parser.dart';
class _PendingItem {
_PendingItem({
required this.name,
this.unitPrice,
this.lineTotal,
this.quantity = 1,
this.bounds,
});
String name;
double? unitPrice;
double? lineTotal;
int quantity;
double discount = 0;
ReceiptBounds? bounds;
void mergeBounds(ReceiptBounds? other) {
if (other == null) return;
bounds = bounds == null ? other : bounds!.union(other);
}
}
class ReceiptSpatialParser {
ReceiptSpatialParser._();
static int _nextId = 0;
static String _id() => 'item-${++_nextId}';
static ReceiptParseResult parse(OcrDocument document) {
_nextId = 0;
final config = receiptParseConfig;
final rows = ReceiptRowBuilder(config).buildRows(document);
return _parseRows(
rows,
rawText: document.flattenedText,
imageWidth: document.imageWidth,
imageHeight: document.imageHeight,
);
}
static ReceiptParseResult _parseRows(
List<ReceiptRow> rows, {
required String rawText,
required double imageWidth,
required double imageHeight,
}) {
final config = receiptParseConfig;
final items = <LineItem>[];
_PendingItem? pending;
var receiptTotal = _readTotalFromRows(rows, config);
var totalDiscount = 0.0;
var stop = false;
void emitPending({required bool needsReview}) {
final current = pending;
pending = null;
if (current == null) return;
final total = current.lineTotal ?? current.unitPrice;
if (total == null) {
items.add(
LineItem(
id: _id(),
name: current.name,
price: 0,
sourceBounds: current.bounds,
needsManualReview: true,
),
);
return;
}
items.add(
LineItem(
id: _id(),
name: current.name,
price: roundMoney(total),
quantity: current.quantity == 1 ? null : current.quantity,
unitPrice: current.quantity == 1 ? null : current.unitPrice,
discount: current.discount == 0 ? 0 : roundMoney(current.discount),
sourceBounds: current.bounds,
needsManualReview: needsReview,
),
);
}
for (var i = 0; i < rows.length && !stop; i++) {
final row = rows[i];
switch (row.kind) {
case ReceiptRowKind.skip:
continue;
case ReceiptRowKind.quantityModifier:
if (pending != null &&
row.quantity != null &&
row.unitPrice != null) {
pending!
..quantity = row.quantity!
..unitPrice = row.unitPrice
..lineTotal = row.lineTotal ?? roundMoney(row.quantity! * row.unitPrice!);
pending!.mergeBounds(row.bounds);
emitPending(needsReview: false);
}
continue;
case ReceiptRowKind.nameOnly:
emitPending(needsReview: pending?.lineTotal == null);
pending = _PendingItem(name: row.nameText, bounds: row.bounds);
final next = i + 1 < rows.length ? rows[i + 1] : null;
if (next?.kind == ReceiptRowKind.quantityModifier) {
continue;
}
emitPending(needsReview: true);
continue;
case ReceiptRowKind.item:
emitPending(needsReview: pending?.lineTotal == null);
pending = _PendingItem(
name: row.nameText,
unitPrice: row.unitPrice ?? row.price,
lineTotal: row.lineTotal ?? row.price,
quantity: row.quantity ?? 1,
bounds: row.bounds,
);
if (row.quantity != null) {
pending!.unitPrice = row.unitPrice;
}
final nextItem = i + 1 < rows.length ? rows[i + 1] : null;
if (nextItem?.kind == ReceiptRowKind.quantityModifier) {
continue;
}
emitPending(needsReview: false);
continue;
case ReceiptRowKind.discount:
var amount = row.price;
if (amount == null && i + 1 < rows.length) {
amount = rows[i + 1].price;
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,
);
pending!.mergeBounds(row.bounds);
} else {
totalDiscount += amount;
}
continue;
case ReceiptRowKind.totalDiscount:
var amount = row.price;
if (amount == null && i + 1 < rows.length) {
amount = rows[i + 1].price;
if (amount != null) i++;
}
if (amount != null) {
totalDiscount += amount > 0 ? -amount : amount;
}
continue;
case ReceiptRowKind.grandTotal:
emitPending(needsReview: pending?.lineTotal == null);
if (row.price != null) {
receiptTotal = row.price;
stop = true;
}
continue;
}
}
emitPending(needsReview: pending?.lineTotal == null);
final expectedItemCount = _readExpectedItemCount(rows, config);
return _finalize(
items: items,
rawText: rawText,
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
expectedItemCount: expectedItemCount,
imageWidth: imageWidth,
imageHeight: imageHeight,
);
}
static double? _readTotalFromRows(
List<ReceiptRow> rows,
ReceiptParseConfig config,
) {
for (final row in rows) {
if (row.kind != ReceiptRowKind.grandTotal) continue;
if (config.matchesAny(row.text, [
...config.totalDiscountKeywords,
...config.taxInclusiveTotalKeywords,
...config.subtotalKeywords,
])) {
continue;
}
return row.price;
}
return null;
}
static int? _readExpectedItemCount(
List<ReceiptRow> rows,
ReceiptParseConfig config,
) {
final pattern = RegExp(
r'(\d+)\s*$',
caseSensitive: false,
);
for (final row in rows) {
if (!config.matchesAny(row.text, config.itemCountKeywords)) continue;
final match = pattern.firstMatch(row.text);
if (match != null) {
return int.tryParse(match.group(1)!);
}
}
return null;
}
static ReceiptParseResult _finalize({
required List<LineItem> items,
required String rawText,
double? receiptTotal,
double totalDiscount = 0,
int? expectedItemCount,
required double imageWidth,
required double imageHeight,
}) {
final kept = items
.where(
(item) =>
item.name.trim().isNotEmpty &&
(item.price > 0 || item.needsManualReview),
)
.toList();
final sum = roundMoney(
kept
.where((item) => item.price > 0)
.fold<double>(0, (total, item) => total + item.price),
);
var passed = true;
if (receiptTotal != null) {
passed =
(sum - receiptTotal).abs() <= 0.01 ||
(sum + totalDiscount - receiptTotal).abs() <= 0.01;
}
if (expectedItemCount != null && kept.length != expectedItemCount) {
passed = false;
}
if (kept.any((item) => item.needsManualReview)) {
passed = false;
}
return ReceiptParseResult(
items: kept,
store: ReceiptTextParser.detectStore(rawText),
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
validationPassed: passed,
expectedItemCount: expectedItemCount,
ocrImageWidth: imageWidth,
ocrImageHeight: imageHeight,
);
}
}
+649
View File
@@ -0,0 +1,649 @@
/// Flat text-line receipt parser (fallback when spatial OCR is unavailable).
library;
import '../models/line_item.dart';
import '../utils/money.dart';
import 'receipt_parse_config.dart';
/// 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,
this.expectedItemCount,
this.ocrImageWidth,
this.ocrImageHeight,
});
final List<LineItem> items;
final String? store;
final double? receiptTotal;
final double totalDiscount;
final bool validationPassed;
final int? expectedItemCount;
final double? ocrImageWidth;
final double? ocrImageHeight;
}
class _Qty {
const _Qty({required this.count, required this.unitPrice, this.total});
final int count;
final double unitPrice;
final double? total;
double get lineTotal => total ?? roundMoney(count * unitPrice);
}
class _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 ReceiptTextParser {
ReceiptTextParser._();
static int _nextId = 0;
static String _id() => 'item-${++_nextId}';
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(_preprocess)
.where((line) => line.isNotEmpty)
.toList();
if (_looksLikeColumnOcr(lines, config)) {
return _parseColumns(lines, config, rawText);
}
return _parseSequential(lines, config, rawText);
}
/// True when OCR dumped an entire name column before a price column (Jumbo-style).
static bool looksLikeColumnOcr(String rawText) {
final config = receiptParseConfig;
final lines = rawText
.split('\n')
.map(_preprocess)
.where((line) => line.isNotEmpty)
.toList();
return _looksLikeColumnOcr(lines, config);
}
static String _preprocess(String line) =>
line.trim().replaceAll(RegExp(r'\s+'), ' ');
static String _stripTax(String line, ReceiptParseConfig config) {
return line.replaceFirst(config.trailingCategoryLetter, '').trim();
}
static bool _looksLikeColumnOcr(
List<String> lines,
ReceiptParseConfig config,
) {
var run = 0;
var maxRun = 0;
var prices = 0;
var names = 0;
for (final line in lines) {
final stripped = _stripTax(line, config);
if (isPriceOnly(stripped)) {
prices++;
run++;
if (run > maxRun) maxRun = run;
} else {
run = 0;
if (_parseQty(stripped, config) == null &&
!_isIgnorable(stripped, config) &&
!config.matchesAny(stripped, [
...config.grandTotalKeywords,
...config.subtotalKeywords,
...config.taxInclusiveTotalKeywords,
...config.totalDiscountKeywords,
]) &&
_trailingPrice(stripped, config) == null) {
names++;
}
}
}
if (maxRun >= 4 && prices >= 4 && names >= 4) return true;
// ML Kit sometimes splits a short receipt into names, then prices (2–3 items).
return maxRun >= 2 && prices >= 2 && names >= prices && maxRun >= prices;
}
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));
}
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 (pending != null) {
if (amount < 0) {
pending!.discount += amount;
pending!.lineTotal = roundMoney(
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
);
} else if (pending!.lineTotal == null &&
pending!.unitPrice == null) {
pending!.lineTotal = amount;
flush();
}
}
}
}
flush();
return _result(
items: items,
rawText: rawText,
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
lines: 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 raw in lines) {
final line = _stripTax(raw, config);
if (isPriceOnly(line)) {
final price = tryParsePrice(line);
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;
}
final qty = _parseQty(line, config);
if (qty != null) {
if (names.isNotEmpty) {
final last = names.removeLast();
names.add((
name: last.name,
quantity: qty.count,
unitPrice: qty.unitPrice,
));
}
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;
final pairedNames = names.length > count
? names.sublist(names.length - count)
: names;
final nameOffset = names.length - pairedNames.length;
final items = <LineItem>[
for (var i = 0; i < count; i++)
LineItem(
id: _id(),
name: pairedNames[i].name,
price: prices[i],
quantity: pairedNames[i].quantity,
unitPrice: pairedNames[i].unitPrice,
),
];
for (var i = 0; i < discountSlots.length && i < discounts.length; i++) {
final index = discountSlots[i] - nameOffset;
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,
lines: lines,
);
}
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 ReceiptParseResult _result({
required List<LineItem> items,
required String rawText,
double? receiptTotal,
double totalDiscount = 0,
List<String>? lines,
}) {
final config = receiptParseConfig;
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 expectedItemCount = lines == null
? null
: _readExpectedItemCountFromLines(lines, config);
var passed =
receiptTotal == null ||
(sum - receiptTotal).abs() <= 0.01 ||
(sum + totalDiscount - receiptTotal).abs() <= 0.01;
if (expectedItemCount != null && kept.length != expectedItemCount) {
passed = false;
}
return ReceiptParseResult(
items: kept,
store: detectStore(rawText),
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
validationPassed: passed,
expectedItemCount: expectedItemCount,
);
}
static int? _readExpectedItemCountFromLines(
List<String> lines,
ReceiptParseConfig config,
) {
final pattern = RegExp(r'(\d+)\s*$', caseSensitive: false);
for (final raw in lines) {
final line = _preprocess(raw);
if (!config.matchesAny(line, config.itemCountKeywords)) continue;
final match = pattern.firstMatch(line);
if (match != null) return int.tryParse(match.group(1)!);
}
return null;
}
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 (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 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;
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);
}
}
+314 -27
View File
@@ -5,11 +5,16 @@ 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';
import 'widgets/name_dialog.dart';
import 'widgets/tag_editor.dart';
class ShopDetailScreen extends StatefulWidget {
const ShopDetailScreen({
@@ -26,21 +31,48 @@ class ShopDetailScreen extends StatefulWidget {
}
class _ShopDetailScreenState extends State<ShopDetailScreen> {
late Future<({Shop shop, List<ShopItem> items})> _future;
Shop? _shop;
List<ShopItem>? _items;
Object? _error;
bool _editing = false;
List<String> _tagSuggestions = [];
int _loadToken = 0;
@override
void initState() {
super.initState();
_future = _load();
widget.repository.addListener(_load);
_load();
widget.repository
.getUsedShopTags()
.then((tags) {
if (mounted) setState(() => _tagSuggestions = tags);
})
.catchError((_) {});
}
Future<({Shop shop, List<ShopItem> items})> _load() async {
final shop = await widget.repository.getShop(widget.shopId);
if (shop == null) {
throw StateError('Shop not found');
@override
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final token = ++_loadToken;
try {
final shop = await widget.repository.getShop(widget.shopId);
if (shop == null) throw StateError('Shop not found');
final items = await widget.repository.getShopItems(widget.shopId);
return (shop: shop, items: items);
if (!mounted || token != _loadToken) return;
setState(() {
_shop = shop;
_items = items;
_error = null;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
}
Future<void> _openReceipt(File file) async {
@@ -68,16 +100,215 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
);
}
Future<void> _addItem() async {
final shop = _shop;
if (shop == null) return;
final result = await showItemEditSheet(context);
if (result == null || !mounted) return;
final pendingId = -DateTime.now().microsecondsSinceEpoch;
final pending = ShopItem(
id: pendingId,
shopId: shop.id,
name: result.name,
price: result.price,
quantity: result.quantity,
unitPrice: result.unitPrice,
);
setState(() {
_items = [...?_items, pending];
_shop = Shop(
id: shop.id,
supermarket: shop.supermarket,
shoppedAt: shop.shoppedAt,
total: roundMoney(shop.total + result.price),
itemCount: shop.itemCount + 1,
name: shop.name,
receiptImagePath: shop.receiptImagePath,
tags: shop.tags,
);
});
try {
final saved = await widget.repository.addShopItem(
shopId: shop.id,
item: result,
);
if (!mounted) return;
setState(() {
_items = [
for (final entry in _items ?? const <ShopItem>[])
if (entry.id == pendingId) saved else entry,
];
});
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not add this product.')),
);
_load();
}
}
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,
clearQuantity: result.quantity == null,
);
if (mounted) _load();
}
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;
final isLastItem = (_items?.length ?? 0) == 1;
final shop = _shop;
setState(() {
_items = [
for (final entry in _items ?? const <ShopItem>[])
if (entry.id != item.id) entry,
];
if (shop != null) {
_shop = Shop(
id: shop.id,
supermarket: shop.supermarket,
shoppedAt: shop.shoppedAt,
total: roundMoney(shop.total - item.price),
itemCount: shop.itemCount - 1,
name: shop.name,
receiptImagePath: shop.receiptImagePath,
tags: shop.tags,
);
}
});
try {
await widget.repository.deleteShopItem(item.id);
if (!mounted || !isLastItem || shop == null) return;
await _confirmRemoveEmptyTrip(shop);
} catch (_) {
if (mounted) _load();
}
}
Future<void> _confirmRemoveEmptyTrip(Shop shop) async {
final removeTrip = await confirmAction(
context,
title: 'Remove trip?',
message:
'This trip has no products left. Remove it from your shop log? '
'Products from this trip stay in your catalog.',
confirmLabel: 'Remove trip',
);
if (!mounted) return;
if (!removeTrip) return;
try {
await widget.repository.deleteShop(shop.id);
if (mounted) Navigator.of(context).pop();
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not remove this trip.')),
);
_load();
}
}
Future<void> _editName(Shop shop) async {
final name = await showNameDialog(
context,
title: 'Trip name',
label: 'Name',
initial: shop.name,
allowEmpty: true,
);
if (name == null || !mounted) return;
await widget.repository.updateShop(
id: shop.id,
name: name,
clearName: name.isEmpty,
);
if (mounted) _load();
}
Future<void> _editDate(Shop shop) async {
final picked = await showDatePicker(
context: context,
initialDate: shop.shoppedAt,
firstDate: DateTime(2018),
lastDate: DateTime.now().add(const Duration(days: 1)),
);
if (picked == null || !mounted) return;
await widget.repository.updateShop(
id: shop.id,
shoppedAt: DateTime(picked.year, picked.month, picked.day),
);
if (mounted) _load();
}
Future<void> _setTags(Shop shop, List<String> tags) async {
await widget.repository.updateShop(id: shop.id, tags: tags);
if (mounted) _load();
}
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')),
body: FutureBuilder(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
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),
),
],
),
floatingActionButton: _editing
? FloatingActionButton.extended(
onPressed: _addItem,
icon: const Icon(Icons.add),
label: const Text('Add product'),
)
: null,
body: Builder(
builder: (context) {
if (_error != null) {
return Center(
child: Text(
'Could not load this trip.',
@@ -85,27 +316,62 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
),
);
}
if (!snapshot.hasData) {
final shop = _shop;
final items = _items;
if (shop == null || items == null) {
return const Center(child: CircularProgressIndicator());
}
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),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 96),
children: [
Text(shop.supermarket, style: theme.textTheme.headlineMedium),
if (_editing)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
shop.name?.isNotEmpty == true
? shop.name!
: 'Add a trip name',
),
subtitle: const Text('Optional name for this shop log'),
trailing: const Icon(Icons.edit_outlined),
onTap: () => _editName(shop),
)
else
Text(shop.displayName, style: theme.textTheme.headlineMedium),
if (_editing || (shop.name != null && shop.name!.isNotEmpty)) ...[
const SizedBox(height: 4),
Text(shop.supermarket, style: theme.textTheme.titleMedium),
],
const SizedBox(height: 4),
if (_editing)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(formatDate(shop.shoppedAt)),
subtitle: const Text('Trip date'),
trailing: const Icon(Icons.calendar_today_outlined),
onTap: () => _editDate(shop),
)
else
Text(
formatDate(shop.shoppedAt),
style: theme.textTheme.bodyLarge,
),
if (_editing) ...[
const SizedBox(height: 16),
TagEditor(
tags: shop.tags,
suggestions: _tagSuggestions,
onChanged: (tags) => _setTags(shop, tags),
),
] else if (shop.tags.isNotEmpty) ...[
const SizedBox(height: 12),
TagChips(tags: shop.tags),
],
if (hasReceipt) ...[
const SizedBox(height: 16),
GestureDetector(
@@ -125,20 +391,41 @@ 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
: Text(
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
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),
),
trailing: Text(
IconButton(
tooltip: 'Remove',
visualDensity: VisualDensity.compact,
onPressed: () => _removeItem(item),
icon: const Icon(Icons.delete_outline),
),
],
)
: Text(
formatEuro(item.price),
style: theme.textTheme.titleMedium,
),
onTap: () => _openProduct(item.productId),
onTap: _editing || item.productId == null
? null
: () => _openProduct(item.productId!),
),
const Divider(),
ListTile(
+362 -78
View File
@@ -1,18 +1,23 @@
/// Log of saved shopping trips.
/// Log of saved shopping trips, with a monthly heatmap and product search.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'models/shop.dart';
import 'models/supermarket.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'shop_detail_screen.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'utils/shop_calendar.dart';
import 'utils/shop_log_filter.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/profile_button.dart';
import 'widgets/settings_button.dart';
import 'widgets/shop_heatmap.dart';
import 'widgets/store_tag.dart';
import 'widgets/tag_editor.dart';
class ShopLogScreen extends StatefulWidget {
const ShopLogScreen({super.key, required this.repository});
@@ -24,44 +29,226 @@ class ShopLogScreen extends StatefulWidget {
}
class _ShopLogScreenState extends State<ShopLogScreen> {
late Future<List<Shop>> _future;
List<Shop>? _shops;
Map<int, List<String>> _productNames = const {};
Object? _error;
int _loadToken = 0;
final _search = TextEditingController();
bool _editing = false;
bool _searching = false;
DateTime _month = monthStart(DateTime.now());
DateTime? _selectedDay;
String? _tagFilter;
bool get _hasTrips => _shops != null && _shops!.isNotEmpty;
@override
void initState() {
super.initState();
_future = widget.repository.getShops();
widget.repository.addListener(_load);
_search.addListener(() => setState(() {}));
_load();
}
void _reload() {
setState(() => _future = widget.repository.getShops());
@override
void dispose() {
widget.repository.removeListener(_load);
_search.dispose();
super.dispose();
}
Future<void> _load() async {
final token = ++_loadToken;
try {
final shops = await widget.repository.getShops();
final productNames = await widget.repository.getShopItemNamesByShop();
if (!mounted || token != _loadToken) return;
setState(() {
_shops = shops;
_productNames = productNames;
_error = null;
if (shops.isEmpty) {
_editing = false;
if (_searching) {
_searching = false;
_search.clear();
}
}
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
}
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();
if (mounted) _load();
}
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;
setState(() {
_shops = [
for (final item in _shops ?? const <Shop>[])
if (item.id != shop.id) item,
];
_productNames = Map.of(_productNames)..remove(shop.id);
if (_shops!.isEmpty) {
_editing = false;
_searching = false;
_search.clear();
}
});
try {
await widget.repository.deleteShop(shop.id);
} catch (_) {
if (mounted) _load();
}
}
void _selectDay(DateTime? day) {
setState(() => _selectedDay = day);
}
void _goMonth(int delta) {
final next = addMonths(_month, delta);
final current = monthStart(DateTime.now());
if (delta > 0 && !next.isBefore(current) && next != current) return;
setState(() {
_month = next.isAfter(current) ? current : next;
_selectedDay = null;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
final nowMonth = monthStart(DateTime.now());
return Scaffold(
appBar: AppBar(
title: const Text('Shop log'),
actions: const [SettingsButton()],
title: _searching
? TextField(
controller: _search,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search products in trips',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
),
body: FutureBuilder<List<Shop>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
)
: const Text('Shop log'),
actions: [
IconButton(
tooltip: _searching ? 'Close search' : 'Search products',
onPressed: _hasTrips
? () {
setState(() {
_searching = !_searching;
if (!_searching) _search.clear();
});
}
: null,
icon: Icon(_searching ? Icons.close : Icons.search),
),
if (_hasTrips)
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
const ProfileButton(),
const SettingsButton(),
],
),
body: _ShopLogBody(
theme: theme,
stores: stores,
nowMonth: nowMonth,
shops: _shops,
productNames: _productNames,
error: _error,
month: _month,
selectedDay: _selectedDay,
tagFilter: _tagFilter,
query: _search.text,
editing: _editing,
onPreviousMonth: () => _goMonth(-1),
onNextMonth: () => _goMonth(1),
onSelectDay: _selectDay,
onClearTag: () => setState(() => _tagFilter = null),
onTagTap: (tag) {
setState(() {
_tagFilter = _tagFilter?.toLowerCase() == tag.toLowerCase()
? null
: tag;
});
},
onOpen: _openShop,
onRemove: _removeShop,
),
);
}
}
class _ShopLogBody extends StatelessWidget {
const _ShopLogBody({
required this.theme,
required this.stores,
required this.nowMonth,
required this.shops,
required this.productNames,
required this.error,
required this.month,
required this.selectedDay,
required this.tagFilter,
required this.query,
required this.editing,
required this.onPreviousMonth,
required this.onNextMonth,
required this.onSelectDay,
required this.onClearTag,
required this.onTagTap,
required this.onOpen,
required this.onRemove,
});
final ThemeData theme;
final List<Supermarket> stores;
final DateTime nowMonth;
final List<Shop>? shops;
final Map<int, List<String>> productNames;
final Object? error;
final DateTime month;
final DateTime? selectedDay;
final String? tagFilter;
final String query;
final bool editing;
final VoidCallback onPreviousMonth;
final VoidCallback onNextMonth;
final ValueChanged<DateTime?> onSelectDay;
final VoidCallback onClearTag;
final ValueChanged<String> onTagTap;
final ValueChanged<Shop> onOpen;
final ValueChanged<Shop> onRemove;
@override
Widget build(BuildContext context) {
if (error != null && shops == null) {
return Center(
child: Text(
'Could not load the shop log.',
@@ -69,88 +256,185 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
),
);
}
if (!snapshot.hasData) {
if (shops == null) {
return const Center(child: CircularProgressIndicator());
}
final shops = snapshot.data!;
if (shops.isEmpty) {
return Center(
final allShops = shops!;
final counts = tripCountsByDay([
for (final shop in allShops)
if (isSameMonth(shop.shoppedAt, month)) shop.shoppedAt,
]);
final monthShops = [
for (final shop in allShops)
if (isSameMonth(shop.shoppedAt, month)) shop,
];
final monthTotal = monthShops.fold<double>(
0,
(sum, shop) => sum + shop.total,
);
final visible = filterShops(
shops: allShops,
productNames: productNames,
selectedDay: selectedDay,
tag: tagFilter,
query: query,
);
return ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
ShopHeatmap(
month: month,
counts: counts,
monthTotal: monthTotal,
monthTripCount: monthShops.length,
canGoForward: month.isBefore(nowMonth),
selectedDay: selectedDay,
onPreviousMonth: onPreviousMonth,
onNextMonth: onNextMonth,
onSelectDay: onSelectDay,
),
if (selectedDay != null || tagFilter != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (selectedDay != null)
InputChip(
label: Text(formatDate(selectedDay!)),
onDeleted: () => onSelectDay(null),
),
if (tagFilter != null)
InputChip(label: Text(tagFilter!), onDeleted: onClearTag),
],
),
),
if (allShops.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
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,
),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
itemCount: shops.length,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final shop = shops[index];
final count = shop.itemCount;
return ListTile(
leading: _ReceiptThumb(path: shop.receiptImagePath),
title: Align(
alignment: Alignment.centerLeft,
child: StoreTag(name: shop.supermarket, stores: stores),
)
else if (visible.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
query.trim().isNotEmpty
? 'No trips contain that product.'
: 'No trips match this filter.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
subtitle: Text(
[
formatDate(shop.shoppedAt),
count == 1 ? '1 product' : '$count products',
].join(' · '),
)
else
for (final shop in visible)
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4),
child: _ShopLogTile(
shop: shop,
stores: stores,
editing: editing,
selectedTag: tagFilter,
onOpen: () => onOpen(shop),
onRemove: () => onRemove(shop),
onTagTap: onTagTap,
),
trailing: Text(
formatEuro(shop.total),
style: theme.textTheme.titleMedium,
),
onTap: () => _openShop(shop),
);
},
);
},
),
],
);
}
}
class _ReceiptThumb extends StatelessWidget {
const _ReceiptThumb({this.path});
class _ShopLogTile extends StatelessWidget {
const _ShopLogTile({
required this.shop,
required this.stores,
required this.editing,
required this.onOpen,
required this.onRemove,
required this.onTagTap,
this.selectedTag,
});
final String? path;
final Shop shop;
final List<Supermarket> stores;
final bool editing;
final VoidCallback onOpen;
final VoidCallback onRemove;
final ValueChanged<String> onTagTap;
final String? selectedTag;
@override
Widget build(BuildContext context) {
final file = path == null ? null : File(path!);
final hasFile = file != null && file.existsSync();
final scheme = Theme.of(context).colorScheme;
final theme = Theme.of(context);
final count = shop.itemCount;
final hasName = shop.name != null && shop.name!.isNotEmpty;
final meta = [
formatDate(shop.shoppedAt),
count == 1 ? '1 product' : '$count products',
].join(' · ');
return ClipRRect(
return Material(
color: Colors.transparent,
child: InkWell(
onTap: editing ? null : onOpen,
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 48,
height: 48,
child: hasFile
? Image.file(
file,
fit: BoxFit.cover,
cacheWidth: 144,
errorBuilder: (_, _, _) => ColoredBox(
color: scheme.secondaryContainer,
child: Icon(
Icons.receipt_long_outlined,
color: scheme.onSecondaryContainer,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (editing)
IconButton(
tooltip: 'Remove',
onPressed: onRemove,
icon: const Icon(Icons.delete_outline),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasName)
Text(shop.name!, style: theme.textTheme.titleMedium)
else
StoreTag(name: shop.supermarket, stores: stores),
const SizedBox(height: 4),
if (hasName) ...[
StoreTag(
name: shop.supermarket,
stores: stores,
compact: true,
),
const SizedBox(height: 4),
],
Text(meta, style: theme.textTheme.bodyMedium),
if (shop.tags.isNotEmpty) ...[
const SizedBox(height: 8),
TagChips(
tags: shop.tags,
compact: true,
selected: selectedTag,
onTap: onTagTap,
),
],
],
),
),
)
: ColoredBox(
color: scheme.secondaryContainer,
child: Icon(
Icons.receipt_long_outlined,
color: scheme.onSecondaryContainer,
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(top: 8, right: 8),
child: Text(
formatEuro(shop.total),
style: theme.textTheme.titleMedium,
),
),
],
),
),
),
+381
View File
@@ -0,0 +1,381 @@
/// Items on one shopping list, with check-off and edit/remove.
library;
import 'package:flutter/material.dart';
import 'models/shopping_list.dart';
import 'product_detail_screen.dart';
import 'services/product_repository.dart';
import 'utils/dates.dart';
import 'widgets/add_list_items_sheet.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> {
ShoppingList? _list;
List<ShoppingListItem>? _items;
Object? _error;
bool _editing = false;
int _loadToken = 0;
@override
void initState() {
super.initState();
widget.repository.addListener(_load);
_load();
}
@override
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final token = ++_loadToken;
try {
final list = await widget.repository.getShoppingList(widget.listId);
if (list == null) throw StateError('List not found');
final items = await widget.repository.getShoppingListItems(widget.listId);
if (!mounted || token != _loadToken) return;
setState(() {
_list = list;
_items = items;
_error = null;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
}
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) {
setState(
() => _list = ShoppingList(
id: list.id,
name: name,
itemCount: list.itemCount,
checkedCount: list.checkedCount,
createdAt: list.createdAt,
updatedAt: DateTime.now(),
),
);
_load();
}
}
Future<void> _openAddSheet() async {
final products = await widget.repository.getAll();
if (!mounted) return;
final selections = await showAddListItemsSheet(context, products: products);
if (selections == null || selections.isEmpty || !mounted) return;
final pending = [
for (var i = 0; i < selections.length; i++)
ShoppingListItem(
id: -(i + 1),
listId: widget.listId,
name: selections[i].name,
checked: false,
sortOrder: (_items?.length ?? 0) + i,
productId: selections[i].productId,
),
];
setState(() {
_items = [...?_items, ...pending];
if (_list != null) {
_list = ShoppingList(
id: _list!.id,
name: _list!.name,
itemCount: _list!.itemCount + pending.length,
checkedCount: _list!.checkedCount,
createdAt: _list!.createdAt,
updatedAt: DateTime.now(),
);
}
});
try {
final added = await widget.repository.addShoppingListItems(
listId: widget.listId,
items: [
for (final selection in selections)
(name: selection.name, productId: selection.productId),
],
);
if (!mounted) return;
setState(() {
final withoutPending = _items
?.where((item) => item.id >= 0)
.toList(growable: false);
_items = [...?withoutPending, ...added];
});
} catch (_) {
if (mounted) _load();
}
}
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) _load();
}
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;
setState(() {
_items = [
for (final entry in _items ?? const <ShoppingListItem>[])
if (entry.id != item.id) entry,
];
if (_list != null) {
_list = ShoppingList(
id: _list!.id,
name: _list!.name,
itemCount: _list!.itemCount - 1,
checkedCount: item.checked
? _list!.checkedCount - 1
: _list!.checkedCount,
createdAt: _list!.createdAt,
updatedAt: DateTime.now(),
);
}
});
try {
await widget.repository.deleteShoppingListItem(item.id);
} catch (_) {
if (mounted) _load();
}
}
Future<void> _toggle(ShoppingListItem item) async {
final next = !item.checked;
setState(() {
_items = [
for (final entry in _items ?? const <ShoppingListItem>[])
if (entry.id == item.id)
ShoppingListItem(
id: entry.id,
listId: entry.listId,
name: entry.name,
checked: next,
sortOrder: entry.sortOrder,
productId: entry.productId,
)
else
entry,
];
if (_list != null) {
_list = ShoppingList(
id: _list!.id,
name: _list!.name,
itemCount: _list!.itemCount,
checkedCount: _list!.checkedCount + (next ? 1 : -1),
createdAt: _list!.createdAt,
updatedAt: _list!.updatedAt,
);
}
});
try {
await widget.repository.updateShoppingListItem(
id: item.id,
checked: next,
);
} catch (_) {
if (mounted) _load();
}
}
Future<void> _shopAgain() async {
final list = _list;
if (list == null || list.checkedCount == 0) return;
setState(() {
_items = [
for (final item in _items ?? const <ShoppingListItem>[])
ShoppingListItem(
id: item.id,
listId: item.listId,
name: item.name,
checked: false,
sortOrder: item.sortOrder,
productId: item.productId,
),
];
_list = ShoppingList(
id: list.id,
name: list.name,
itemCount: list.itemCount,
checkedCount: 0,
createdAt: list.createdAt,
updatedAt: DateTime.now(),
);
});
try {
await widget.repository.resetShoppingListChecks(widget.listId);
} catch (_) {
if (mounted) _load();
}
}
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);
if (_error != null && _list == null) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Text(
'Could not load this list.',
style: theme.textTheme.bodyLarge,
),
),
);
}
if (_list == null || _items == null) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: CircularProgressIndicator()),
);
}
final list = _list!;
final items = _items!;
return Scaffold(
appBar: AppBar(
title: Text('${list.name} (${formatDate(list.createdAt)})'),
actions: [
if (list.checkedCount > 0 && !_editing)
TextButton(
onPressed: _shopAgain,
child: const Text('Shop again'),
),
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 || item.id < 0
? 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: item.id < 0
? null
: () => _editItem(item),
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Remove',
onPressed: item.id < 0
? null
: () => _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 || item.id < 0 ? null : () => _toggle(item),
);
},
),
);
}
}
+177
View File
@@ -0,0 +1,177 @@
/// 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 'utils/dates.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/name_dialog.dart';
import 'widgets/profile_button.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> {
List<ShoppingList>? _lists;
Object? _error;
bool _editing = false;
int _loadToken = 0;
bool get _hasLists => _lists != null && _lists!.isNotEmpty;
@override
void initState() {
super.initState();
widget.repository.addListener(_load);
_load();
}
@override
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final token = ++_loadToken;
try {
final lists = await widget.repository.getShoppingLists();
if (!mounted || token != _loadToken) return;
setState(() {
_lists = lists;
_error = null;
if (lists.isEmpty) _editing = false;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
}
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) _load();
}
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;
setState(() {
_lists = [
for (final item in _lists ?? const <ShoppingList>[])
if (item.id != list.id) item,
];
if (_lists!.isEmpty) _editing = false;
});
try {
await widget.repository.deleteShoppingList(list.id);
} catch (_) {
if (mounted) _load();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Shopping lists'),
actions: [
if (_hasLists)
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
const ProfileButton(),
const SettingsButton(),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _create,
tooltip: 'New list',
child: const Icon(Icons.add),
),
body: _error != null && _lists == null
? Center(
child: Text(
'Could not load shopping lists.',
style: theme.textTheme.bodyLarge,
),
)
: _lists == null
? const Center(child: CircularProgressIndicator())
: _lists!.isEmpty
? Center(
child: Text(
'Create a list for your next shop.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
)
: 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} (${formatDate(list.createdAt)})'),
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),
);
},
),
);
}
}
+284
View File
@@ -0,0 +1,284 @@
/// Manage supermarket names, colors, and bonus card photos.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'models/supermarket.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'widgets/store_edit_dialog.dart';
import 'widgets/store_tag.dart';
class SupermarketsScreen extends StatefulWidget {
const SupermarketsScreen({super.key, required this.repository});
final ProductRepository repository;
@override
State<SupermarketsScreen> createState() => _SupermarketsScreenState();
}
class _SupermarketsScreenState extends State<SupermarketsScreen> {
List<Supermarket>? _stores;
bool _busy = false;
final ImagePicker _picker = ImagePicker();
@override
void initState() {
super.initState();
widget.repository.addListener(_load);
_load();
}
@override
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
try {
final stores = await widget.repository.getSupermarkets();
if (!mounted) return;
setState(() => _stores = stores);
} catch (_) {
if (!mounted) return;
setState(() => _stores ??= const []);
}
}
Future<void> _syncSettings() async {
await _settings?.reload();
}
AppSettings? get _settings => SettingsScope.maybeOf(context);
Future<void> _editStore(Supermarket? existing) async {
final result = await showStoreEditDialog(context, store: existing);
if (result == null) return;
setState(() => _busy = true);
try {
final settings = _settings;
if (settings == null) return;
if (existing == null) {
await settings.addSupermarket(result.name, result.colorValue);
} else {
await settings.updateSupermarket(result);
}
await _load();
await _syncSettings();
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('That supermarket already exists.')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _deleteStore(Supermarket store) async {
if (store.id == null) return;
setState(() => _busy = true);
try {
await _settings?.deleteSupermarket(store.id!);
await _load();
await _syncSettings();
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _pickBonusCard(Supermarket store) async {
if (store.id == null) return;
final source = await showModalBottomSheet<ImageSource>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Take photo'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Pick from gallery'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
);
},
);
if (source == null) return;
final file = await _picker.pickImage(source: source, imageQuality: 85);
if (file == null) return;
setState(() => _busy = true);
try {
await widget.repository.setSupermarketBonusCard(store.id!, file.path);
await _load();
await _syncSettings();
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _viewBonusCard(String path) async {
await showDialog<void>(
context: context,
builder: (context) {
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: InteractiveViewer(
child: Image.file(File(path), fit: BoxFit.contain),
),
);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stores = _stores;
return Scaffold(
appBar: AppBar(title: const Text('Supermarkets')),
floatingActionButton: FloatingActionButton(
onPressed: _busy ? null : () => _editStore(null),
tooltip: 'Add supermarket',
child: const Icon(Icons.add),
),
body: stores == null
? const Center(child: CircularProgressIndicator())
: ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
itemCount: stores.length + 1,
separatorBuilder: (_, index) =>
index == 0 ? const SizedBox(height: 4) : const Divider(height: 1),
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Store names and colors appear on products. '
'Add a bonus card photo when you have one.',
style: theme.textTheme.bodyMedium,
),
if (_busy) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(),
],
],
),
);
}
final store = stores[index - 1];
return _SupermarketRow(
store: store,
stores: stores,
busy: _busy,
onEdit: () => _editStore(store),
onDelete: store.id == null ? null : () => _deleteStore(store),
onBonusCard: () => _pickBonusCard(store),
onViewBonusCard: store.bonusImagePath == null
? null
: () => _viewBonusCard(store.bonusImagePath!),
);
},
),
);
}
}
class _SupermarketRow extends StatelessWidget {
const _SupermarketRow({
required this.store,
required this.stores,
required this.busy,
required this.onEdit,
required this.onBonusCard,
this.onDelete,
this.onViewBonusCard,
});
final Supermarket store;
final List<Supermarket> stores;
final bool busy;
final VoidCallback onEdit;
final VoidCallback? onDelete;
final VoidCallback onBonusCard;
final VoidCallback? onViewBonusCard;
@override
Widget build(BuildContext context) {
final path = store.bonusImagePath;
final hasCard = path != null && path.isNotEmpty && File(path).existsSync();
return ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
leading: hasCard
? InkWell(
onTap: busy ? null : onViewBonusCard,
borderRadius: BorderRadius.circular(8),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(path),
width: 44,
height: 28,
fit: BoxFit.cover,
),
),
)
: CircleAvatar(
radius: 16,
backgroundColor: store.color,
child: Text(
store.name.isEmpty ? '?' : store.name.substring(0, 1),
style: TextStyle(
color: store.color.computeLuminance() > 0.55
? Colors.black87
: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
),
title: StoreTag(name: store.name, stores: stores, compact: true),
subtitle: hasCard ? null : const Text('No bonus card'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: hasCard ? 'Replace bonus card' : 'Add bonus card',
onPressed: busy ? null : onBonusCard,
icon: Icon(hasCard ? Icons.photo_camera : Icons.card_membership_outlined),
),
IconButton(
tooltip: 'Edit',
onPressed: busy ? null : onEdit,
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Delete',
onPressed: busy ? null : onDelete,
icon: const Icon(Icons.delete_outline),
),
],
),
);
}
}
+5 -15
View File
@@ -39,9 +39,7 @@ abstract final class AppTheme {
cardTheme: CardThemeData(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
),
@@ -60,9 +58,7 @@ abstract final class AppTheme {
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.outlineVariant),
@@ -88,9 +84,7 @@ abstract final class AppTheme {
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
dividerTheme: DividerThemeData(
color: colorScheme.outlineVariant,
@@ -98,14 +92,10 @@ abstract final class AppTheme {
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
listTileTheme: ListTileThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
iconColor: colorScheme.primary,
),
shadowColor: isDark ? Colors.black : const Color(0x1A0F172A),
+1 -1
View File
@@ -1,7 +1,7 @@
/// Shared strings used across the app.
library;
const String kAppName = 'Receipity';
const String kAppName = 'Groci';
/// Bundled Jumbo receipt used to test OCR without the camera.
const String kSampleReceiptAsset = 'assets/receipt.jpeg';
+18
View File
@@ -16,5 +16,23 @@ const _months = [
'Dec',
];
const _monthNames = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
String formatDate(DateTime date) =>
'${date.day} ${_months[date.month - 1]} ${date.year}';
String formatMonthYear(DateTime date) =>
'${_monthNames[date.month - 1]} ${date.year}';
+269
View File
@@ -0,0 +1,269 @@
/// Case-insensitive product name matching and duplicate grouping.
library;
import '../models/product.dart';
String normalizeProductName(String name) =>
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
String normalizeSupermarket(String? supermarket) =>
supermarket?.trim().toUpperCase() ?? '';
String productConflictKey({
required String name,
required String? supermarket,
}) => '${normalizeSupermarket(supermarket)}|${normalizeProductName(name)}';
String mergeGroupKey(List<Product> products, String? supermarket) {
final ids = products.map((product) => product.id).toList()..sort();
return 'merge:${normalizeSupermarket(supermarket)}:'
'${normalizeProductName(products.first.name)}:${ids.join(',')}';
}
String spellingVariantKey(int productId, String? supermarket) =>
'spelling:$productId:${normalizeSupermarket(supermarket)}';
List<List<Product>> nameConflictGroups(Iterable<Product> products) {
final groups = <String, List<Product>>{};
for (final product in products) {
final key = productConflictKey(
name: product.name,
supermarket: product.supermarket,
);
if (normalizeProductName(product.name).isEmpty) continue;
(groups[key] ??= []).add(product);
}
return [
for (final group in groups.values)
if (group.length > 1) group,
];
}
Set<int> nameConflictIds(Iterable<Product> products) {
return {
for (final group in nameConflictGroups(products))
for (final product in group) product.id,
};
}
/// Separate catalog products with the same name at the same store.
class ProductMergeGroup {
const ProductMergeGroup({
required this.products,
required this.supermarket,
required this.key,
});
final List<Product> products;
final String? supermarket;
final String key;
}
/// One catalog product bought under multiple spellings at one store.
class ProductSpellingVariant {
const ProductSpellingVariant({
required this.product,
required this.supermarket,
required this.variants,
required this.key,
});
final Product product;
final String? supermarket;
final List<String> variants;
final String key;
}
/// Builds merge groups from catalog names and trip lines at the same store.
List<ProductMergeGroup> buildNameConflictGroups({
required Iterable<Product> products,
required Iterable<({int productId, String name, String supermarket})>
shopLines,
}) {
final productById = {for (final product in products) product.id: product};
final idsByKey = <String, Set<int>>{};
final supermarketByKey = <String, String>{};
void add(int productId, String name, String? supermarket) {
final key = productConflictKey(name: name, supermarket: supermarket);
if (normalizeProductName(name).isEmpty) return;
final trimmed = supermarket?.trim();
if (trimmed != null && trimmed.isNotEmpty) {
supermarketByKey.putIfAbsent(key, () => trimmed);
}
(idsByKey[key] ??= {}).add(productId);
}
for (final product in products) {
add(product.id, product.name, product.supermarket);
}
for (final line in shopLines) {
add(line.productId, line.name, line.supermarket);
}
final groups = <ProductMergeGroup>[];
for (final entry in idsByKey.entries) {
if (entry.value.length < 2) continue;
final productsInGroup = [
for (final id in entry.value)
if (productById.containsKey(id)) productById[id]!,
];
if (productsInGroup.length < 2) continue;
productsInGroup.sort(
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
final supermarketLabel = supermarketByKey[entry.key];
groups.add(
ProductMergeGroup(
products: productsInGroup,
supermarket: supermarketLabel,
key: mergeGroupKey(productsInGroup, supermarketLabel),
),
);
}
groups.sort(
(a, b) =>
productConflictKey(
name: a.products.first.name,
supermarket: a.supermarket,
).compareTo(
productConflictKey(
name: b.products.first.name,
supermarket: b.supermarket,
),
),
);
return groups;
}
/// Finds receipt spellings that differ only by capitals at one store.
List<ProductSpellingVariant> findSpellingVariants({
required Iterable<Product> products,
required Iterable<({int productId, String name, String supermarket})>
shopLines,
Iterable<int> mergeProductIds = const [],
Iterable<String> mergeGroupKeys = const [],
}) {
final productById = {for (final product in products) product.id: product};
final excludedProducts = mergeProductIds.toSet();
final excludedGroups = mergeGroupKeys.toSet();
final namesByProductStore = <String, Set<String>>{};
final supermarketByKey = <String, String>{};
void addName(int productId, String name, String supermarket) {
final storeKey = normalizeSupermarket(supermarket);
final bucketKey = '$productId|$storeKey';
supermarketByKey[bucketKey] = supermarket;
(namesByProductStore[bucketKey] ??= {}).add(name);
}
for (final line in shopLines) {
addName(line.productId, line.name, line.supermarket);
}
for (final product in products) {
final supermarket = product.supermarket;
if (supermarket == null || supermarket.trim().isEmpty) continue;
addName(product.id, product.name, supermarket);
}
final issues = <ProductSpellingVariant>[];
for (final entry in namesByProductStore.entries) {
final parts = entry.key.split('|');
final productId = int.parse(parts.first);
if (excludedProducts.contains(productId)) continue;
final product = productById[productId];
if (product == null) continue;
final supermarket = supermarketByKey[entry.key];
final byNormalized = <String, Set<String>>{};
for (final name in entry.value) {
final key = normalizeProductName(name);
if (key.isEmpty) continue;
(byNormalized[key] ??= {}).add(name);
}
for (final names in byNormalized.values) {
if (names.length < 2) continue;
final variants = names.toList()
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
final issue = ProductSpellingVariant(
product: product,
supermarket: supermarket,
variants: variants,
key: spellingVariantKey(productId, supermarket),
);
if (excludedGroups.contains(issue.key)) continue;
issues.add(issue);
}
}
issues.sort(
(a, b) =>
productConflictKey(
name: a.product.name,
supermarket: a.supermarket,
).compareTo(
productConflictKey(name: b.product.name, supermarket: b.supermarket),
),
);
return issues;
}
/// Catalog merges and receipt spelling variants for the Products screen.
class ProductNameIssues {
const ProductNameIssues({
required this.mergeGroups,
required this.spellingVariants,
});
final List<ProductMergeGroup> mergeGroups;
final List<ProductSpellingVariant> spellingVariants;
bool get isEmpty => mergeGroups.isEmpty && spellingVariants.isEmpty;
bool get isNotEmpty => !isEmpty;
int get issueCount => mergeGroups.length + spellingVariants.length;
Set<int> get productIds => {
for (final group in mergeGroups)
for (final product in group.products) product.id,
for (final variant in spellingVariants) variant.product.id,
};
}
ProductNameIssues buildProductNameIssues({
required Iterable<Product> products,
required Iterable<({int productId, String name, String supermarket})>
shopLines,
Set<String> dismissedKeys = const {},
}) {
final mergeGroups = [
for (final group in buildNameConflictGroups(
products: products,
shopLines: shopLines,
))
if (!dismissedKeys.contains(group.key)) group,
];
final mergeIds = {
for (final group in mergeGroups)
for (final product in group.products) product.id,
};
final mergeKeys = {for (final group in mergeGroups) group.key};
final spellingVariants = [
for (final variant in findSpellingVariants(
products: products,
shopLines: shopLines,
mergeProductIds: mergeIds,
mergeGroupKeys: mergeKeys,
))
if (!dismissedKeys.contains(variant.key)) variant,
];
return ProductNameIssues(
mergeGroups: mergeGroups,
spellingVariants: spellingVariants,
);
}
+48
View File
@@ -0,0 +1,48 @@
/// Month-grid helpers for the shop-log heatmap.
library;
DateTime dateOnly(DateTime date) => DateTime(date.year, date.month, date.day);
DateTime monthStart(DateTime date) => DateTime(date.year, date.month);
bool isSameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
bool isSameMonth(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month;
DateTime addMonths(DateTime month, int delta) =>
DateTime(month.year, month.month + delta);
/// Monday-first calendar cells for [month], padded with nulls to full weeks.
List<DateTime?> monthGrid(DateTime month) {
final start = DateTime(month.year, month.month, 1);
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
final leading = start.weekday - DateTime.monday;
final cells = <DateTime?>[
...List<DateTime?>.filled(leading, null),
for (var day = 1; day <= daysInMonth; day++)
DateTime(month.year, month.month, day),
];
while (cells.length % 7 != 0) {
cells.add(null);
}
return cells;
}
/// How many trips landed on each calendar day.
Map<DateTime, int> tripCountsByDay(Iterable<DateTime> shoppedAt) {
final counts = <DateTime, int>{};
for (final date in shoppedAt) {
final key = dateOnly(date);
counts[key] = (counts[key] ?? 0) + 1;
}
return counts;
}
/// GitHub-style 0–4 intensity from a trip count.
int heatmapLevel(int count) {
if (count <= 0) return 0;
if (count >= 4) return 4;
return count;
}
+48
View File
@@ -0,0 +1,48 @@
/// Filters shop-log rows by day, tag, and product search.
library;
import '../models/shop.dart';
import 'fuzzy.dart';
import 'shop_calendar.dart';
List<Shop> filterShops({
required List<Shop> shops,
required Map<int, List<String>> productNames,
DateTime? selectedDay,
String? tag,
String query = '',
}) {
final needle = query.trim();
final tagFilter = tag?.trim();
return [
for (final shop in shops)
if (_matches(
shop: shop,
names: productNames[shop.id] ?? const [],
selectedDay: selectedDay,
tag: tagFilter,
query: needle,
))
shop,
];
}
bool _matches({
required Shop shop,
required List<String> names,
required DateTime? selectedDay,
required String? tag,
required String query,
}) {
if (selectedDay != null && !isSameDay(shop.shoppedAt, selectedDay)) {
return false;
}
if (tag != null && tag.isNotEmpty) {
final wanted = tag.toLowerCase();
if (!shop.tags.any((item) => item.toLowerCase() == wanted)) {
return false;
}
}
if (query.isEmpty) return true;
return names.any((name) => fuzzyScore(query, name) != null);
}
+17 -8
View File
@@ -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
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;
}
}
+175
View File
@@ -0,0 +1,175 @@
/// Multi-select sheet for adding catalog products to a shopping list.
library;
import 'package:flutter/material.dart';
import '../models/product.dart';
import 'product_image.dart';
class AddListItemSelection {
const AddListItemSelection({required this.name, this.productId});
final String name;
final int? productId;
}
Future<List<AddListItemSelection>?> showAddListItemsSheet(
BuildContext context, {
required List<Product> products,
}) {
return showModalBottomSheet<List<AddListItemSelection>>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => _AddListItemsSheet(products: products),
);
}
class _AddListItemsSheet extends StatefulWidget {
const _AddListItemsSheet({required this.products});
final List<Product> products;
@override
State<_AddListItemsSheet> createState() => _AddListItemsSheetState();
}
class _AddListItemsSheetState extends State<_AddListItemsSheet> {
final TextEditingController _controller = TextEditingController();
final Set<int> _selectedProductIds = {};
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;
return widget.products
.where((product) => product.name.toLowerCase().contains(query))
.toList();
}
int get _addCount {
var count = _selectedProductIds.length;
final typed = _controller.text.trim();
if (typed.isEmpty) return count;
final selectedNames = {
for (final product in widget.products)
if (_selectedProductIds.contains(product.id))
product.name.toLowerCase(),
};
if (!selectedNames.contains(typed.toLowerCase())) count++;
return count;
}
void _toggleProduct(Product product) {
setState(() {
if (_selectedProductIds.contains(product.id)) {
_selectedProductIds.remove(product.id);
} else {
_selectedProductIds.add(product.id);
}
_error = null;
});
}
void _add() {
final selections = <AddListItemSelection>[
for (final product in widget.products)
if (_selectedProductIds.contains(product.id))
AddListItemSelection(name: product.name, productId: product.id),
];
final typed = _controller.text.trim();
if (typed.isNotEmpty &&
!selections.any(
(item) => item.name.toLowerCase() == typed.toLowerCase(),
)) {
selections.add(AddListItemSelection(name: typed));
}
if (selections.isEmpty) {
setState(() => _error = 'Select products or enter an item name.');
return;
}
Navigator.of(context).pop(selections);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
final matches = _matches;
final addCount = _addCount;
return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.72,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Add items', style: theme.textTheme.titleLarge),
const SizedBox(height: 12),
TextField(
controller: _controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Search or type a new item',
),
onChanged: (_) => setState(() => _error = null),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
],
const SizedBox(height: 8),
Text('From products', style: theme.textTheme.labelMedium),
const SizedBox(height: 4),
Expanded(
child: matches.isEmpty
? Center(
child: Text(
widget.products.isEmpty
? 'No products in your catalog yet.'
: 'No products match this search.',
style: theme.textTheme.bodyMedium,
),
)
: ListView.builder(
itemCount: matches.length,
itemBuilder: (context, index) {
final product = matches[index];
final selected = _selectedProductIds.contains(
product.id,
);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Checkbox(
value: selected,
onChanged: (_) => _toggleProduct(product),
),
title: Text(product.name),
trailing: ProductImage(
name: product.name,
path: product.imagePath,
size: 32,
),
onTap: () => _toggleProduct(product),
);
},
),
),
FilledButton(
onPressed: addCount == 0 ? null : _add,
child: Text(addCount == 1 ? 'Add 1 item' : 'Add $addCount items'),
),
],
),
),
);
}
}
+32
View File
@@ -0,0 +1,32 @@
/// Confirm a destructive action before it runs.
library;
import 'package:flutter/material.dart';
Future<bool> confirmAction(
BuildContext context, {
required String title,
required String message,
String confirmLabel = 'Delete',
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmLabel),
),
],
);
},
);
return result == true;
}
+37
View File
@@ -0,0 +1,37 @@
/// Paid amount, with the pre-discount price struck through when there is one.
library;
import 'package:flutter/material.dart';
import '../models/line_item.dart';
import '../utils/money.dart';
class DiscountedPrice extends StatelessWidget {
const DiscountedPrice({super.key, required this.item});
final LineItem item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final paid = Text(
formatEuro(item.price),
style: theme.textTheme.titleMedium,
);
if (!item.hasDiscount) return paid;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
formatEuro(item.originalPrice),
style: theme.textTheme.bodyMedium?.copyWith(
decoration: TextDecoration.lineThrough,
),
),
const SizedBox(width: 8),
paid,
],
);
}
}
+251
View File
@@ -0,0 +1,251 @@
/// Bottom sheet to add or edit a priced line item.
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.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 _quantityController;
late final TextEditingController _unitPriceController;
late final TextEditingController _totalController;
String? _error;
bool _syncing = false;
@override
void initState() {
super.initState();
final item = widget.item;
final quantity = item?.quantity ?? 1;
final total = item?.price;
final unit = item?.unitPrice ??
(total != null && quantity > 0 ? roundMoney(total / quantity) : null);
_nameController = TextEditingController(text: item?.name ?? '');
_quantityController = TextEditingController(text: '$quantity');
_unitPriceController = TextEditingController(
text: unit == null ? '' : unit.toStringAsFixed(2),
);
_totalController = TextEditingController(
text: total == null ? '' : total.toStringAsFixed(2),
);
_quantityController.addListener(_onQuantityChanged);
_unitPriceController.addListener(_onUnitPriceChanged);
_totalController.addListener(_onTotalChanged);
}
@override
void dispose() {
_quantityController.removeListener(_onQuantityChanged);
_unitPriceController.removeListener(_onUnitPriceChanged);
_totalController.removeListener(_onTotalChanged);
_nameController.dispose();
_quantityController.dispose();
_unitPriceController.dispose();
_totalController.dispose();
super.dispose();
}
int? get _quantity {
final raw = _quantityController.text.trim();
if (raw.isEmpty) return null;
return int.tryParse(raw);
}
void _setText(TextEditingController controller, String value) {
if (controller.text == value) return;
controller.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(offset: value.length),
);
}
void _onQuantityChanged() {
if (_syncing) return;
final quantity = _quantity;
if (quantity == null || quantity <= 0) {
setState(() {});
return;
}
final unit = tryParsePrice(_unitPriceController.text);
final total = tryParsePrice(_totalController.text);
_syncing = true;
if (unit != null) {
_setText(_totalController, roundMoney(quantity * unit).toStringAsFixed(2));
} else if (total != null) {
_setText(
_unitPriceController,
roundMoney(total / quantity).toStringAsFixed(2),
);
}
_syncing = false;
setState(() {});
}
void _onUnitPriceChanged() {
if (_syncing) return;
final quantity = _quantity;
final unit = tryParsePrice(_unitPriceController.text);
if (quantity == null || quantity <= 0 || unit == null) {
setState(() {});
return;
}
_syncing = true;
_setText(_totalController, roundMoney(quantity * unit).toStringAsFixed(2));
_syncing = false;
setState(() {});
}
void _onTotalChanged() {
if (_syncing) return;
final quantity = _quantity;
final total = tryParsePrice(_totalController.text);
if (quantity == null || quantity <= 0 || total == null) {
setState(() {});
return;
}
_syncing = true;
_setText(
_unitPriceController,
roundMoney(total / quantity).toStringAsFixed(2),
);
_syncing = false;
setState(() {});
}
void _save() {
final name = _nameController.text.trim();
final quantity = _quantity;
final unit = tryParsePrice(_unitPriceController.text);
final total = tryParsePrice(_totalController.text);
if (name.isEmpty) {
setState(() => _error = 'Enter a product name.');
return;
}
if (quantity == null || quantity <= 0) {
setState(() => _error = 'Enter a quantity of at least 1.');
return;
}
if (total == null && unit == null) {
setState(() => _error = 'Enter a unit price or a total.');
return;
}
final resolvedTotal = total ?? roundMoney(quantity * unit!);
final resolvedUnit = unit ?? roundMoney(resolvedTotal / quantity);
final hasMulti = quantity > 1;
Navigator.of(context).pop(
LineItem(
id:
widget.item?.id ??
'manual-${DateTime.now().microsecondsSinceEpoch}',
name: name,
price: roundMoney(resolvedTotal),
quantity: hasMulti ? quantity : null,
unitPrice: hasMulti ? roundMoney(resolvedUnit) : null,
discount: widget.item?.price == roundMoney(resolvedTotal)
? widget.item?.discount ?? 0
: 0,
sourceBounds: widget.item?.sourceBounds,
needsManualReview: false,
),
);
}
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.viewInsetsOf(context).bottom;
final quantity = _quantity;
final unit = tryParsePrice(_unitPriceController.text);
final preview = quantity != null && quantity > 1 && unit != null
? '$quantity × ${formatEuro(unit)}'
: null;
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),
Row(
children: [
SizedBox(
width: 88,
child: TextField(
controller: _quantityController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(labelText: 'Qty'),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _unitPriceController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(labelText: 'Unit price'),
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _totalController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'Total price',
helperText: preview,
),
),
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')),
],
),
);
}
}
+105
View File
@@ -0,0 +1,105 @@
/// 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',
bool allowEmpty = false,
}) {
return showDialog<String>(
context: context,
builder: (context) => _NameDialog(
title: title,
initial: initial ?? '',
label: label,
confirmLabel: confirmLabel,
allowEmpty: allowEmpty,
),
);
}
class _NameDialog extends StatefulWidget {
const _NameDialog({
required this.title,
required this.initial,
required this.label,
required this.confirmLabel,
required this.allowEmpty,
});
final String title;
final String initial;
final String label;
final String confirmLabel;
final bool allowEmpty;
@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 && !widget.allowEmpty) {
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)),
],
);
}
}
+90
View File
@@ -0,0 +1,90 @@
/// Bottom sheet for picking a shopping list to add a product to.
library;
import 'package:flutter/material.dart';
import '../models/shopping_list.dart';
import '../services/product_repository.dart';
import 'name_dialog.dart';
Future<int?> showPickShoppingListSheet(
BuildContext context, {
required ProductRepository repository,
}) async {
final lists = await repository.getShoppingLists();
if (!context.mounted) return null;
if (lists.isEmpty) {
final name = await showNameDialog(
context,
title: 'New shopping list',
label: 'List name',
confirmLabel: 'Create',
);
if (name == null || !context.mounted) return null;
return repository.createShoppingList(name);
}
return showModalBottomSheet<int>(
context: context,
showDragHandle: true,
builder: (context) {
return _PickShoppingListSheet(repository: repository, lists: lists);
},
);
}
class _PickShoppingListSheet extends StatelessWidget {
const _PickShoppingListSheet({required this.repository, required this.lists});
final ProductRepository repository;
final List<ShoppingList> lists;
Future<void> _create(BuildContext context) async {
final name = await showNameDialog(
context,
title: 'New shopping list',
label: 'List name',
confirmLabel: 'Create',
);
if (name == null || !context.mounted) return;
final id = await repository.createShoppingList(name);
if (context.mounted) Navigator.of(context).pop(id);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text('Add to list', style: theme.textTheme.titleLarge),
),
for (final list in lists)
ListTile(
leading: const Icon(Icons.checklist_outlined),
title: Text(list.name),
subtitle: Text(
list.itemCount == 0
? 'Empty'
: list.itemCount == 1
? '1 item'
: '${list.itemCount} items',
),
onTap: () => Navigator.of(context).pop(list.id),
),
ListTile(
leading: const Icon(Icons.add),
title: const Text('Create new list'),
onTap: () => _create(context),
),
],
),
);
}
}
+7 -4
View File
@@ -28,9 +28,7 @@ class PriceChart extends StatelessWidget {
height: 160,
child: CustomPaint(
painter: _PriceChartPainter(
prices: [
for (final purchase in points) purchase.unitOrLinePrice,
],
prices: [for (final purchase in points) purchase.unitOrLinePrice],
lineColor: theme.colorScheme.primary,
fillColor: theme.colorScheme.primary.withValues(alpha: 0.12),
gridColor: theme.colorScheme.outlineVariant,
@@ -87,7 +85,12 @@ class _PriceChartPainter extends CustomPainter {
const right = 8.0;
const top = 12.0;
const bottom = 8.0;
final chart = Rect.fromLTRB(left, top, size.width - right, size.height - bottom);
final chart = Rect.fromLTRB(
left,
top,
size.width - right,
size.height - bottom,
);
final gridPaint = Paint()
..color = gridColor
+3 -1
View File
@@ -63,7 +63,9 @@ class _LetterAvatar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final trimmed = name.trim();
final letter = trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase();
final letter = trimmed.isEmpty
? '?'
: trimmed.substring(0, 1).toUpperCase();
return ColoredBox(
color: colorScheme.secondaryContainer,
child: Center(
+618
View File
@@ -0,0 +1,618 @@
/// Banner, list markers, and merge sheet for name duplicates.
library;
import 'package:flutter/material.dart';
import '../models/product.dart';
import '../services/product_repository.dart';
import '../utils/money.dart';
import '../utils/product_names.dart';
import 'product_image.dart';
const IconData kProductNameConflictIcon = Icons.merge_type;
class ProductNameConflictBanner extends StatelessWidget {
const ProductNameConflictBanner({
super.key,
required this.issues,
required this.onTap,
});
final ProductNameIssues issues;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final title = _bannerTitle(issues);
final subtitle = _bannerSubtitle(issues);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Material(
color: scheme.errorContainer.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
child: Row(
children: [
Icon(kProductNameConflictIcon, color: scheme.error),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleSmall),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
],
),
),
Icon(Icons.chevron_right, color: scheme.onSurfaceVariant),
],
),
),
),
),
);
}
}
String _bannerTitle(ProductNameIssues issues) {
final count = issues.issueCount;
if (count == 1 && issues.mergeGroups.length == 1) {
return '1 duplicate product';
}
if (count == 1 && issues.spellingVariants.length == 1) {
return '1 product, multiple spellings';
}
return '$count name issues';
}
String _bannerSubtitle(ProductNameIssues issues) {
final examples = <String>[];
for (final group in issues.mergeGroups.take(2)) {
final store = group.supermarket == null ? '' : ' @ ${group.supermarket}';
examples.add(
'${group.products.map((product) => product.name).join(' / ')}$store',
);
}
for (final variant in issues.spellingVariants.take(2)) {
final store = variant.supermarket == null
? ''
: ' @ ${variant.supermarket}';
examples.add('${variant.variants.join(' / ')}$store');
}
return examples.join(', ');
}
class ProductNameConflictMarker extends StatelessWidget {
const ProductNameConflictMarker({
super.key,
this.onTap,
this.compact = false,
});
final VoidCallback? onTap;
final bool compact;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final icon = Icon(
kProductNameConflictIcon,
size: compact ? 14 : 20,
color: scheme.error,
);
if (onTap == null) return icon;
if (compact) {
return Material(
color: scheme.errorContainer,
shape: const CircleBorder(),
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Padding(padding: const EdgeInsets.all(2), child: icon),
),
);
}
return IconButton(
tooltip: 'Resolve name issues',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: onTap,
icon: icon,
);
}
}
Future<bool> showProductNameConflictSheet(
BuildContext context, {
required ProductRepository repository,
required ProductNameIssues issues,
int? focusProductId,
}) {
if (issues.isEmpty) return Future.value(false);
return showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
builder: (context) {
final size = MediaQuery.sizeOf(context);
return SizedBox(
width: size.width,
height: size.height * 0.85,
child: _ProductNameConflictSheet(
repository: repository,
issues: issues,
focusProductId: focusProductId,
),
);
},
).then((resolved) => resolved == true);
}
class _ProductNameConflictSheet extends StatefulWidget {
const _ProductNameConflictSheet({
required this.repository,
required this.issues,
this.focusProductId,
});
final ProductRepository repository;
final ProductNameIssues issues;
final int? focusProductId;
@override
State<_ProductNameConflictSheet> createState() =>
_ProductNameConflictSheetState();
}
class _ProductNameConflictSheetState extends State<_ProductNameConflictSheet> {
late List<ProductMergeGroup> _mergeGroups;
late List<ProductSpellingVariant> _spellingVariants;
late Map<int, int> _keepByMergeGroup;
late Map<int, String> _spellingChoiceByIndex;
final Set<int> _busyMergeGroups = {};
final Set<int> _busySpellingGroups = {};
bool _resolvedAny = false;
@override
void initState() {
super.initState();
_mergeGroups = [for (final group in widget.issues.mergeGroups) group];
_spellingVariants = [
for (final variant in widget.issues.spellingVariants) variant,
];
_rebuildChoices();
}
void _rebuildChoices() {
_keepByMergeGroup = {
for (var i = 0; i < _mergeGroups.length; i++)
i: _defaultKeep(_mergeGroups[i].products),
};
_spellingChoiceByIndex = {
for (var i = 0; i < _spellingVariants.length; i++)
i: _spellingVariants[i].variants.first,
};
}
int _defaultKeep(List<Product> group) {
final sorted = [...group]
..sort((a, b) {
final seen = b.timesSeen.compareTo(a.timesSeen);
if (seen != 0) return seen;
return a.id.compareTo(b.id);
});
return sorted.first.id;
}
bool get _isEmpty => _mergeGroups.isEmpty && _spellingVariants.isEmpty;
int? _focusedMergeIndex() {
final focus = widget.focusProductId;
if (focus == null) return null;
for (var i = 0; i < _mergeGroups.length; i++) {
if (_mergeGroups[i].products.any((product) => product.id == focus)) {
return i;
}
}
return null;
}
int? _focusedSpellingIndex() {
final focus = widget.focusProductId;
if (focus == null) return null;
for (var i = 0; i < _spellingVariants.length; i++) {
if (_spellingVariants[i].product.id == focus) return i;
}
return null;
}
void _maybeClose() {
if (_isEmpty && mounted) {
Navigator.of(context).pop(true);
}
}
Future<void> _mergeGroup(int index) async {
if (_busyMergeGroups.contains(index)) return;
final group = _mergeGroups[index];
final keepId = _keepByMergeGroup[index]!;
setState(() => _busyMergeGroups.add(index));
try {
await widget.repository.mergeProducts(
keepId: keepId,
sourceIds: [for (final product in group.products) product.id],
);
if (!mounted) return;
setState(() {
_resolvedAny = true;
_busyMergeGroups.remove(index);
_mergeGroups.removeAt(index);
_rebuildChoices();
});
_maybeClose();
} catch (_) {
if (!mounted) return;
setState(() => _busyMergeGroups.remove(index));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not merge these products.')),
);
}
}
Future<void> _leaveMergeAsIs(int index) async {
final group = _mergeGroups[index];
await widget.repository.dismissNameIssue(group.key);
if (!mounted) return;
setState(() {
_resolvedAny = true;
_mergeGroups.removeAt(index);
_rebuildChoices();
});
_maybeClose();
}
Future<void> _applySpelling(int index) async {
if (_busySpellingGroups.contains(index)) return;
final variant = _spellingVariants[index];
final chosen = _spellingChoiceByIndex[index]!;
setState(() => _busySpellingGroups.add(index));
try {
await widget.repository.unifyProductSpelling(
productId: variant.product.id,
name: chosen,
supermarket: variant.supermarket,
);
if (!mounted) return;
setState(() {
_resolvedAny = true;
_busySpellingGroups.remove(index);
_spellingVariants.removeAt(index);
_rebuildChoices();
});
_maybeClose();
} catch (_) {
if (!mounted) return;
setState(() => _busySpellingGroups.remove(index));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not update this spelling.')),
);
}
}
Future<void> _leaveSpellingAsIs(int index) async {
final variant = _spellingVariants[index];
await widget.repository.dismissNameIssue(variant.key);
if (!mounted) return;
setState(() {
_resolvedAny = true;
_spellingVariants.removeAt(index);
_rebuildChoices();
});
_maybeClose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final focusMergeIndex = _focusedMergeIndex();
final focusSpellingIndex = _focusedSpellingIndex();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Resolve name issues', style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(
'Same name at the same store. Pick which entry to keep, '
'then merge or leave as separate products.',
style: theme.textTheme.bodyMedium,
),
],
),
),
const SizedBox(height: 12),
Expanded(
child: _isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
_resolvedAny
? 'All name issues resolved.'
: 'No name issues found.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
),
)
: ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
if (_mergeGroups.isNotEmpty) ...[
Text(
'Separate products to merge',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
for (var i = 0; i < _mergeGroups.length; i++) ...[
if (i > 0) const SizedBox(height: 12),
_MergeGroupCard(
group: _mergeGroups[i],
keepId: _keepByMergeGroup[i]!,
busy: _busyMergeGroups.contains(i),
highlighted: focusMergeIndex == i,
onKeepChanged: (id) {
setState(() => _keepByMergeGroup[i] = id);
},
onMerge: () => _mergeGroup(i),
onLeaveAsIs: () => _leaveMergeAsIs(i),
),
],
],
if (_spellingVariants.isNotEmpty) ...[
if (_mergeGroups.isNotEmpty) const SizedBox(height: 20),
Text(
'One product, multiple spellings',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
for (var i = 0; i < _spellingVariants.length; i++) ...[
if (i > 0) const SizedBox(height: 12),
_SpellingVariantCard(
variant: _spellingVariants[i],
chosen: _spellingChoiceByIndex[i]!,
busy: _busySpellingGroups.contains(i),
highlighted: focusSpellingIndex == i,
onChosen: (name) {
setState(() => _spellingChoiceByIndex[i] = name);
},
onApply: () => _applySpelling(i),
onLeaveAsIs: () => _leaveSpellingAsIs(i),
),
],
],
],
),
),
],
);
}
}
class _MergeGroupCard extends StatelessWidget {
const _MergeGroupCard({
required this.group,
required this.keepId,
required this.busy,
required this.highlighted,
required this.onKeepChanged,
required this.onMerge,
required this.onLeaveAsIs,
});
final ProductMergeGroup group;
final int keepId;
final bool busy;
final bool highlighted;
final ValueChanged<int> onKeepChanged;
final VoidCallback onMerge;
final VoidCallback onLeaveAsIs;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final products = group.products;
return Card(
color: highlighted
? theme.colorScheme.tertiaryContainer.withValues(alpha: 0.35)
: null,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
kProductNameConflictIcon,
size: 18,
color: theme.colorScheme.tertiary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
[
normalizeProductName(products.first.name),
if (group.supermarket != null) group.supermarket!,
].join(' · '),
style: theme.textTheme.titleSmall,
),
),
],
),
const SizedBox(height: 8),
for (final product in products)
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: ProductImage(
name: product.name,
path: product.imagePath,
size: 36,
),
title: Text(product.name),
subtitle: Text(
[
formatEuro(product.lastPrice),
product.timesSeen == 1
? '1 purchase'
: '${product.timesSeen} purchases',
].join(' · '),
),
trailing: Icon(
keepId == product.id
? Icons.check_circle
: Icons.circle_outlined,
color: keepId == product.id
? theme.colorScheme.primary
: theme.colorScheme.outline,
),
onTap: busy ? null : () => onKeepChanged(product.id),
),
Wrap(
alignment: WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
TextButton(
onPressed: busy ? null : onLeaveAsIs,
child: const Text('Leave as is'),
),
FilledButton(
onPressed: busy ? null : onMerge,
child: busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Merge'),
),
],
),
],
),
),
);
}
}
class _SpellingVariantCard extends StatelessWidget {
const _SpellingVariantCard({
required this.variant,
required this.chosen,
required this.busy,
required this.highlighted,
required this.onChosen,
required this.onApply,
required this.onLeaveAsIs,
});
final ProductSpellingVariant variant;
final String chosen;
final bool busy;
final bool highlighted;
final ValueChanged<String> onChosen;
final VoidCallback onApply;
final VoidCallback onLeaveAsIs;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final product = variant.product;
return Card(
color: highlighted
? theme.colorScheme.tertiaryContainer.withValues(alpha: 0.35)
: null,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: ProductImage(
name: product.name,
path: product.imagePath,
size: 36,
),
title: Text(product.name),
subtitle: Text(
[
if (variant.supermarket != null) variant.supermarket!,
product.timesSeen == 1
? '1 purchase'
: '${product.timesSeen} purchases',
].join(' · '),
),
),
const SizedBox(height: 4),
Text('Recorded as:', style: theme.textTheme.labelLarge),
const SizedBox(height: 4),
for (final name in variant.variants)
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(name),
trailing: Icon(
chosen == name ? Icons.check_circle : Icons.circle_outlined,
color: chosen == name
? theme.colorScheme.primary
: theme.colorScheme.outline,
),
onTap: busy ? null : () => onChosen(name),
),
Wrap(
alignment: WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
TextButton(
onPressed: busy ? null : onLeaveAsIs,
child: const Text('Leave as is'),
),
FilledButton(
onPressed: busy ? null : onApply,
child: Text(busy ? 'Saving…' : 'Use this spelling'),
),
],
),
],
),
),
);
}
}
+23
View File
@@ -0,0 +1,23 @@
/// Opens the profile screen from an app bar.
library;
import 'package:flutter/material.dart';
import '../profile_screen.dart';
class ProfileButton extends StatelessWidget {
const ProfileButton({super.key});
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: 'Profile',
onPressed: () {
Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => const ProfileScreen()));
},
icon: const Icon(Icons.person_outline),
);
}
}
@@ -0,0 +1,91 @@
/// Overlays a highlight on the receipt photo for a parsed line item.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import '../models/receipt_bounds.dart';
class ReceiptHighlightOverlay extends StatelessWidget {
const ReceiptHighlightOverlay({
super.key,
required this.imagePath,
required this.bounds,
this.maxHeight = 280,
});
final String imagePath;
final ReceiptBounds bounds;
final double maxHeight;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
fit: StackFit.passthrough,
children: [
Image.file(
File(imagePath),
fit: BoxFit.contain,
height: maxHeight,
width: constraints.maxWidth,
),
Positioned.fill(
child: CustomPaint(
painter: _BoundsPainter(
bounds: bounds,
color: scheme.primary.withValues(alpha: 0.35),
stroke: scheme.primary,
),
),
),
],
);
},
),
);
}
}
class _BoundsPainter extends CustomPainter {
_BoundsPainter({
required this.bounds,
required this.color,
required this.stroke,
});
final ReceiptBounds bounds;
final Color color;
final Color stroke;
@override
void paint(Canvas canvas, Size size) {
if (bounds.isEmpty) return;
final rect = Rect.fromLTRB(
bounds.left * size.width,
bounds.top * size.height,
bounds.right * size.width,
bounds.bottom * size.height,
);
canvas.drawRect(rect, Paint()..color = color);
canvas.drawRect(
rect,
Paint()
..color = stroke
..style = PaintingStyle.stroke
..strokeWidth = 2,
);
}
@override
bool shouldRepaint(covariant _BoundsPainter oldDelegate) {
return oldDelegate.bounds != bounds ||
oldDelegate.color != color ||
oldDelegate.stroke != stroke;
}
}
+86 -214
View File
@@ -3,9 +3,10 @@ library;
import 'package:flutter/material.dart';
import '../models/supermarket.dart';
import '../services/app_settings.dart';
import '../services/backup_service.dart';
import '../supermarkets_screen.dart';
import 'confirm_dialog.dart';
class SettingsButton extends StatelessWidget {
const SettingsButton({super.key});
@@ -47,9 +48,8 @@ class _SettingsSheetState extends State<_SettingsSheet> {
final shared = await BackupService(settings.repository).exportBackup();
if (!mounted) return;
if (shared) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Backup ready.')),
);
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Backup ready.')));
}
} catch (_) {
if (!mounted) return;
@@ -92,9 +92,8 @@ class _SettingsSheetState extends State<_SettingsSheet> {
if (imported) {
await settings.reload();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Backup imported.')),
);
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Backup imported.')));
}
} catch (_) {
if (!mounted) return;
@@ -106,22 +105,63 @@ class _SettingsSheetState extends State<_SettingsSheet> {
}
}
Future<void> _wipeAllData(AppSettings settings) async {
final confirmed = await confirmAction(
context,
title: 'Delete all data?',
message:
'This permanently removes all products, shopping trips, lists, '
'photos, bonus cards, and settings on this device. '
'Export a backup first if you want to keep anything.',
confirmLabel: 'Delete everything',
);
if (!confirmed || !mounted) return;
setState(() => _busy = true);
try {
await settings.wipeAllData();
if (!mounted) return;
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All data deleted.')),
);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not delete all data.')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _openSupermarkets(BuildContext context, AppSettings settings) {
Navigator.of(context)
..pop()
..push(
MaterialPageRoute(
builder: (_) => SupermarketsScreen(repository: settings.repository),
),
);
}
@override
Widget build(BuildContext context) {
final settings = SettingsScope.of(context);
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final bottom = MediaQuery.viewInsetsOf(context).bottom;
final maxHeight = MediaQuery.sizeOf(context).height * 0.85;
return ListenableBuilder(
listenable: settings,
builder: (context, _) {
return Padding(
return SizedBox(
height: maxHeight,
child: ListView(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.75,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Settings', style: Theme.of(context).textTheme.titleLarge),
Text('Settings', style: theme.textTheme.titleLarge),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
@@ -129,10 +169,25 @@ class _SettingsSheetState extends State<_SettingsSheet> {
value: settings.darkMode,
onChanged: _busy ? null : settings.setDarkMode,
),
Text('Backup', style: Theme.of(context).textTheme.titleMedium),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.store_outlined),
title: const Text('Supermarkets'),
subtitle: Text(
settings.supermarkets.isEmpty
? 'Names, colors, and bonus cards'
: '${settings.supermarkets.length} stores',
),
trailing: const Icon(Icons.chevron_right),
onTap: _busy
? null
: () => _openSupermarkets(context, settings),
),
const SizedBox(height: 16),
Text('Backup', style: theme.textTheme.titleMedium),
Text(
'Save everything to a JSON file, or replace this device from a backup.',
style: Theme.of(context).textTheme.bodyMedium,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 8),
Row(
@@ -158,215 +213,32 @@ class _SettingsSheetState extends State<_SettingsSheet> {
const SizedBox(height: 8),
const LinearProgressIndicator(),
],
const SizedBox(height: 16),
const SizedBox(height: 32),
Text(
'Supermarkets',
style: Theme.of(context).textTheme.titleMedium,
'Danger zone',
style: theme.textTheme.titleMedium?.copyWith(
color: scheme.error,
),
),
const SizedBox(height: 4),
Text(
'Name and color used as tags on products.',
style: Theme.of(context).textTheme.bodyMedium,
'Permanently delete everything on this device.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 8),
Expanded(
child: ListView(
children: [
for (final store in settings.supermarkets)
_StoreRow(
store: store,
onEdit: _busy
? () {}
: () => _editStore(context, settings, store),
onDelete: _busy || store.id == null
? null
: () => settings.deleteSupermarket(store.id!),
),
],
),
),
const SizedBox(height: 8),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _busy
? null
: () => _editStore(context, settings, null),
icon: const Icon(Icons.add),
label: const Text('Add supermarket'),
style: OutlinedButton.styleFrom(
foregroundColor: scheme.error,
side: BorderSide(color: scheme.error),
),
onPressed: _busy ? null : () => _wipeAllData(settings),
icon: const Icon(Icons.delete_forever_outlined),
label: const Text('Delete all data'),
),
],
),
),
);
},
);
}
Future<void> _editStore(
BuildContext context,
AppSettings settings,
Supermarket? existing,
) async {
final result = await showDialog<Supermarket>(
context: context,
builder: (context) => _StoreEditDialog(store: existing),
);
if (result == null) return;
if (existing == null) {
try {
await settings.addSupermarket(result.name, result.colorValue);
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('That supermarket already exists.')),
);
}
}
} else {
await settings.updateSupermarket(
existing.copyWith(name: result.name, colorValue: result.colorValue),
);
}
}
}
class _StoreRow extends StatelessWidget {
const _StoreRow({
required this.store,
required this.onEdit,
this.onDelete,
});
final Supermarket store;
final VoidCallback onEdit;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final onColor = store.color.computeLuminance() > 0.55
? Colors.black87
: Colors.white;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: store.color,
child: Text(
store.name.isEmpty ? '?' : store.name.substring(0, 1),
style: TextStyle(color: onColor, fontWeight: FontWeight.w700),
),
),
title: Text(store.name),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Delete',
onPressed: onDelete,
icon: const Icon(Icons.delete_outline),
),
],
),
);
}
}
class _StoreEditDialog extends StatefulWidget {
const _StoreEditDialog({this.store});
final Supermarket? store;
@override
State<_StoreEditDialog> createState() => _StoreEditDialogState();
}
class _StoreEditDialogState extends State<_StoreEditDialog> {
late final TextEditingController _name;
late int _color;
@override
void initState() {
super.initState();
_name = TextEditingController(text: widget.store?.name ?? '');
_color = widget.store?.colorValue ?? kStoreColorPalette.first;
}
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.store == null ? 'Add supermarket' : 'Edit supermarket'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _name,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: widget.store == null,
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: Text('Color', style: Theme.of(context).textTheme.titleMedium),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final value in kStoreColorPalette)
GestureDetector(
onTap: () => setState(() => _color = value),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Color(value),
shape: BoxShape.circle,
border: Border.all(
color: _color == value
? Theme.of(context).colorScheme.onSurface
: Colors.transparent,
width: 2,
),
),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = _name.text.trim();
if (name.isEmpty) return;
Navigator.pop(
context,
Supermarket(
id: widget.store?.id,
name: name,
colorValue: _color,
sortOrder: widget.store?.sortOrder ?? 0,
),
);
},
child: const Text('Save'),
),
],
);
}
}
+216
View File
@@ -0,0 +1,216 @@
/// GitHub-style contribution heatmap for one calendar month of shopping trips.
library;
import 'package:flutter/material.dart';
import '../utils/dates.dart';
import '../utils/money.dart';
import '../utils/shop_calendar.dart';
class ShopHeatmap extends StatelessWidget {
const ShopHeatmap({
super.key,
required this.month,
required this.counts,
required this.monthTotal,
required this.monthTripCount,
required this.canGoForward,
this.selectedDay,
required this.onPreviousMonth,
required this.onNextMonth,
required this.onSelectDay,
});
final DateTime month;
final Map<DateTime, int> counts;
final double monthTotal;
final int monthTripCount;
final bool canGoForward;
final DateTime? selectedDay;
final VoidCallback onPreviousMonth;
final VoidCallback onNextMonth;
final ValueChanged<DateTime?> onSelectDay;
static const _weekdays = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final cells = monthGrid(month);
final today = dateOnly(DateTime.now());
final weeks = cells.length ~/ 7;
return Card(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
IconButton(
tooltip: 'Previous month',
onPressed: onPreviousMonth,
icon: const Icon(Icons.chevron_left),
),
Expanded(
child: Text(
formatMonthYear(month),
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
),
IconButton(
tooltip: 'Next month',
onPressed: canGoForward ? onNextMonth : null,
icon: const Icon(Icons.chevron_right),
),
],
),
Text(
monthTripCount == 0
? 'No trips this month'
: '${monthTripCount == 1 ? '1 trip' : '$monthTripCount trips'} · ${formatEuro(monthTotal)}',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
children: [
for (final label in _weekdays)
Expanded(
child: Text(
label,
textAlign: TextAlign.center,
style: theme.textTheme.labelMedium,
),
),
],
),
const SizedBox(height: 4),
for (var week = 0; week < weeks; week++)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
for (var weekday = 0; weekday < 7; weekday++)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: _HeatCell(
date: cells[week * 7 + weekday],
count: cells[week * 7 + weekday] == null
? 0
: counts[cells[week * 7 + weekday]] ?? 0,
today: today,
selectedDay: selectedDay,
onSelect: onSelectDay,
),
),
),
],
),
),
const SizedBox(height: 12),
Row(
children: [
Text('Less', style: theme.textTheme.labelMedium),
const SizedBox(width: 6),
for (var level = 0; level <= 4; level++)
Padding(
padding: const EdgeInsets.only(right: 3),
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: _fillForLevel(scheme, level),
borderRadius: BorderRadius.circular(3),
),
),
),
Text('More', style: theme.textTheme.labelMedium),
],
),
],
),
),
);
}
}
class _HeatCell extends StatelessWidget {
const _HeatCell({
required this.date,
required this.count,
required this.today,
required this.selectedDay,
required this.onSelect,
});
final DateTime? date;
final int count;
final DateTime today;
final DateTime? selectedDay;
final ValueChanged<DateTime?> onSelect;
@override
Widget build(BuildContext context) {
if (date == null) {
return const SizedBox(height: 36);
}
final scheme = Theme.of(context).colorScheme;
final level = heatmapLevel(count);
final selected = selectedDay != null && isSameDay(date!, selectedDay!);
final isToday = isSameDay(date!, today);
final fill = _fillForLevel(scheme, level);
final textColor = level >= 3
? scheme.onPrimary
: scheme.onSurface.withValues(alpha: count == 0 ? 0.45 : 1);
return Tooltip(
message: count == 0
? formatDate(date!)
: '${formatDate(date!)} · ${count == 1 ? '1 trip' : '$count trips'}',
child: InkWell(
onTap: () => onSelect(selected ? null : date),
borderRadius: BorderRadius.circular(6),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: fill,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: selected
? scheme.primary
: isToday
? scheme.outline
: Colors.transparent,
width: selected ? 2 : 1,
),
),
child: Text(
'${date!.day}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: textColor,
fontWeight: isToday || selected
? FontWeight.w700
: FontWeight.w500,
fontSize: 11,
),
),
),
),
);
}
}
Color _fillForLevel(ColorScheme scheme, int level) {
if (level <= 0) return scheme.surfaceContainerHighest;
final t = (level / 4).clamp(0.28, 1.0);
return Color.lerp(scheme.primary.withValues(alpha: 0.22), scheme.primary, t)!;
}
+119
View File
@@ -0,0 +1,119 @@
/// Dialog for adding or editing a supermarket name and color.
library;
import 'package:flutter/material.dart';
import '../models/supermarket.dart';
Future<Supermarket?> showStoreEditDialog(
BuildContext context, {
Supermarket? store,
}) {
return showDialog<Supermarket>(
context: context,
builder: (context) => _StoreEditDialog(store: store),
);
}
class _StoreEditDialog extends StatefulWidget {
const _StoreEditDialog({this.store});
final Supermarket? store;
@override
State<_StoreEditDialog> createState() => _StoreEditDialogState();
}
class _StoreEditDialogState extends State<_StoreEditDialog> {
late final TextEditingController _name;
late int _color;
@override
void initState() {
super.initState();
_name = TextEditingController(text: widget.store?.name ?? '');
_color = widget.store?.colorValue ?? kStoreColorPalette.first;
}
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
widget.store == null ? 'Add supermarket' : 'Edit supermarket',
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _name,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: widget.store == null,
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: Text(
'Color',
style: Theme.of(context).textTheme.titleMedium,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final value in kStoreColorPalette)
GestureDetector(
onTap: () => setState(() => _color = value),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Color(value),
shape: BoxShape.circle,
border: Border.all(
color: _color == value
? Theme.of(context).colorScheme.onSurface
: Colors.transparent,
width: 2,
),
),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = _name.text.trim();
if (name.isEmpty) return;
Navigator.pop(
context,
Supermarket(
id: widget.store?.id,
name: name,
colorValue: _color,
sortOrder: widget.store?.sortOrder ?? 0,
bonusImagePath: widget.store?.bonusImagePath,
),
);
},
child: const Text('Save'),
),
],
);
}
}
+181
View File
@@ -0,0 +1,181 @@
/// Chip row and optional editor for shopping-trip tags.
library;
import 'package:flutter/material.dart';
class TagChips extends StatelessWidget {
const TagChips({
super.key,
required this.tags,
this.onTap,
this.selected,
this.compact = false,
});
final List<String> tags;
final ValueChanged<String>? onTap;
final String? selected;
final bool compact;
@override
Widget build(BuildContext context) {
if (tags.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: compact ? 4 : 6,
runSpacing: compact ? 4 : 6,
children: [
for (final tag in tags)
_TagChip(
label: tag,
selected:
selected != null &&
selected!.toLowerCase() == tag.toLowerCase(),
compact: compact,
onPressed: onTap == null ? null : () => onTap!(tag),
),
],
);
}
}
class TagEditor extends StatefulWidget {
const TagEditor({
super.key,
required this.tags,
required this.onChanged,
this.suggestions = const [],
this.enabled = true,
});
final List<String> tags;
final List<String> suggestions;
final ValueChanged<List<String>> onChanged;
final bool enabled;
@override
State<TagEditor> createState() => _TagEditorState();
}
class _TagEditorState extends State<TagEditor> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _add(String raw) {
final label = raw.trim().replaceAll(RegExp(r'\s+'), ' ');
if (label.isEmpty) return;
final exists = widget.tags.any(
(tag) => tag.toLowerCase() == label.toLowerCase(),
);
if (exists) {
_controller.clear();
return;
}
widget.onChanged([...widget.tags, label]);
_controller.clear();
}
void _remove(String tag) {
widget.onChanged([
for (final item in widget.tags)
if (item.toLowerCase() != tag.toLowerCase()) item,
]);
}
@override
Widget build(BuildContext context) {
final unused = [
for (final tag in widget.suggestions)
if (!widget.tags.any((item) => item.toLowerCase() == tag.toLowerCase()))
tag,
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.tags.isNotEmpty) ...[
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final tag in widget.tags)
InputChip(
label: Text(tag),
onDeleted: widget.enabled ? () => _remove(tag) : null,
),
],
),
const SizedBox(height: 8),
],
TextField(
controller: _controller,
enabled: widget.enabled,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Add tag',
hintText: 'weekly, bulk, work…',
),
onSubmitted: widget.enabled ? _add : null,
),
if (widget.enabled && unused.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final tag in unused.take(12))
ActionChip(label: Text(tag), onPressed: () => _add(tag)),
],
),
],
],
);
}
}
class _TagChip extends StatelessWidget {
const _TagChip({
required this.label,
required this.selected,
required this.compact,
this.onPressed,
});
final String label;
final bool selected;
final bool compact;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final bg = selected ? scheme.primary : scheme.secondaryContainer;
final fg = selected ? scheme.onPrimary : scheme.onSecondaryContainer;
return Material(
color: bg,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : 8,
vertical: compact ? 2 : 4,
),
child: Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: fg,
fontWeight: FontWeight.w600,
fontSize: compact ? 11 : 12,
),
),
),
),
);
}
}
+64
View File
@@ -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:
@@ -240,6 +256,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
google_mlkit_commons:
dependency: transitive
description:
@@ -280,6 +304,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:
@@ -440,6 +472,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72
url: "https://pub.dev"
source: hosted
version: "0.19.2"
objective_c:
dependency: transitive
description:
@@ -536,6 +576,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:
@@ -605,6 +653,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.5.11"
sqflite_common_ffi:
dependency: "direct dev"
description:
name: sqflite_common_ffi
sha256: d5564f1308cafbf064be498f2beb1e993e742985a7cca9e2e228d6cbccd84a05
url: "https://pub.dev"
source: hosted
version: "2.4.2+1"
sqflite_darwin:
dependency: transitive
description:
@@ -621,6 +677,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c"
url: "https://pub.dev"
source: hosted
version: "3.5.2"
stack_trace:
dependency: transitive
description:
+4 -1
View File
@@ -1,4 +1,4 @@
name: receipity
name: groci
description: Capture a shopping receipt and show Google ML Kit text recognition output.
publish_to: 'none'
version: 1.0.0+1
@@ -18,13 +18,16 @@ 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:
sdk: flutter
flutter_lints: ^6.0.0
sqflite_common_ffi: ^2.4.0
flutter:
uses-material-design: true
assets:
- assets/receipt.jpeg
- receipt-parsing-keywords.json
+192
View File
@@ -0,0 +1,192 @@
{
"_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"
]
},
"spatial_parsing": {
"_comment": "Bounding-box pipeline settings. row_cluster_height_factor multiplies median element height when grouping elements into rows (only used when ML Kit lines are unavailable — keep below 0.6 so adjacent receipt lines stay separate). price_column_width_fraction is the right-hand slice of receipt content width treated as the price column.",
"row_cluster_height_factor": 0.55,
"price_column_width_fraction": 0.25
}
}
+306
View File
@@ -0,0 +1,306 @@
# 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 structured OCR from ML Kit: **blocks → lines → elements**, each
element carrying its own pixel bounding box (`left`, `top`, `right`,
`bottom`) on the receipt image. Do **not** rely on the flattened `.text`
property for parsing — it discards the two-column layout.
- 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.
- Receipt photos vary in resolution and crop; all column thresholds must be
computed **relative to the detected content width**, not as fixed pixels.
## 2. Preprocessing (run before classification)
1. Extract every text **element** with its bounding box from ML Kit.
2. **Rebuild rows** — do not trust ML Kit's line segmentation:
- Sort elements by Y-center (top to bottom).
- Cluster into rows where Y-centers fall within one line height of each
other. Threshold = `median_element_height × row_cluster_height_factor`
(default factor **1.2**, configurable in `spatial_parsing`).
- Within each row, sort elements left-to-right by X position.
3. Join element text within a row (single spaces) for keyword/regex checks.
4. Trim leading/trailing whitespace; collapse multiple internal spaces to one.
5. Normalize decimal separators: treat `,` as the decimal point in any number
matching `\d+[,.]\d{2}`. Convert to `.` internally for storage.
6. Do NOT drop short rows outright (e.g. a lone `BROCCOLI` or a lone
`2 X 0,95`) — both are meaningful in different receipt layouts.
## 3. Core row classification
Classify every **row** (not flat OCR line) into one of these categories.
Within a row, use **X-position column logic** alongside regex:
- Compute content width = rightmost − leftmost element across the receipt.
- **Price column** = rightmost `price_column_width_fraction` (default **25%**)
of that width. A row has a price when a token in the price column matches
`\d+[,.]\d{2}`. Everything left of the price column is the candidate name.
- A row with no price-column match is **name-only**.
Check categories in this priority order (first match wins):
### 3.0 QUANTITY_MODIFIER (checked first — unconditional)
- If the **entire row** matches (not a substring):
`^\d+\s*[xX]\s*\d+[,.]\d{2}\s*(PER\s+STUK|PER\s+KG|ST\.?|STUKS?)?$`
- Classify as QUANTITY_MODIFIER **unconditionally**. A pure `N X price` row
can never be a standalone product, even if a price-column token is present.
### 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-row, belongs to previous or next ITEM)
See **§3.0** — pure `N X price` rows are detected before any other type.
The rules below describe how they attach to items:
- This row does NOT introduce a new product — it modifies the item on
an adjacent row.
- 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)
Uses a **one-row lookahead buffer** — do not emit an item until the next
row is inspected (or the receipt ends).
```
items = []
pending_item = None # name found, price/qty not yet resolved
for row in classified_rows:
if row.type == QUANTITY_MODIFIER:
if pending_item:
pending_item.quantity = row.qty
pending_item.unit_price = row.price
pending_item.line_total = row.qty * row.price
merge bounding boxes from row into pending_item
items.append(pending_item)
pending_item = None
continue # orphan modifier without pending → ignore
if row.type == NAME_ONLY:
if pending_item:
items.append(pending_item with needs_review if no price)
pending_item = { name: row.name, unit_price: None, quantity: 1,
line_total: None, discount: 0, bounds: row.bounds }
peek next row:
if next.type == QUANTITY_MODIFIER → defer emit (handled above)
else → append pending_item flagged needs_review, clear pending
continue
if row.type == ITEM:
if pending_item:
items.append(pending_item with needs_review if no price)
pending_item = { name: row.name, unit_price: row.price,
quantity: 1, line_total: row.price,
discount: 0, bounds: row.bounds }
peek next row:
if next.type == QUANTITY_MODIFIER → defer emit (modifier overrides qty/unit)
else → append pending_item, clear pending
continue
if row.type == DISCOUNT:
... (unchanged: attach to pending_item or receipt.total_discount)
if row.type in (SUBTOTAL, TOTAL):
flush pending_item
if is_grand_total(row):
receipt.total = row.amount
break
flush any trailing pending_item
```
Each emitted item keeps **source bounding box(es)** (normalized 0–1 on the
receipt image) for highlight overlays in the review UI.
## 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 the receipt prints an item count (e.g. `Aantal Artikel: N`), compare
`N` to the number of parsed items.
5. If totals match within 0.01 **and** item count matches (when present)
**and** no item is flagged `needs_manual_review` → high confidence.
6. If any check fails → mark the **whole receipt** as "needs review".
## 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.
+52 -4
View File
@@ -1,10 +1,10 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/services/backup_format.dart';
import 'package:groci/services/backup_format.dart';
void main() {
test('round-trips a backup document', () {
final original = const BackupDocument(
version: 1,
const original = BackupDocument(
version: 3,
exportedAt: '2026-08-29T12:00:00.000',
darkMode: true,
supermarkets: [
@@ -29,6 +29,7 @@ void main() {
{
'id': 8,
'supermarket': 'Jumbo',
'name': 'Weekly shop',
'shopped_at': '2026-08-29T12:00:00.000',
'receipt_image_path': 'receipts/shop_8.jpg',
'total': 1.5,
@@ -46,6 +47,27 @@ void main() {
'unit_price': 1.5,
},
],
shopTags: [
{'id': 1, 'shop_id': 8, 'tag': 'weekly', 'normalized_tag': 'weekly'},
],
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'},
);
@@ -53,11 +75,37 @@ void main() {
expect(parsed.darkMode, isTrue);
expect(parsed.products.single['name'], 'Milk');
expect(parsed.shops.single['id'], 8);
expect(parsed.shops.single['name'], 'Weekly shop');
expect(parsed.shopItems.single['product_id'], 1);
expect(parsed.shopTags.single['tag'], 'weekly');
expect(parsed.shoppingLists.single['name'], 'Weekly');
expect(parsed.shoppingListItems.single['name'], 'Milk');
expect(parsed.images['products/product_1.jpg'], 'abc123');
expect(parsed.encode(), contains('"format": "groci-backup"'));
});
test('rejects a file that is not a Receipity backup', () {
test('accepts legacy receipity backup format', () {
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);
expect(parsed.shopTags, isEmpty);
});
test('rejects a file that is not a Groci backup', () {
expect(
() => BackupDocument.parse('{"hello":"world"}'),
throwsFormatException,
+111
View File
@@ -0,0 +1,111 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:groci/models/line_item.dart';
import 'test_helpers.dart';
void main() {
ensureTestSqlite();
test(
'deleting a product keeps the trip line and drops the catalog row',
() async {
final repository = memoryRepository();
final shopId = await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 12),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
final products = await repository.getAll();
expect(products, hasLength(1));
await repository.deleteProduct(products.single.id);
expect(await repository.getAll(), isEmpty);
final items = await repository.getShopItems(shopId);
expect(items, hasLength(1));
expect(items.single.name, 'Milk');
expect(items.single.productId, isNull);
},
);
test('addShopItem appends a line and updates the trip total', () async {
final repository = memoryRepository();
final shopId = await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 12),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
await repository.addShopItem(
shopId: shopId,
item: LineItem(id: '2', name: 'Bread', price: 2),
);
final shop = (await repository.getShop(shopId))!;
expect(shop.total, 3.5);
final items = await repository.getShopItems(shopId);
expect(items, hasLength(2));
expect(items.map((item) => item.name), ['Milk', 'Bread']);
expect((await repository.getAll()).map((product) => product.name).toSet(), {
'Milk',
'Bread',
});
});
test('getLastTripSupermarkets lists unique stores newest first', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Albert Heijn',
shoppedAt: DateTime(2026, 8, 12),
items: [LineItem(id: '2', name: 'Bread', price: 2)],
);
expect(await repository.getLastTripSupermarkets(), [
'Albert Heijn',
'Jumbo',
]);
});
test('wipeAllData clears products, trips, lists, and settings', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 12),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
await repository.createShoppingList('Weekly');
await repository.setDarkMode(true);
await repository.wipeAllData();
expect(await repository.getAll(), isEmpty);
expect(await repository.getShops(), isEmpty);
expect(await repository.getShoppingLists(), isEmpty);
expect(await repository.getDarkMode(), isFalse);
expect((await repository.getSupermarkets()).length, greaterThan(0));
});
test('deleting all products leaves shopping trips in place', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 12),
items: [
LineItem(id: '1', name: 'Milk', price: 1.5),
LineItem(id: '2', name: 'Bread', price: 2),
],
);
await repository.deleteAllProducts();
expect(await repository.getAll(), isEmpty);
expect(await repository.getShops(), hasLength(1));
final overview = await repository.getOverview();
expect(overview.productCount, 0);
expect(overview.tripCount, 1);
});
}
+5 -2
View File
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/utils/fuzzy.dart';
import 'package:groci/utils/fuzzy.dart';
void main() {
test('matches substrings and character subsequences', () {
@@ -10,6 +10,9 @@ void main() {
});
test('ranks closer matches higher', () {
expect(fuzzyScore('melk', 'MELK')! > fuzzyScore('melk', 'JUMBO BIO VOLLE MELK')!, isTrue);
expect(
fuzzyScore('melk', 'MELK')! > fuzzyScore('melk', 'JUMBO BIO VOLLE MELK')!,
isTrue,
);
});
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:groci/models/line_item.dart';
import 'package:groci/widgets/item_edit_sheet.dart';
void main() {
testWidgets('edit sheet syncs quantity unit and total', (tester) async {
LineItem? saved;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) {
return TextButton(
onPressed: () async {
saved = await showItemEditSheet(
context,
item: LineItem(
id: '1',
name: 'Milk',
price: 3.98,
quantity: 2,
unitPrice: 1.99,
),
);
},
child: const Text('Open'),
);
},
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('Edit item'), findsOneWidget);
expect(find.text('Qty'), findsOneWidget);
expect(find.text('Unit price'), findsOneWidget);
expect(find.text('Total price'), findsOneWidget);
expect(find.text('2 × € 1.99'), findsOneWidget);
await tester.enterText(find.widgetWithText(TextField, 'Qty'), '3');
await tester.pump();
expect(find.text('3 × € 1.99'), findsOneWidget);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(saved, isNotNull);
expect(saved!.name, 'Milk');
expect(saved!.quantity, 3);
expect(saved!.unitPrice, 1.99);
expect(saved!.price, closeTo(5.97, 0.001));
});
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:groci/models/line_item.dart';
import 'package:groci/models/product.dart';
import 'package:groci/utils/product_names.dart';
import 'test_helpers.dart';
Product _product(int id, String name, {String? supermarket}) => Product(
id: id,
name: name,
lastPrice: 1,
timesSeen: 1,
updatedAt: DateTime(2026, 1, 1),
supermarket: supermarket,
);
void main() {
ensureTestSqlite();
group('nameConflictGroups', () {
test('groups products that differ only by case at the same store', () {
final groups = nameConflictGroups([
_product(1, 'Broccoli', supermarket: 'Jumbo'),
_product(2, 'BROCCOLI', supermarket: 'Jumbo'),
_product(3, 'Milk', supermarket: 'Jumbo'),
]);
expect(groups, hasLength(1));
expect(groups.single.map((product) => product.id), [1, 2]);
});
test('ignores same name at different stores', () {
final groups = nameConflictGroups([
_product(1, 'Broccoli', supermarket: 'Jumbo'),
_product(2, 'BROCCOLI', supermarket: 'Lidl'),
]);
expect(groups, isEmpty);
});
});
group('buildNameConflictGroups', () {
test('finds conflicts from trip lines linked to different products', () {
final groups = buildNameConflictGroups(
products: [
_product(1, 'Broccoli', supermarket: 'Jumbo'),
_product(2, 'BROCCOLI', supermarket: 'Jumbo'),
],
shopLines: const [
(productId: 1, name: 'Broccoli', supermarket: 'Jumbo'),
(productId: 2, name: 'BROCCOLI', supermarket: 'Jumbo'),
],
);
expect(groups, hasLength(1));
expect(groups.single.products.map((product) => product.id), [1, 2]);
expect(groups.single.supermarket, 'Jumbo');
});
});
group('findSpellingVariants', () {
test('finds multiple receipt spellings for one product at one store', () {
final variants = findSpellingVariants(
products: [_product(1, 'Broccoli', supermarket: 'Jumbo')],
shopLines: const [
(productId: 1, name: 'Broccoli', supermarket: 'Jumbo'),
(productId: 1, name: 'BROCCOLI', supermarket: 'Jumbo'),
],
);
expect(variants, hasLength(1));
expect(variants.single.product.id, 1);
expect(variants.single.supermarket, 'Jumbo');
expect(variants.single.variants, ['Broccoli', 'BROCCOLI']);
});
test('ignores different spellings at different stores', () {
final variants = findSpellingVariants(
products: [_product(1, 'Broccoli', supermarket: 'Jumbo')],
shopLines: const [
(productId: 1, name: 'Broccoli', supermarket: 'Jumbo'),
(productId: 1, name: 'BROCCOLI', supermarket: 'Lidl'),
],
);
expect(variants, isEmpty);
});
});
test('same name at different supermarkets creates separate products', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Lidl',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'Milk', price: 1.2)],
);
final products = await repository.getAll();
expect(products, hasLength(2));
expect(products.map((p) => p.supermarket).toSet(), {'Jumbo', 'Lidl'});
expect(await repository.getProductNameIssues(), isEmpty);
});
test('getNameConflictGroups finds duplicates in the database', () async {
final repository = memoryRepository();
await seedCaseConflictCatalog(repository);
final groups = await repository.getNameConflictGroups();
expect(groups, hasLength(1));
expect(groups.single.map((product) => product.name).toSet(), {
'Broccoli',
'BROCCOLI',
});
});
test('getProductNameIssues finds receipt spelling variants', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)],
);
final issues = await repository.getProductNameIssues();
expect(issues.mergeGroups, isEmpty);
expect(issues.spellingVariants, hasLength(1));
expect(issues.spellingVariants.single.supermarket, 'Jumbo');
expect(issues.spellingVariants.single.variants, ['Broccoli', 'BROCCOLI']);
});
test('dismissNameIssue hides a spelling variant', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)],
);
final issue =
(await repository.getProductNameIssues()).spellingVariants.single;
await repository.dismissNameIssue(issue.key);
expect(await repository.getProductNameIssues(), isEmpty);
});
test('mergeProducts combines purchases onto the kept product', () async {
final repository = memoryRepository();
await seedCaseConflictCatalog(repository);
await repository.mergeProducts(keepId: 1, sourceIds: [2]);
final after = await repository.getAll();
expect(after, hasLength(1));
expect(after.single.name, 'Broccoli');
expect(after.single.timesSeen, 2);
expect(await repository.getPurchases(1), hasLength(2));
});
test('unifyProductSpelling updates catalog and receipt lines', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)],
);
final product = (await repository.getAll()).single;
await repository.unifyProductSpelling(
productId: product.id,
name: 'BROCCOLI',
supermarket: 'Jumbo',
);
final updated = (await repository.getProduct(product.id))!;
expect(updated.name, 'BROCCOLI');
expect((await repository.getProductNameIssues()).isEmpty, isTrue);
});
}
+141 -9
View File
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/services/receipt_parser.dart';
import 'package:groci/models/ocr_element.dart';
import 'package:groci/services/receipt_parser.dart';
void main() {
test('parses name and price at the end of a line', () {
@@ -15,6 +16,35 @@ CHANGE 1.50
expect(items.map((item) => item.price), [1.50, 2.00]);
});
test('parses name on one line and price on the next', () {
final items = ReceiptParser.parse('''
MILK
1,50
BREAD
2,00
Totaal 3,50
''');
expect(items, hasLength(2));
expect(items.map((item) => item.name), ['MILK', 'BREAD']);
expect(items.map((item) => item.price), [1.50, 2.00]);
});
test('pairs two name lines with two price lines from short column OCR', () {
final items = ReceiptParser.parse('''
Jumbo
MILK
BREAD
1,50
2,00
Totaal 3,50
''');
expect(items, hasLength(2));
expect(items.map((item) => item.name), ['MILK', 'BREAD']);
expect(items.map((item) => item.price), [1.50, 2.00]);
});
test('uses the next line when quantity and total are split', () {
final items = ReceiptParser.parse('''
EGGS
@@ -44,17 +74,60 @@ 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.items.single.originalPrice, 7.98);
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,14 +139,73 @@ 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));
});
test('scan path uses column parser for Jumbo name-then-price OCR dump', () {
const ocr = '''
BIO VASTKOKEND AARD
2 X 1,99
AARDBEIEN
2 X 3,99
ACTIE AARDBEIEN
BROCCOLII
PREI
CITROENEN
KOMKOMMER
2 X 0,95
ACTIE KOMKOMMER
BOL KNOFLOOK
BROODSTICKS OLIJF
4 X 1.25
3,98
7,98
-2,00
1,24
0,69
2,11
1,90
-0,21
1,79
5,00
''';
final result = ReceiptParser.parseReceiptFromOcr(
OcrDocument(
elements: const [],
lines: const [],
imageWidth: 800,
imageHeight: 1200,
flattenedText: ocr,
),
);
expect(result.items, hasLength(8));
expect(result.items[0].name, 'BIO VASTKOKEND AARD');
expect(result.items[0].quantity, 2);
expect(result.items[0].price, 3.98);
expect(result.items[1].name, 'AARDBEIEN');
expect(result.items[1].price, 5.98);
expect(result.items[1].discount, -2.00);
expect(result.items[5].name, 'KOMKOMMER');
expect(result.items[5].price, 1.69);
expect(result.items.last.name, 'BROODSTICKS OLIJF');
expect(result.items.last.quantity, 4);
expect(result.items.last.price, 5.00);
expect(result.items.every((item) => !item.needsManualReview), isTrue);
});
}
const _jumboColumnOcr = '''
+195
View File
@@ -0,0 +1,195 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:groci/models/ocr_element.dart';
import 'package:groci/services/receipt_parse_config.dart';
import 'package:groci/services/receipt_parser.dart';
import 'package:groci/services/receipt_row_builder.dart';
import 'package:groci/services/receipt_spatial_parser.dart';
/// Synthetic two-column receipt layout (800×1200 image).
const _imageWidth = 800.0;
const _imageHeight = 1200.0;
const _nameLeft = 40.0;
const _priceLeft = 620.0;
const _rowHeight = 24.0;
const _rowGap = 8.0;
OcrElement _word(String text, double row, {bool price = false}) {
final y = row * (_rowHeight + _rowGap) + 100;
final left = price ? _priceLeft : _nameLeft;
final width = text.length * 10.0 + 20;
return OcrElement(
text: text,
left: left,
top: y,
right: left + width,
bottom: y + _rowHeight,
);
}
OcrLine _line(List<OcrElement> elements) {
var left = double.infinity;
var top = double.infinity;
var right = 0.0;
var bottom = 0.0;
for (final e in elements) {
if (e.left < left) left = e.left;
if (e.top < top) top = e.top;
if (e.right > right) right = e.right;
if (e.bottom > bottom) bottom = e.bottom;
}
return OcrLine(
text: elements.map((e) => e.text).join(' '),
elements: elements,
left: left,
top: top,
right: right,
bottom: bottom,
);
}
OcrDocument _document(
List<List<OcrElement>> rows, {
String? flattened,
}) {
final elements = [for (final row in rows) ...row];
final lines = [for (final row in rows) _line(row)];
return OcrDocument(
elements: elements,
lines: lines,
imageWidth: _imageWidth,
imageHeight: _imageHeight,
flattenedText: flattened ?? lines.map((l) => l.text).join('\n'),
);
}
void main() {
setUpAll(() async {
await ReceiptParser.loadConfig();
});
group('ReceiptRowBuilder', () {
test('clusters name and price on the same row by Y position', () {
final rows = ReceiptRowBuilder(receiptParseConfig).buildRows(
_document([
[_word('MILK', 0), _word('1,50', 0, price: true)],
]),
);
expect(rows, hasLength(1));
expect(rows.single.kind.name, 'item');
expect(rows.single.nameText, 'MILK');
expect(rows.single.price, 1.50);
});
test('classifies pure quantity row before item logic', () {
final rows = ReceiptRowBuilder(receiptParseConfig).buildRows(
_document([
[_word('2', 1), _word('X', 1), _word('2,99', 1, price: true)],
], flattened: '2 X 2,99'),
);
expect(rows, hasLength(1));
expect(rows.single.kind.name, 'quantityModifier');
expect(rows.single.quantity, 2);
expect(rows.single.unitPrice, 2.99);
});
test('treats row without price-column token as name-only', () {
final rows = ReceiptRowBuilder(receiptParseConfig).buildRows(
_document([
[_word('BROCCOLI', 0)],
]),
);
expect(rows.single.kind.name, 'nameOnly');
expect(rows.single.nameText, 'BROCCOLI');
});
test('does not merge adjacent ML Kit lines into one row', () {
final rows = ReceiptRowBuilder(receiptParseConfig).buildRows(
_document([
[_word('BIO', 0), _word('VASTKOKEND', 0), _word('AARD', 0)],
[_word('2', 1), _word('X', 1), _word('1,99', 1, price: true)],
]),
);
expect(rows, hasLength(2));
expect(rows[0].nameText, contains('BIO'));
expect(rows[1].kind.name, 'quantityModifier');
});
});
group('ReceiptSpatialParser lookahead', () {
test('merges Jumbo-style name row with following quantity modifier', () {
final result = ReceiptSpatialParser.parse(
_document([
[_word('AARDBEIEN', 0)],
[_word('2', 1), _word('X', 1), _word('3,99', 1, price: true)],
[_word('Totaal', 2), _word('7,98', 2, price: true)],
]),
);
expect(result.items, hasLength(1));
expect(result.items.single.name, 'AARDBEIEN');
expect(result.items.single.quantity, 2);
expect(result.items.single.unitPrice, 3.99);
expect(result.items.single.price, 7.98);
expect(result.items.single.sourceBounds, isNotNull);
});
test('overrides inline price when quantity modifier follows (Kruidvat)', () {
final result = ReceiptSpatialParser.parse(
_document([
[_word('ZEEPTABLET', 0), _word('NEUTRAL', 0), _word('7,98', 0, price: true)],
[_word('2', 1), _word('X', 1), _word('3,99', 1, price: true)],
]),
);
expect(result.items, hasLength(1));
expect(result.items.single.quantity, 2);
expect(result.items.single.unitPrice, 3.99);
expect(result.items.single.price, 7.98);
});
test('flags name-only row without following modifier for manual review', () {
final result = ReceiptSpatialParser.parse(
_document([
[_word('MILK', 0)],
[_word('BREAD', 1), _word('2,00', 1, price: true)],
]),
);
expect(result.items, hasLength(2));
expect(result.items[0].name, 'MILK');
expect(result.items[0].needsManualReview, isTrue);
expect(result.items[1].name, 'BREAD');
expect(result.items[1].price, 2.00);
});
test('parses two side-by-side items without cross-assigning prices', () {
final result = ReceiptSpatialParser.parse(
_document([
[_word('MILK', 0), _word('1,50', 0, price: true)],
[_word('BREAD', 1), _word('2,00', 1, price: true)],
[_word('Totaal', 2), _word('3,50', 2, price: true)],
]),
);
expect(result.items.map((i) => i.name), ['MILK', 'BREAD']);
expect(result.items.map((i) => i.price), [1.50, 2.00]);
expect(result.validationPassed, isTrue);
});
test('fails validation when printed item count does not match', () {
final result = ReceiptSpatialParser.parse(
_document([
[_word('MILK', 0), _word('1,50', 0, price: true)],
[_word('Aantal Artikel: 3', 1)],
]),
);
expect(result.expectedItemCount, 3);
expect(result.validationPassed, isFalse);
});
});
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:groci/models/shop.dart';
import 'package:groci/utils/shop_calendar.dart';
import 'package:groci/utils/shop_log_filter.dart';
Shop _shop({
required int id,
required DateTime at,
String supermarket = 'Jumbo',
String? name,
List<String> tags = const [],
}) {
return Shop(
id: id,
supermarket: supermarket,
name: name,
shoppedAt: at,
total: 10,
itemCount: 1,
tags: tags,
);
}
void main() {
group('monthGrid', () {
test('pads August 2026 from Saturday with Monday-first weeks', () {
final cells = monthGrid(DateTime(2026, 8));
expect(cells.length % 7, 0);
expect(cells[0], isNull);
expect(cells[1], isNull);
expect(cells[2], isNull);
expect(cells[3], isNull);
expect(cells[4], isNull);
expect(cells[5], DateTime(2026, 8, 1));
expect(cells[35], DateTime(2026, 8, 31));
expect(cells.last, isNull);
});
});
test('tripCountsByDay groups multiple trips on the same day', () {
final counts = tripCountsByDay([
DateTime(2026, 8, 12, 9),
DateTime(2026, 8, 12, 18),
DateTime(2026, 8, 13),
]);
expect(counts[DateTime(2026, 8, 12)], 2);
expect(counts[DateTime(2026, 8, 13)], 1);
expect(heatmapLevel(0), 0);
expect(heatmapLevel(1), 1);
expect(heatmapLevel(4), 4);
expect(heatmapLevel(9), 4);
});
group('filterShops', () {
final shops = [
_shop(id: 1, at: DateTime(2026, 8, 12), tags: const ['weekly']),
_shop(
id: 2,
at: DateTime(2026, 8, 13),
name: 'Work lunch',
tags: const ['work'],
),
];
const names = {
1: ['Milk', 'Bread'],
2: ['Coffee'],
};
test('filters by product name', () {
final found = filterShops(
shops: shops,
productNames: names,
query: 'milk',
);
expect(found.map((shop) => shop.id), [1]);
});
test('filters by selected day', () {
final found = filterShops(
shops: shops,
productNames: names,
selectedDay: DateTime(2026, 8, 13),
);
expect(found.single.id, 2);
});
test('filters by tag', () {
final found = filterShops(
shops: shops,
productNames: names,
tag: 'weekly',
);
expect(found.single.id, 1);
});
});
}
+30 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/utils/supermarkets.dart';
import 'package:groci/models/supermarket.dart';
import 'package:groci/utils/supermarkets.dart';
void main() {
test('detects Jumbo and Lidl from receipt text', () {
@@ -20,4 +21,32 @@ void main() {
test('returns null when no supermarket is present', () {
expect(detectSupermarket('Milk 1.50\nBread 2.00'), isNull);
});
test('sortStoresByLastTrip puts the latest trip store first', () {
const jumbo = Supermarket(id: 1, name: 'Jumbo', colorValue: 1, sortOrder: 0);
const lidl = Supermarket(id: 2, name: 'Lidl', colorValue: 2, sortOrder: 1);
const ah = Supermarket(
id: 3,
name: 'Albert Heijn',
colorValue: 3,
sortOrder: 2,
);
final afterJumbo = sortStoresByLastTrip([jumbo, lidl, ah], ['Jumbo']);
expect(afterJumbo.map((store) => store.name), [
'Jumbo',
'Lidl',
'Albert Heijn',
]);
final afterAh = sortStoresByLastTrip(
[jumbo, lidl, ah],
['Albert Heijn', 'Jumbo'],
);
expect(afterAh.map((store) => store.name), [
'Albert Heijn',
'Jumbo',
'Lidl',
]);
});
}
+108
View File
@@ -0,0 +1,108 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:groci/services/product_repository.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
var _dbSeq = 0;
var _sqliteReady = false;
void ensureTestSqlite() {
if (_sqliteReady) return;
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
_sqliteReady = true;
}
ProductRepository memoryRepository() {
ensureTestSqlite();
final dir = Directory(p.join('.dart_tool', 'groci_test_db'))
..createSync(recursive: true);
final name =
'db_${DateTime.now().microsecondsSinceEpoch}_${_dbSeq++}_${Random().nextInt(1 << 32)}.sqlite';
return ProductRepository(databasePath: p.join(dir.path, name));
}
Future<void> pumpUntilFound(
WidgetTester tester,
Finder finder, {
int attempts = 40,
}) async {
for (var i = 0; i < attempts; i++) {
await tester.pump();
if (finder.evaluate().isNotEmpty) return;
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 20)),
);
}
throw TestFailure('Timed out waiting for $finder');
}
const _seedTs = '2026-08-01T00:00:00.000';
Future<void> seedCaseConflictCatalog(ProductRepository repository) async {
await repository.replaceAllData(
darkMode: false,
supermarkets: [
{'id': 1, 'name': 'Jumbo', 'color': 0xFFEEC21B, 'sort_order': 0},
],
products: [
{
'id': 1,
'name': 'Broccoli',
'normalized_name': 'BROCCOLI_A',
'last_price': 1.5,
'times_seen': 1,
'favourite': 0,
'supermarket': 'Jumbo',
'created_at': _seedTs,
'updated_at': _seedTs,
},
{
'id': 2,
'name': 'BROCCOLI',
'normalized_name': 'BROCCOLI_B',
'last_price': 1.8,
'times_seen': 1,
'favourite': 0,
'supermarket': 'Jumbo',
'created_at': _seedTs,
'updated_at': _seedTs,
},
],
shops: [
{
'id': 1,
'supermarket': 'Jumbo',
'shopped_at': '2026-08-01T00:00:00.000',
'total': 1.5,
'created_at': _seedTs,
},
{
'id': 2,
'supermarket': 'Jumbo',
'shopped_at': '2026-08-02T00:00:00.000',
'total': 1.8,
'created_at': _seedTs,
},
],
shopItems: [
{
'id': 1,
'shop_id': 1,
'product_id': 1,
'name': 'Broccoli',
'price': 1.5,
},
{
'id': 2,
'shop_id': 2,
'product_id': 2,
'name': 'BROCCOLI',
'price': 1.8,
},
],
);
}
+291 -13
View File
@@ -1,27 +1,192 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/main.dart';
import 'package:receipity/models/line_item.dart';
import 'package:receipity/review_screen.dart';
import 'package:receipity/services/product_repository.dart';
import 'package:groci/main.dart';
import 'package:groci/models/line_item.dart';
import 'package:groci/review_screen.dart';
import 'package:groci/widgets/shop_heatmap.dart';
import 'test_helpers.dart';
void main() {
testWidgets('scan screen shows capture actions and bottom navigation', (
ensureTestSqlite();
testWidgets('home screen shows overview and bottom navigation', (
tester,
) async {
await tester.pumpWidget(ReceipityApp());
await tester.pumpWidget(GrociApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Nothing logged yet.'));
expect(find.text('Receipity'), findsOneWidget);
expect(find.text('Home'), findsWidgets);
expect(find.text('Products'), findsOneWidget);
expect(find.text('Lists'), findsOneWidget);
expect(find.byTooltip('Add'), findsOneWidget);
expect(find.byIcon(Icons.add), findsWidgets);
expect(find.text('Shop log'), findsOneWidget);
expect(find.byTooltip('Profile'), findsOneWidget);
expect(find.byTooltip('Settings'), findsOneWidget);
});
testWidgets('scan screen shows capture actions', (tester) async {
await tester.pumpWidget(GrociApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.byTooltip('Add'));
await tester.pump();
expect(find.text('Groci'), 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('Shop log'), findsOneWidget);
});
testWidgets('empty shop log hides edit and disables search', (tester) async {
await tester.pumpWidget(GrociApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Shop log'));
await pumpUntilFound(
tester,
find.text('Scan a receipt or enter a trip to log it.'),
);
expect(find.byTooltip('Edit'), findsNothing);
expect(find.byTooltip('Search products'), findsOneWidget);
expect(
tester
.widget<IconButton>(
find.ancestor(
of: find.byIcon(Icons.search),
matching: find.byType(IconButton),
),
)
.onPressed,
isNull,
);
});
testWidgets('deleting a trip removes it without leaving the screen', (
tester,
) async {
final repository = memoryRepository();
await tester.runAsync(() async {
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime.now(),
name: 'Weekly shop',
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
});
await tester.pumpWidget(GrociApp(repository: repository));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Shop log'));
await pumpUntilFound(tester, find.text('Weekly shop'));
await tester.tap(find.byTooltip('Edit'));
await tester.pump();
await tester.tap(find.byTooltip('Remove'));
await tester.pump();
await tester.tap(find.text('Delete'));
await tester.pump();
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
expect(find.text('Weekly shop'), findsNothing);
expect(
find.text('Scan a receipt or enter a trip to log it.'),
findsOneWidget,
);
expect(find.byTooltip('Edit'), findsNothing);
});
testWidgets('products edit mode shows delete, star, and wipe all', (
tester,
) async {
final repository = memoryRepository();
await tester.runAsync(() async {
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime.now(),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
});
await tester.pumpWidget(GrociApp(repository: repository));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Products').last);
await pumpUntilFound(tester, find.text('Milk'));
await tester.tap(find.byTooltip('Edit'));
await tester.pump();
expect(find.byTooltip('Delete'), findsOneWidget);
expect(find.byTooltip('Favourite'), findsOneWidget);
expect(find.byTooltip('Delete all products'), findsOneWidget);
await tester.tap(find.byTooltip('Delete all products'));
await tester.pump();
await tester.tap(find.text('Delete'));
await tester.pump();
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
expect(find.text('Milk'), findsNothing);
expect(find.byTooltip('Edit'), findsNothing);
});
testWidgets('profile screen is a placeholder', (tester) async {
await tester.pumpWidget(GrociApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.byTooltip('Profile'));
await pumpUntilFound(tester, find.text('Guest'));
});
testWidgets('shop heatmap renders the visible month', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ShopHeatmap(
month: DateTime(2026, 8),
counts: const {},
monthTotal: 0,
monthTripCount: 0,
canGoForward: false,
onPreviousMonth: () {},
onNextMonth: () {},
onSelectDay: (_) {},
),
),
),
);
expect(find.text('August 2026'), findsOneWidget);
expect(find.text('No trips this month'), findsOneWidget);
expect(find.text('Less'), findsOneWidget);
expect(find.text('More'), findsOneWidget);
});
testWidgets('enter manually opens a blank trip', (tester) async {
await tester.pumpWidget(GrociApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.byTooltip('Add'));
await tester.pump();
await tester.tap(find.text('Enter manually'));
await pumpUntilFound(tester, find.text('New trip'));
expect(find.text('Add item'), findsOneWidget);
});
testWidgets('lists tab shows shopping lists', (tester) async {
await tester.pumpWidget(GrociApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Overview'));
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 {
@@ -33,7 +198,7 @@ void main() {
LineItem(id: '2', name: 'Bread', price: 2),
],
rawText: 'Milk 1.50\nBread 2.00',
repository: ProductRepository(databasePath: ':memory:'),
repository: memoryRepository(),
),
),
);
@@ -43,6 +208,8 @@ void main() {
expect(find.text('Add item'), findsOneWidget);
expect(find.textContaining('Save trip'), findsOneWidget);
expect(find.text('Supermarket'), findsOneWidget);
expect(find.text('Trip name'), findsOneWidget);
expect(find.text('Add tag'), findsOneWidget);
await tester.drag(find.text('Milk'), const Offset(-500, 0));
await tester.pumpAndSettle();
@@ -58,11 +225,122 @@ void main() {
home: ReviewScreen(
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
rawText: 'Jumbo\nMilk 1.50',
repository: ProductRepository(databasePath: ':memory:'),
repository: memoryRepository(),
),
),
);
expect(find.text('Jumbo'), findsWidgets);
});
testWidgets('review screen strikes through the pre-discount price', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: ReviewScreen(
items: [
LineItem(id: '1', name: 'Berries', price: 6.85, discount: -1.15),
],
rawText: 'Berries 8.00\nKorting -1.15',
repository: memoryRepository(),
),
),
);
expect(find.text('€ 8.00'), findsOneWidget);
expect(find.text('€ 6.85'), findsOneWidget);
final original = tester.widget<Text>(find.text('€ 8.00'));
expect(original.style?.decoration, TextDecoration.lineThrough);
});
testWidgets('product name is saved when leaving the screen', (tester) async {
final repository = memoryRepository();
await tester.runAsync(() async {
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime.now(),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
});
await tester.pumpWidget(GrociApp(repository: repository));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Products').last);
await pumpUntilFound(tester, find.text('Milk'));
await tester.tap(find.text('Milk'));
await pumpUntilFound(tester, find.text('Name'));
await tester.enterText(find.byType(TextField).first, 'Oat milk');
await tester.tap(find.byTooltip('Back'));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 150)),
);
await tester.pump();
expect(find.byType(TextField), findsNothing);
expect(find.text('Oat milk'), findsOneWidget);
expect(find.text('Milk'), findsNothing);
});
testWidgets('products screen marks case-only duplicates', (tester) async {
final repository = memoryRepository();
await tester.runAsync(() => seedCaseConflictCatalog(repository));
await tester.pumpWidget(GrociApp(repository: repository));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Products').last);
await pumpUntilFound(tester, find.text('1 duplicate product'));
expect(find.text('Broccoli'), findsOneWidget);
expect(find.text('BROCCOLI'), findsOneWidget);
expect(find.byTooltip('Resolve name issues'), findsWidgets);
});
testWidgets('products screen marks receipt spelling variants', (
tester,
) async {
final repository = memoryRepository();
await tester.runAsync(() async {
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)],
);
});
await tester.pumpWidget(GrociApp(repository: repository));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Products').last);
await pumpUntilFound(tester, find.text('1 product, multiple spellings'));
expect(find.text('Broccoli'), findsOneWidget);
expect(find.byTooltip('Resolve name issues'), findsWidgets);
});
testWidgets('resolve name issues sheet shows merge and leave as is', (
tester,
) async {
final repository = memoryRepository();
await tester.runAsync(() => seedCaseConflictCatalog(repository));
await tester.pumpWidget(GrociApp(repository: repository));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Products').last);
await pumpUntilFound(tester, find.text('1 duplicate product'));
await tester.tap(find.text('1 duplicate product'));
await pumpUntilFound(tester, find.text('Resolve name issues'));
expect(find.text('Resolve name issues'), findsOneWidget);
expect(find.text('Separate products to merge'), findsOneWidget);
expect(find.text('Broccoli'), findsWidgets);
expect(find.text('BROCCOLI'), findsWidgets);
expect(find.text('Merge'), findsOneWidget);
expect(find.text('Leave as is'), findsOneWidget);
});
}