Compare commits
12
Commits
v0.2
..
98574af903
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98574af903 | ||
|
|
e98fb0e7f4 | ||
|
|
d532af6111 | ||
|
|
cdf46bb12a | ||
|
|
edbfade477 | ||
|
|
b0708101b0 | ||
|
|
14fcb9a533 | ||
|
|
756f9db356 | ||
|
|
7692809219 | ||
|
|
92581e6d55 | ||
|
|
3b698a3667 | ||
|
|
35d5c90d81 |
@@ -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.
|
Flutter app for scanning shopping receipts, tracking products and prices, shopping lists, and bonus cards.
|
||||||
|
|
||||||
No database, no saving, no other screens.
|
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -11,4 +9,4 @@ flutter pub get
|
|||||||
flutter run
|
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.
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ android {
|
|||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
// Flutter turns R8 on for release. That strips ML Kit classes
|
||||||
|
// discovered via manifest metadata / JNI, so OCR fails in the APK.
|
||||||
|
isMinifyEnabled = false
|
||||||
|
isShrinkResources = false
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
|
|||||||
Vendored
+15
-10
@@ -1,10 +1,15 @@
|
|||||||
# ML Kit text recognition ships Latin only; the plugin still references
|
# ML Kit text recognition is wired up through manifest metadata, reflection,
|
||||||
# optional script packs that are not in this app.
|
# and JNI. R8 otherwise strips those classes and OCR fails in release APKs.
|
||||||
-dontwarn com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions$Builder
|
-keep class com.google.mlkit.** { *; }
|
||||||
-dontwarn com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions
|
-keep class com.google.android.gms.internal.mlkit_** { *; }
|
||||||
-dontwarn com.google.mlkit.vision.text.devanagari.DevanagariTextRecognizerOptions$Builder
|
-keep class com.google.android.gms.internal.mlkit_vision_** { *; }
|
||||||
-dontwarn com.google.mlkit.vision.text.devanagari.DevanagariTextRecognizerOptions
|
-keep class com.google.android.libraries.vision.** { *; }
|
||||||
-dontwarn com.google.mlkit.vision.text.japanese.JapaneseTextRecognizerOptions$Builder
|
-keep class com.google_mlkit_commons.** { *; }
|
||||||
-dontwarn com.google.mlkit.vision.text.japanese.JapaneseTextRecognizerOptions
|
-keep class com.google_mlkit_text_recognition.** { *; }
|
||||||
-dontwarn com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions$Builder
|
-keep class * implements com.google.firebase.components.ComponentRegistrar { *; }
|
||||||
-dontwarn com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions
|
|
||||||
|
# The Flutter plugin references optional script packs that this app does not ship.
|
||||||
|
-dontwarn com.google.mlkit.vision.text.chinese.**
|
||||||
|
-dontwarn com.google.mlkit.vision.text.devanagari.**
|
||||||
|
-dontwarn com.google.mlkit.vision.text.japanese.**
|
||||||
|
-dontwarn com.google.mlkit.vision.text.korean.**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||||
<application
|
<application
|
||||||
android:label="Receipity"
|
android:label="Groci"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
# Flutter enables R8 for release by default. That breaks on-device ML Kit OCR.
|
||||||
|
shrink=false
|
||||||
# This newDsl flag was added by the Flutter template
|
# This newDsl flag was added by the Flutter template
|
||||||
android.newDsl=false
|
android.newDsl=false
|
||||||
# This builtInKotlin flag was added by the Flutter template
|
# This builtInKotlin flag was added by the Flutter template
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,13 +1,15 @@
|
|||||||
/// Bottom navigation: products, scan, and shop log.
|
/// Bottom navigation: home, products, lists, add, and shop log.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'home_screen.dart';
|
||||||
import 'products_screen.dart';
|
import 'products_screen.dart';
|
||||||
import 'scan_screen.dart';
|
import 'scan_screen.dart';
|
||||||
import 'services/app_settings.dart';
|
import 'services/app_settings.dart';
|
||||||
import 'services/product_repository.dart';
|
import 'services/product_repository.dart';
|
||||||
import 'shop_log_screen.dart';
|
import 'shop_log_screen.dart';
|
||||||
|
import 'shopping_lists_screen.dart';
|
||||||
|
|
||||||
class HomeShell extends StatefulWidget {
|
class HomeShell extends StatefulWidget {
|
||||||
const HomeShell({super.key, required this.repository});
|
const HomeShell({super.key, required this.repository});
|
||||||
@@ -19,10 +21,14 @@ class HomeShell extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeShellState extends State<HomeShell> {
|
class _HomeShellState extends State<HomeShell> {
|
||||||
static const _scanIndex = 1;
|
static const _homeIndex = 0;
|
||||||
static const _shopLogIndex = 2;
|
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;
|
int _revision = 0;
|
||||||
AppSettings? _settings;
|
AppSettings? _settings;
|
||||||
int _seenGeneration = 0;
|
int _seenGeneration = 0;
|
||||||
@@ -59,21 +65,39 @@ class _HomeShellState extends State<HomeShell> {
|
|||||||
});
|
});
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context)
|
||||||
const SnackBar(content: Text('Shopping trip saved.')),
|
.showSnackBar(const SnackBar(content: Text('Shopping trip saved.')));
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _select(int index) {
|
||||||
|
if (index == _index) return;
|
||||||
|
setState(() => _index = index);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: switch (_index) {
|
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'),
|
key: ValueKey('products-$_revision'),
|
||||||
repository: widget.repository,
|
repository: widget.repository,
|
||||||
),
|
),
|
||||||
1 => ScanScreen(
|
_listsIndex => ShoppingListsScreen(
|
||||||
|
key: ValueKey('lists-$_revision'),
|
||||||
|
repository: widget.repository,
|
||||||
|
),
|
||||||
|
_addIndex => ScanScreen(
|
||||||
repository: widget.repository,
|
repository: widget.repository,
|
||||||
onTripSaved: _onTripSaved,
|
onTripSaved: _onTripSaved,
|
||||||
),
|
),
|
||||||
@@ -82,24 +106,120 @@ class _HomeShellState extends State<HomeShell> {
|
|||||||
repository: widget.repository,
|
repository: widget.repository,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
bottomNavigationBar: NavigationBar(
|
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
||||||
selectedIndex: _index,
|
floatingActionButton: FloatingActionButton.small(
|
||||||
onDestinationSelected: (index) => setState(() => _index = index),
|
onPressed: () => _select(_addIndex),
|
||||||
destinations: const [
|
tooltip: 'Add',
|
||||||
NavigationDestination(
|
elevation: _index == _addIndex ? 4 : 1,
|
||||||
icon: Icon(Icons.inventory_2_outlined),
|
shape: const CircleBorder(),
|
||||||
selectedIcon: Icon(Icons.inventory_2),
|
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',
|
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',
|
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
@@ -6,25 +6,27 @@ import 'package:flutter/material.dart';
|
|||||||
import 'home_shell.dart';
|
import 'home_shell.dart';
|
||||||
import 'services/app_settings.dart';
|
import 'services/app_settings.dart';
|
||||||
import 'services/product_repository.dart';
|
import 'services/product_repository.dart';
|
||||||
|
import 'services/receipt_parser.dart';
|
||||||
import 'utils/app_theme.dart';
|
import 'utils/app_theme.dart';
|
||||||
import 'utils/constants.dart';
|
import 'utils/constants.dart';
|
||||||
|
|
||||||
void main() {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
runApp(ReceipityApp());
|
await ReceiptParser.loadConfig();
|
||||||
|
runApp(GrociApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
class ReceipityApp extends StatefulWidget {
|
class GrociApp extends StatefulWidget {
|
||||||
ReceipityApp({super.key, ProductRepository? repository})
|
GrociApp({super.key, ProductRepository? repository})
|
||||||
: repository = repository ?? ProductRepository();
|
: repository = repository ?? ProductRepository();
|
||||||
|
|
||||||
final ProductRepository repository;
|
final ProductRepository repository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ReceipityApp> createState() => _ReceipityAppState();
|
State<GrociApp> createState() => _GrociAppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ReceipityAppState extends State<ReceipityApp> {
|
class _GrociAppState extends State<GrociApp> {
|
||||||
late final AppSettings _settings;
|
late final AppSettings _settings;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
/// A parsed receipt row shown on the review screen.
|
/// A parsed receipt row shown on the review screen.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'receipt_bounds.dart';
|
||||||
|
|
||||||
class LineItem {
|
class LineItem {
|
||||||
LineItem({
|
LineItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -8,21 +10,47 @@ class LineItem {
|
|||||||
required this.price,
|
required this.price,
|
||||||
this.quantity,
|
this.quantity,
|
||||||
this.unitPrice,
|
this.unitPrice,
|
||||||
|
this.discount = 0,
|
||||||
|
this.sourceBounds,
|
||||||
|
this.needsManualReview = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
|
|
||||||
|
/// Line total after any discount.
|
||||||
final double price;
|
final double price;
|
||||||
final int? quantity;
|
final int? quantity;
|
||||||
final double? unitPrice;
|
final double? unitPrice;
|
||||||
|
|
||||||
|
/// Negative amount when a discount was folded into [price].
|
||||||
|
final double discount;
|
||||||
|
|
||||||
|
/// 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({
|
LineItem copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
String? name,
|
String? name,
|
||||||
double? price,
|
double? price,
|
||||||
int? quantity,
|
int? quantity,
|
||||||
double? unitPrice,
|
double? unitPrice,
|
||||||
|
double? discount,
|
||||||
|
ReceiptBounds? sourceBounds,
|
||||||
|
bool? needsManualReview,
|
||||||
bool clearQuantity = false,
|
bool clearQuantity = false,
|
||||||
|
bool clearSourceBounds = false,
|
||||||
}) {
|
}) {
|
||||||
return LineItem(
|
return LineItem(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -30,6 +58,9 @@ class LineItem {
|
|||||||
price: price ?? this.price,
|
price: price ?? this.price,
|
||||||
quantity: clearQuantity ? null : (quantity ?? this.quantity),
|
quantity: clearQuantity ? null : (quantity ?? this.quantity),
|
||||||
unitPrice: clearQuantity ? null : (unitPrice ?? this.unitPrice),
|
unitPrice: clearQuantity ? null : (unitPrice ?? this.unitPrice),
|
||||||
|
discount: discount ?? this.discount,
|
||||||
|
sourceBounds: clearSourceBounds ? null : (sourceBounds ?? this.sourceBounds),
|
||||||
|
needsManualReview: needsManualReview ?? this.needsManualReview,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All positioned elements from one ML Kit recognition pass.
|
||||||
|
class OcrDocument {
|
||||||
|
const OcrDocument({
|
||||||
|
required this.elements,
|
||||||
|
required this.imageWidth,
|
||||||
|
required this.imageHeight,
|
||||||
|
required this.flattenedText,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<OcrElement> elements;
|
||||||
|
final double imageWidth;
|
||||||
|
final double imageHeight;
|
||||||
|
final String flattenedText;
|
||||||
|
}
|
||||||
@@ -72,4 +72,4 @@ class Product {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ProductSort { name, preferred }
|
enum ProductSort { name, preferred, favourite }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -8,22 +8,47 @@ class Shop {
|
|||||||
required this.shoppedAt,
|
required this.shoppedAt,
|
||||||
required this.total,
|
required this.total,
|
||||||
required this.itemCount,
|
required this.itemCount,
|
||||||
|
this.name,
|
||||||
this.receiptImagePath,
|
this.receiptImagePath,
|
||||||
|
this.tags = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
final int id;
|
final int id;
|
||||||
final String supermarket;
|
final String supermarket;
|
||||||
|
final String? name;
|
||||||
final DateTime shoppedAt;
|
final DateTime shoppedAt;
|
||||||
final double total;
|
final double total;
|
||||||
final int itemCount;
|
final int itemCount;
|
||||||
final String? receiptImagePath;
|
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) {
|
factory Shop.fromMap(Map<String, Object?> map) {
|
||||||
|
final rawName = (map['name'] as String?)?.trim();
|
||||||
return Shop(
|
return Shop(
|
||||||
id: map['id']! as int,
|
id: map['id']! as int,
|
||||||
supermarket: map['supermarket']! as String,
|
supermarket: map['supermarket']! as String,
|
||||||
|
name: (rawName == null || rawName.isEmpty) ? null : rawName,
|
||||||
shoppedAt: DateTime.parse(map['shopped_at']! as String),
|
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,
|
itemCount: (map['item_count'] as num?)?.toInt() ?? 0,
|
||||||
receiptImagePath: map['receipt_image_path'] as String?,
|
receiptImagePath: map['receipt_image_path'] as String?,
|
||||||
);
|
);
|
||||||
@@ -34,7 +59,7 @@ class ShopItem {
|
|||||||
const ShopItem({
|
const ShopItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.shopId,
|
required this.shopId,
|
||||||
required this.productId,
|
this.productId,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.price,
|
required this.price,
|
||||||
this.quantity,
|
this.quantity,
|
||||||
@@ -43,7 +68,7 @@ class ShopItem {
|
|||||||
|
|
||||||
final int id;
|
final int id;
|
||||||
final int shopId;
|
final int shopId;
|
||||||
final int productId;
|
final int? productId;
|
||||||
final String name;
|
final String name;
|
||||||
final double price;
|
final double price;
|
||||||
final int? quantity;
|
final int? quantity;
|
||||||
@@ -53,9 +78,9 @@ class ShopItem {
|
|||||||
return ShopItem(
|
return ShopItem(
|
||||||
id: map['id']! as int,
|
id: map['id']! as int,
|
||||||
shopId: map['shop_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,
|
name: map['name']! as String,
|
||||||
price: (map['price']! as num).toDouble(),
|
price: (map['price'] as num).toDouble(),
|
||||||
quantity: map['quantity'] as int?,
|
quantity: map['quantity'] as int?,
|
||||||
unitPrice: (map['unit_price'] as num?)?.toDouble(),
|
unitPrice: (map['unit_price'] as num?)?.toDouble(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,14 @@ class Supermarket {
|
|||||||
required this.name,
|
required this.name,
|
||||||
required this.colorValue,
|
required this.colorValue,
|
||||||
this.sortOrder = 0,
|
this.sortOrder = 0,
|
||||||
|
this.bonusImagePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int? id;
|
final int? id;
|
||||||
final String name;
|
final String name;
|
||||||
final int colorValue;
|
final int colorValue;
|
||||||
final int sortOrder;
|
final int sortOrder;
|
||||||
|
final String? bonusImagePath;
|
||||||
|
|
||||||
Color get color => Color(colorValue);
|
Color get color => Color(colorValue);
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ class Supermarket {
|
|||||||
name: map['name']! as String,
|
name: map['name']! as String,
|
||||||
colorValue: map['color']! as int,
|
colorValue: map['color']! as int,
|
||||||
sortOrder: map['sort_order'] as int? ?? 0,
|
sortOrder: map['sort_order'] as int? ?? 0,
|
||||||
|
bonusImagePath: map['bonus_image_path'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,16 +35,47 @@ class Supermarket {
|
|||||||
String? name,
|
String? name,
|
||||||
int? colorValue,
|
int? colorValue,
|
||||||
int? sortOrder,
|
int? sortOrder,
|
||||||
|
String? bonusImagePath,
|
||||||
|
bool clearBonusImage = false,
|
||||||
}) {
|
}) {
|
||||||
return Supermarket(
|
return Supermarket(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
colorValue: colorValue ?? this.colorValue,
|
colorValue: colorValue ?? this.colorValue,
|
||||||
sortOrder: sortOrder ?? this.sortOrder,
|
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.
|
/// Palette offered when the user picks a store color.
|
||||||
const List<int> kStoreColorPalette = [
|
const List<int> kStoreColorPalette = [
|
||||||
0xFFEEC21B, // Jumbo yellow
|
0xFFEEC21B, // Jumbo yellow
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'utils/categories.dart';
|
|||||||
import 'utils/dates.dart';
|
import 'utils/dates.dart';
|
||||||
import 'utils/money.dart';
|
import 'utils/money.dart';
|
||||||
import 'utils/supermarkets.dart';
|
import 'utils/supermarkets.dart';
|
||||||
|
import 'widgets/pick_shopping_list_sheet.dart';
|
||||||
import 'widgets/price_chart.dart';
|
import 'widgets/price_chart.dart';
|
||||||
import 'widgets/product_image.dart';
|
import 'widgets/product_image.dart';
|
||||||
import 'widgets/store_tag.dart';
|
import 'widgets/store_tag.dart';
|
||||||
@@ -37,6 +38,7 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
late final TextEditingController _notesController;
|
late final TextEditingController _notesController;
|
||||||
final ImagePicker _picker = ImagePicker();
|
final ImagePicker _picker = ImagePicker();
|
||||||
bool _ready = false;
|
bool _ready = false;
|
||||||
|
bool _leaving = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -48,7 +50,7 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_saveNotes();
|
_saveEdits();
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_notesController.dispose();
|
_notesController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -69,7 +71,18 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _reload() {
|
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 {
|
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 {
|
Future<void> _toggleFavourite(Product product) async {
|
||||||
await widget.repository.setFavourite(product.id, !product.favourite);
|
await widget.repository.setFavourite(product.id, !product.favourite);
|
||||||
if (mounted) _reload();
|
if (mounted) _reload();
|
||||||
@@ -169,6 +191,22 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
if (mounted) _reload();
|
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 {
|
Future<void> _pickCategory(Product product) async {
|
||||||
final used = await widget.repository.getUsedCategories();
|
final used = await widget.repository.getUsedCategories();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -238,10 +276,8 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
Future<void> _openShop(int shopId) async {
|
Future<void> _openShop(int shopId) async {
|
||||||
await Navigator.of(context).push<void>(
|
await Navigator.of(context).push<void>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => ShopDetailScreen(
|
builder: (_) =>
|
||||||
repository: widget.repository,
|
ShopDetailScreen(repository: widget.repository, shopId: shopId),
|
||||||
shopId: shopId,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (mounted) _reload();
|
if (mounted) _reload();
|
||||||
@@ -250,10 +286,16 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final stores = SettingsScope.maybeOf(context)?.supermarkets ??
|
final stores =
|
||||||
kDefaultSupermarkets;
|
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')),
|
appBar: AppBar(title: const Text('Product')),
|
||||||
body: FutureBuilder(
|
body: FutureBuilder(
|
||||||
future: _future,
|
future: _future,
|
||||||
@@ -317,13 +359,15 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
textCapitalization: TextCapitalization.sentences,
|
textCapitalization:
|
||||||
|
TextCapitalization.sentences,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Name',
|
labelText: 'Name',
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _saveName(),
|
onSubmitted: (_) => _saveName(),
|
||||||
onEditingComplete: _saveName,
|
onEditingComplete: _saveName,
|
||||||
|
onTapOutside: (_) => _saveName(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -351,7 +395,8 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
child: product.supermarket == null
|
child: product.supermarket == null
|
||||||
? Text(
|
? Text(
|
||||||
'Add supermarket',
|
'Add supermarket',
|
||||||
style: theme.textTheme.labelMedium?.copyWith(
|
style: theme.textTheme.labelMedium
|
||||||
|
?.copyWith(
|
||||||
color: theme.colorScheme.primary,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -381,6 +426,15 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
onPressed: () => _pickCategory(product),
|
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),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'Price and purchase history',
|
'Price and purchase history',
|
||||||
@@ -405,7 +459,8 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
[
|
[
|
||||||
formatDate(purchase.boughtAt),
|
formatDate(purchase.boughtAt),
|
||||||
purchase.supermarket,
|
purchase.supermarket,
|
||||||
if (purchase.quantity != null) '${purchase.quantity}×',
|
if (purchase.quantity != null)
|
||||||
|
'${purchase.quantity}×',
|
||||||
].join(' · '),
|
].join(' · '),
|
||||||
),
|
),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
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),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||||
onPressed: _save,
|
|
||||||
child: const Text('Save'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+246
-56
@@ -4,12 +4,17 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'models/product.dart';
|
import 'models/product.dart';
|
||||||
|
import 'models/supermarket.dart';
|
||||||
import 'product_detail_screen.dart';
|
import 'product_detail_screen.dart';
|
||||||
import 'services/app_settings.dart';
|
import 'services/app_settings.dart';
|
||||||
import 'services/product_repository.dart';
|
import 'services/product_repository.dart';
|
||||||
import 'utils/fuzzy.dart';
|
import 'utils/fuzzy.dart';
|
||||||
import 'utils/money.dart';
|
import 'utils/money.dart';
|
||||||
|
import 'utils/product_names.dart';
|
||||||
|
import 'widgets/confirm_dialog.dart';
|
||||||
import 'widgets/product_image.dart';
|
import 'widgets/product_image.dart';
|
||||||
|
import 'widgets/product_name_conflicts.dart';
|
||||||
|
import 'widgets/profile_button.dart';
|
||||||
import 'widgets/settings_button.dart';
|
import 'widgets/settings_button.dart';
|
||||||
import 'widgets/store_tag.dart';
|
import 'widgets/store_tag.dart';
|
||||||
|
|
||||||
@@ -24,33 +29,56 @@ class ProductsScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _ProductsScreenState extends State<ProductsScreen> {
|
class _ProductsScreenState extends State<ProductsScreen> {
|
||||||
ProductSort _sort = ProductSort.preferred;
|
ProductSort _sort = ProductSort.preferred;
|
||||||
bool _favouritesOnly = false;
|
|
||||||
bool _searching = false;
|
bool _searching = false;
|
||||||
|
bool _editing = false;
|
||||||
final TextEditingController _search = TextEditingController();
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_future = _load();
|
widget.repository.addListener(_load);
|
||||||
_search.addListener(() => setState(() {}));
|
_search.addListener(() => setState(() {}));
|
||||||
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
widget.repository.removeListener(_load);
|
||||||
_search.dispose();
|
_search.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Product>> _load() {
|
Future<void> _load() async {
|
||||||
return widget.repository.getAll(
|
final token = ++_loadToken;
|
||||||
sort: _sort,
|
try {
|
||||||
favouritesOnly: _favouritesOnly,
|
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) {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
|
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
|
||||||
|
final products = _products == null
|
||||||
|
? const <Product>[]
|
||||||
|
: _filter(_products!);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -106,68 +219,92 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
|||||||
)
|
)
|
||||||
: const Text('Products'),
|
: const Text('Products'),
|
||||||
actions: [
|
actions: [
|
||||||
|
if (!_editing)
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: _searching ? 'Close search' : 'Search',
|
tooltip: _searching ? 'Close search' : 'Search',
|
||||||
onPressed: () {
|
onPressed: _hasProducts
|
||||||
|
? () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_searching = !_searching;
|
_searching = !_searching;
|
||||||
if (!_searching) _search.clear();
|
if (!_searching) _search.clear();
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
|
: null,
|
||||||
icon: Icon(_searching ? Icons.close : Icons.search),
|
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(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: SegmentedButton<ProductSort>(
|
child: SegmentedButton<ProductSort>(
|
||||||
segments: const [
|
segments: const [
|
||||||
ButtonSegment(
|
|
||||||
value: ProductSort.preferred,
|
|
||||||
label: Text('Preferred'),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: ProductSort.name,
|
value: ProductSort.name,
|
||||||
label: Text('A–Z'),
|
label: Text('A–Z'),
|
||||||
),
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: ProductSort.preferred,
|
||||||
|
label: Text('Frequent'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: ProductSort.favourite,
|
||||||
|
icon: Icon(Icons.star),
|
||||||
|
label: Text('Star'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
selected: {_sort},
|
selected: {_sort},
|
||||||
onSelectionChanged: (selected) {
|
onSelectionChanged: (selected) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_sort = selected.first;
|
_sort = selected.first;
|
||||||
_future = _load();
|
|
||||||
});
|
});
|
||||||
|
_load();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
if (_nameIssues.isNotEmpty && !_editing)
|
||||||
IconButton.filledTonal(
|
ProductNameConflictBanner(
|
||||||
tooltip: 'Favourites',
|
issues: _nameIssues,
|
||||||
isSelected: _favouritesOnly,
|
onTap: _resolveNameIssues,
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_favouritesOnly = !_favouritesOnly;
|
|
||||||
_future = _load();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
icon: Icon(
|
|
||||||
_favouritesOnly ? Icons.star : Icons.star_outline,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
Expanded(child: _buildList(theme, stores, products)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
Expanded(
|
}
|
||||||
child: FutureBuilder<List<Product>>(
|
|
||||||
future: _future,
|
Widget _buildList(
|
||||||
builder: (context, snapshot) {
|
ThemeData theme,
|
||||||
if (snapshot.hasError) {
|
List<Supermarket> stores,
|
||||||
|
List<Product> products,
|
||||||
|
) {
|
||||||
|
if (_error != null && _products == null) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Could not load products.',
|
'Could not load products.',
|
||||||
@@ -175,15 +312,13 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!snapshot.hasData) {
|
if (_products == null) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
if (_products!.isEmpty) {
|
||||||
final products = _filter(snapshot.data!);
|
|
||||||
if (snapshot.data!.isEmpty) {
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
_favouritesOnly
|
_sort == ProductSort.favourite
|
||||||
? 'Star products to keep them as favourites.'
|
? 'Star products to keep them as favourites.'
|
||||||
: 'Confirmed receipt items will show up here.',
|
: 'Confirmed receipt items will show up here.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -207,6 +342,7 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
|||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final product = products[index];
|
final product = products[index];
|
||||||
|
final hasIssue = _issueProductIds.contains(product.id);
|
||||||
return ListTile(
|
return ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
@@ -214,11 +350,59 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
|||||||
horizontal: 8,
|
horizontal: 8,
|
||||||
vertical: 0,
|
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,
|
name: product.name,
|
||||||
path: product.imagePath,
|
path: product.imagePath,
|
||||||
size: 36,
|
size: 36,
|
||||||
),
|
),
|
||||||
|
if (hasIssue && !_editing)
|
||||||
|
Positioned(
|
||||||
|
right: -2,
|
||||||
|
top: -2,
|
||||||
|
child: ProductNameConflictMarker(
|
||||||
|
compact: true,
|
||||||
|
onTap: () =>
|
||||||
|
_resolveNameIssues(focusProductId: product.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
product.name,
|
product.name,
|
||||||
maxLines: 1,
|
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),
|
formatEuro(product.lastPrice),
|
||||||
style: theme.textTheme.titleMedium,
|
style: theme.textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
onTap: () => _openProduct(product),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
onTap: _editing ? null : () => _openProduct(product),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+201
-126
@@ -1,6 +1,8 @@
|
|||||||
/// Editable list of parsed receipt lines before they are saved as a shop trip.
|
/// Editable list of parsed receipt lines before they are saved as a shop trip.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'models/line_item.dart';
|
import 'models/line_item.dart';
|
||||||
@@ -9,7 +11,11 @@ import 'services/product_repository.dart';
|
|||||||
import 'utils/dates.dart';
|
import 'utils/dates.dart';
|
||||||
import 'utils/money.dart';
|
import 'utils/money.dart';
|
||||||
import 'utils/supermarkets.dart';
|
import 'utils/supermarkets.dart';
|
||||||
|
import 'widgets/discounted_price.dart';
|
||||||
|
import 'widgets/item_edit_sheet.dart';
|
||||||
|
import 'widgets/receipt_highlight_overlay.dart';
|
||||||
import 'widgets/store_tag.dart';
|
import 'widgets/store_tag.dart';
|
||||||
|
import 'widgets/tag_editor.dart';
|
||||||
|
|
||||||
class ReviewScreen extends StatefulWidget {
|
class ReviewScreen extends StatefulWidget {
|
||||||
const ReviewScreen({
|
const ReviewScreen({
|
||||||
@@ -19,6 +25,10 @@ class ReviewScreen extends StatefulWidget {
|
|||||||
required this.repository,
|
required this.repository,
|
||||||
this.imagePath,
|
this.imagePath,
|
||||||
this.supermarketNames = kSupermarkets,
|
this.supermarketNames = kSupermarkets,
|
||||||
|
this.needsReview = false,
|
||||||
|
this.detectedStore,
|
||||||
|
this.ocrImageWidth,
|
||||||
|
this.ocrImageHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<LineItem> items;
|
final List<LineItem> items;
|
||||||
@@ -26,6 +36,10 @@ class ReviewScreen extends StatefulWidget {
|
|||||||
final ProductRepository repository;
|
final ProductRepository repository;
|
||||||
final String? imagePath;
|
final String? imagePath;
|
||||||
final List<String> supermarketNames;
|
final List<String> supermarketNames;
|
||||||
|
final bool needsReview;
|
||||||
|
final String? detectedStore;
|
||||||
|
final double? ocrImageWidth;
|
||||||
|
final double? ocrImageHeight;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ReviewScreen> createState() => _ReviewScreenState();
|
State<ReviewScreen> createState() => _ReviewScreenState();
|
||||||
@@ -37,6 +51,10 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
|||||||
String? _supermarket;
|
String? _supermarket;
|
||||||
String? _customStore;
|
String? _customStore;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
final _tripName = TextEditingController();
|
||||||
|
List<String> _tags = [];
|
||||||
|
List<String> _tagSuggestions = [];
|
||||||
|
String? _highlightedItemId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -49,7 +67,21 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
|||||||
);
|
);
|
||||||
if (detected != null) {
|
if (detected != null) {
|
||||||
_supermarket = detected;
|
_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 {
|
String? get _resolvedSupermarket {
|
||||||
@@ -62,13 +94,13 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
|||||||
return _supermarket;
|
return _supermarket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get _hasReceipt {
|
||||||
|
final path = widget.imagePath;
|
||||||
|
return path != null && path.isNotEmpty && File(path).existsSync();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _edit({LineItem? existing}) async {
|
Future<void> _edit({LineItem? existing}) async {
|
||||||
final result = await showModalBottomSheet<LineItem>(
|
final result = await showItemEditSheet(context, item: existing);
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
showDragHandle: true,
|
|
||||||
builder: (context) => _ItemEditSheet(item: existing),
|
|
||||||
);
|
|
||||||
if (result == null || !mounted) return;
|
if (result == null || !mounted) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -77,9 +109,20 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
|||||||
} else {
|
} else {
|
||||||
final index = _items.indexWhere((item) => item.id == existing.id);
|
final index = _items.indexWhere((item) => item.id == existing.id);
|
||||||
if (index >= 0) {
|
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 +137,29 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
|||||||
setState(() => _shoppedAt = picked);
|
setState(() => _shoppedAt = picked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openReceipt() async {
|
||||||
|
final path = widget.imagePath;
|
||||||
|
if (path == null || path.isEmpty) return;
|
||||||
|
final file = File(path);
|
||||||
|
if (!file.existsSync()) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return Dialog(
|
||||||
|
insetPadding: const EdgeInsets.all(16),
|
||||||
|
child: InteractiveViewer(
|
||||||
|
child: Image.file(file, fit: BoxFit.contain),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _confirm() async {
|
Future<void> _confirm() async {
|
||||||
final supermarket = _resolvedSupermarket;
|
final supermarket = _resolvedSupermarket;
|
||||||
if (supermarket == null) {
|
if (supermarket == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context)
|
||||||
const SnackBar(content: Text('Select a supermarket.')),
|
.showSnackBar(const SnackBar(content: Text('Select a supermarket.')));
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_items.isEmpty) {
|
if (_items.isEmpty) {
|
||||||
@@ -116,6 +176,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
|||||||
shoppedAt: _shoppedAt,
|
shoppedAt: _shoppedAt,
|
||||||
items: _items,
|
items: _items,
|
||||||
receiptImagePath: widget.imagePath,
|
receiptImagePath: widget.imagePath,
|
||||||
|
name: _tripName.text,
|
||||||
|
tags: _tags,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pop(true);
|
Navigator.of(context).pop(true);
|
||||||
@@ -140,9 +202,22 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
|||||||
if (_supermarket != null && !names.contains(_supermarket)) {
|
if (_supermarket != null && !names.contains(_supermarket)) {
|
||||||
names.add(_supermarket!);
|
names.add(_supermarket!);
|
||||||
}
|
}
|
||||||
|
final receiptPath = widget.imagePath;
|
||||||
|
final highlighted = _highlightedItemId == null
|
||||||
|
? null
|
||||||
|
: _items.cast<LineItem?>().firstWhere(
|
||||||
|
(item) => item?.id == _highlightedItemId,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Review items')),
|
appBar: AppBar(
|
||||||
|
title: Text(
|
||||||
|
widget.rawText.isEmpty && widget.imagePath == null
|
||||||
|
? 'New trip'
|
||||||
|
: 'Review items',
|
||||||
|
),
|
||||||
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
@@ -150,13 +225,27 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
if (widget.needsReview) ...[
|
||||||
|
Material(
|
||||||
|
color: theme.colorScheme.errorContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Text(
|
||||||
|
'Item count or totals do not match the receipt, or some lines need manual review. Check highlighted items before saving.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
key: ValueKey(_supermarket),
|
key: ValueKey(_supermarket),
|
||||||
initialValue: _supermarket,
|
initialValue: _supermarket,
|
||||||
hint: const Text('Select supermarket'),
|
hint: const Text('Select supermarket'),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(labelText: 'Supermarket'),
|
||||||
labelText: 'Supermarket',
|
|
||||||
),
|
|
||||||
items: [
|
items: [
|
||||||
for (final store in names)
|
for (final store in names)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
@@ -177,9 +266,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
|||||||
TextField(
|
TextField(
|
||||||
enabled: !_saving,
|
enabled: !_saving,
|
||||||
textCapitalization: TextCapitalization.words,
|
textCapitalization: TextCapitalization.words,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(labelText: 'Store name'),
|
||||||
labelText: 'Store name',
|
|
||||||
),
|
|
||||||
onChanged: (value) => _customStore = value,
|
onChanged: (value) => _customStore = value,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -195,16 +282,49 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _items.isEmpty
|
child: CustomScrollView(
|
||||||
? Center(
|
slivers: [
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_items.isEmpty)
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No line items found.\nAdd anything the parser missed.',
|
'No line items yet.\nAdd products for this trip.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: theme.textTheme.bodyLarge,
|
style: theme.textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: ListView.separated(
|
else
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||||
|
sliver: SliverList.separated(
|
||||||
itemCount: _items.length,
|
itemCount: _items.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -230,22 +350,68 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
title: Text(item.name),
|
selected: _highlightedItemId == item.id,
|
||||||
subtitle: item.quantity == null
|
selectedTileColor: theme.colorScheme.primaryContainer
|
||||||
|
.withValues(alpha: 0.35),
|
||||||
|
leading: item.sourceBounds != null && _hasReceipt
|
||||||
|
? IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_highlightedItemId == item.id
|
||||||
|
? Icons.visibility
|
||||||
|
: Icons.visibility_outlined,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
tooltip: 'Show on receipt',
|
||||||
|
onPressed: _saving
|
||||||
? null
|
? null
|
||||||
: Text(
|
: () => _toggleHighlight(item),
|
||||||
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
|
)
|
||||||
),
|
: item.needsManualReview
|
||||||
trailing: Text(
|
? Icon(
|
||||||
formatEuro(item.price),
|
Icons.flag_outlined,
|
||||||
style: theme.textTheme.titleMedium,
|
color: theme.colorScheme.error,
|
||||||
),
|
size: 20,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
title: Text(item.name),
|
||||||
|
subtitle: _itemSubtitle(item),
|
||||||
|
trailing: DiscountedPrice(item: item),
|
||||||
onTap: _saving ? null : () => _edit(existing: item),
|
onTap: _saving ? null : () => _edit(existing: item),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_hasReceipt)
|
||||||
|
ExpansionTile(
|
||||||
|
initiallyExpanded: highlighted?.sourceBounds != null,
|
||||||
|
title: const Text('See receipt'),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
child: highlighted?.sourceBounds != null
|
||||||
|
? ReceiptHighlightOverlay(
|
||||||
|
imagePath: receiptPath!,
|
||||||
|
bounds: highlighted!.sourceBounds!,
|
||||||
|
)
|
||||||
|
: GestureDetector(
|
||||||
|
onTap: _openReceipt,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Image.file(
|
||||||
|
File(receiptPath!),
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
height: 220,
|
||||||
|
width: double.infinity,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
if (widget.rawText.isNotEmpty)
|
if (widget.rawText.isNotEmpty)
|
||||||
ExpansionTile(
|
ExpansionTile(
|
||||||
title: const Text('Raw OCR text'),
|
title: const Text('Raw OCR text'),
|
||||||
@@ -274,9 +440,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
|||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _saving ? null : _confirm,
|
onPressed: _saving ? null : _confirm,
|
||||||
child: Text(
|
child: Text(
|
||||||
_saving
|
_saving ? 'Saving…' : 'Save trip · ${formatEuro(total)}',
|
||||||
? 'Saving…'
|
|
||||||
: 'Save trip · ${formatEuro(total)}',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -287,98 +451,9 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class _ItemEditSheet extends StatefulWidget {
|
Widget? _itemSubtitle(LineItem item) {
|
||||||
const _ItemEditSheet({this.item});
|
if (item.quantity == null) return null;
|
||||||
|
return Text('${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}');
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-30
@@ -2,26 +2,26 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
import 'crop_receipt_screen.dart';
|
||||||
|
import 'models/line_item.dart';
|
||||||
import 'review_screen.dart';
|
import 'review_screen.dart';
|
||||||
import 'services/app_settings.dart';
|
import 'services/app_settings.dart';
|
||||||
import 'services/product_repository.dart';
|
import 'services/product_repository.dart';
|
||||||
import 'services/receipt_parser.dart';
|
import 'services/receipt_parser.dart';
|
||||||
import 'utils/constants.dart';
|
import 'utils/constants.dart';
|
||||||
import 'utils/supermarkets.dart';
|
import 'utils/supermarkets.dart';
|
||||||
|
import 'widgets/profile_button.dart';
|
||||||
import 'widgets/settings_button.dart';
|
import 'widgets/settings_button.dart';
|
||||||
|
|
||||||
class ScanScreen extends StatefulWidget {
|
class ScanScreen extends StatefulWidget {
|
||||||
const ScanScreen({
|
const ScanScreen({super.key, required this.repository, this.onTripSaved});
|
||||||
super.key,
|
|
||||||
required this.repository,
|
|
||||||
this.onTripSaved,
|
|
||||||
});
|
|
||||||
|
|
||||||
final ProductRepository repository;
|
final ProductRepository repository;
|
||||||
final VoidCallback? onTripSaved;
|
final VoidCallback? onTripSaved;
|
||||||
@@ -51,31 +51,30 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
if (!mounted || response.isEmpty) return;
|
if (!mounted || response.isEmpty) return;
|
||||||
final file = response.file;
|
final file = response.file;
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
await _runOcr(file.path);
|
await _cropThenOcr(file.path);
|
||||||
}
|
}
|
||||||
} on UnimplementedError {
|
} on UnimplementedError {
|
||||||
// Desktop / platforms that do not support lost-data recovery.
|
// Desktop / platforms that do not support lost-data recovery.
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Widget tests and hosts without the image_picker plugin.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _capture(ImageSource source) async {
|
Future<void> _capture(ImageSource source) async {
|
||||||
final file = await _picker.pickImage(
|
final file = await _picker.pickImage(source: source, imageQuality: 95);
|
||||||
source: source,
|
|
||||||
imageQuality: 95,
|
|
||||||
);
|
|
||||||
if (file == null || !mounted) return;
|
if (file == null || !mounted) return;
|
||||||
await _runOcr(file.path);
|
await _cropThenOcr(file.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _scanSampleReceipt() async {
|
Future<void> _scanSampleReceipt() async {
|
||||||
try {
|
try {
|
||||||
final data = await rootBundle.load(kSampleReceiptAsset);
|
final data = await rootBundle.load(kSampleReceiptAsset);
|
||||||
final file = File(
|
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);
|
await file.writeAsBytes(data.buffer.asUint8List(), flush: true);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await _runOcr(file.path);
|
await _cropThenOcr(file.path);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -84,6 +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 {
|
Future<void> _runOcr(String imagePath) async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_busy = true;
|
_busy = true;
|
||||||
@@ -93,20 +103,37 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final recognizer = TextRecognizer(script: TextRecognitionScript.latin);
|
final recognizer = TextRecognizer(script: TextRecognitionScript.latin);
|
||||||
|
RecognizedText? recognized;
|
||||||
|
double imageWidth = 0;
|
||||||
|
double imageHeight = 0;
|
||||||
try {
|
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 inputImage = InputImage.fromFilePath(imagePath);
|
||||||
final recognized = await recognizer.processImage(inputImage);
|
recognized = await recognizer.processImage(inputImage);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_extractedText = recognized.text.trim();
|
_extractedText = recognized!.text.trim();
|
||||||
if (_extractedText.isEmpty) {
|
if (_extractedText.isEmpty) {
|
||||||
_error = 'No text found in this image.';
|
_error = 'No text found in this image.';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} on PlatformException catch (error) {
|
||||||
|
debugPrint('OCR failed: ${error.code} ${error.message}');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Could not read text from this image.';
|
_error = _ocrFailureMessage(error.message);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('OCR failed: $error');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = _ocrFailureMessage(error.toString());
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await recognizer.close();
|
await recognizer.close();
|
||||||
@@ -115,13 +142,48 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted && _extractedText.isNotEmpty) {
|
if (mounted && _extractedText.isNotEmpty && recognized != null) {
|
||||||
await _openReview(_extractedText);
|
await _openReview(
|
||||||
|
_extractedText,
|
||||||
|
imagePath: imagePath,
|
||||||
|
recognized: recognized,
|
||||||
|
imageWidth: imageWidth,
|
||||||
|
imageHeight: imageHeight,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openReview(String rawText) async {
|
Future<void> _enterManually() async {
|
||||||
final items = ReceiptParser.parse(rawText);
|
await _openReview('', items: const []);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _ocrFailureMessage(String? detail) {
|
||||||
|
const fallback = 'Could not read text from this image.';
|
||||||
|
if (detail == null || detail.trim().isEmpty) return fallback;
|
||||||
|
return '$fallback\n$detail';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openReview(
|
||||||
|
String rawText, {
|
||||||
|
List<LineItem>? items,
|
||||||
|
String? imagePath,
|
||||||
|
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;
|
if (!mounted) return;
|
||||||
final names =
|
final names =
|
||||||
SettingsScope.maybeOf(context)?.supermarkets.map((s) => s.name) ??
|
SettingsScope.maybeOf(context)?.supermarkets.map((s) => s.name) ??
|
||||||
@@ -129,11 +191,15 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
final saved = await Navigator.of(context).push<bool>(
|
final saved = await Navigator.of(context).push<bool>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => ReviewScreen(
|
builder: (_) => ReviewScreen(
|
||||||
items: items,
|
items: parsedItems ?? parsed!.items,
|
||||||
rawText: rawText,
|
rawText: rawText,
|
||||||
repository: widget.repository,
|
repository: widget.repository,
|
||||||
imagePath: _imagePath,
|
imagePath: imagePath ?? (items == null ? _imagePath : null),
|
||||||
supermarketNames: names.toList(),
|
supermarketNames: names.toList(),
|
||||||
|
needsReview: parsed != null && !parsed.validationPassed,
|
||||||
|
detectedStore: parsed?.store,
|
||||||
|
ocrImageWidth: parsed?.ocrImageWidth,
|
||||||
|
ocrImageHeight: parsed?.ocrImageHeight,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -149,7 +215,7 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text(kAppName),
|
title: const Text(kAppName),
|
||||||
actions: const [SettingsButton()],
|
actions: const [ProfileButton(), SettingsButton()],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -169,6 +235,12 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
label: const Text('Pick from gallery'),
|
label: const Text('Pick from gallery'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _busy ? null : _enterManually,
|
||||||
|
icon: const Icon(Icons.edit_note_outlined),
|
||||||
|
label: const Text('Enter manually'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: _busy ? null : _scanSampleReceipt,
|
onPressed: _busy ? null : _scanSampleReceipt,
|
||||||
icon: const Icon(Icons.receipt_long_outlined),
|
icon: const Icon(Icons.receipt_long_outlined),
|
||||||
@@ -185,13 +257,12 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const LinearProgressIndicator(),
|
const LinearProgressIndicator(),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text('Reading text…', style: theme.textTheme.bodyMedium),
|
||||||
'Reading text…',
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Expanded(child: _ResultPane(text: _extractedText, error: _error)),
|
Expanded(
|
||||||
|
child: _ResultPane(text: _extractedText, error: _error),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -223,7 +294,7 @@ class _ResultPane extends StatelessWidget {
|
|||||||
if (text.isEmpty) {
|
if (text.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Take a photo of a shopping receipt, or pick one from the gallery.\n\nConfirmed trips show up in Shop log.',
|
'Scan a receipt or enter a trip by hand.\n\nConfirmed trips show up in Shop log.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: theme.textTheme.bodyLarge,
|
style: theme.textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ class AppSettings extends ChangeNotifier {
|
|||||||
catalogGeneration++;
|
catalogGeneration++;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> wipeAllData() async {
|
||||||
|
await repository.wipeAllData();
|
||||||
|
darkMode = false;
|
||||||
|
supermarkets = List<Supermarket>.from(kDefaultSupermarkets);
|
||||||
|
catalogGeneration++;
|
||||||
|
notifyListeners();
|
||||||
|
await load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SettingsScope extends InheritedNotifier<AppSettings> {
|
class SettingsScope extends InheritedNotifier<AppSettings> {
|
||||||
@@ -84,6 +93,8 @@ class SettingsScope extends InheritedNotifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static AppSettings? maybeOf(BuildContext context) {
|
static AppSettings? maybeOf(BuildContext context) {
|
||||||
return context.dependOnInheritedWidgetOfExactType<SettingsScope>()?.notifier;
|
return context
|
||||||
|
.dependOnInheritedWidgetOfExactType<SettingsScope>()
|
||||||
|
?.notifier;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ library;
|
|||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
const int kBackupVersion = 1;
|
const int kBackupVersion = 3;
|
||||||
|
|
||||||
|
const String kBackupFormat = 'groci-backup';
|
||||||
|
const String kLegacyBackupFormat = 'receipity-backup';
|
||||||
|
|
||||||
class BackupDocument {
|
class BackupDocument {
|
||||||
const BackupDocument({
|
const BackupDocument({
|
||||||
@@ -14,7 +17,10 @@ class BackupDocument {
|
|||||||
required this.products,
|
required this.products,
|
||||||
required this.shops,
|
required this.shops,
|
||||||
required this.shopItems,
|
required this.shopItems,
|
||||||
|
required this.shoppingLists,
|
||||||
|
required this.shoppingListItems,
|
||||||
required this.images,
|
required this.images,
|
||||||
|
this.shopTags = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
final int version;
|
final int version;
|
||||||
@@ -24,10 +30,13 @@ class BackupDocument {
|
|||||||
final List<Map<String, Object?>> products;
|
final List<Map<String, Object?>> products;
|
||||||
final List<Map<String, Object?>> shops;
|
final List<Map<String, Object?>> shops;
|
||||||
final List<Map<String, Object?>> shopItems;
|
final List<Map<String, Object?>> shopItems;
|
||||||
|
final List<Map<String, Object?>> shopTags;
|
||||||
|
final List<Map<String, Object?>> shoppingLists;
|
||||||
|
final List<Map<String, Object?>> shoppingListItems;
|
||||||
final Map<String, String> images;
|
final Map<String, String> images;
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {
|
Map<String, Object?> toJson() => {
|
||||||
'format': 'receipity-backup',
|
'format': kBackupFormat,
|
||||||
'version': version,
|
'version': version,
|
||||||
'exportedAt': exportedAt,
|
'exportedAt': exportedAt,
|
||||||
'darkMode': darkMode,
|
'darkMode': darkMode,
|
||||||
@@ -35,6 +44,9 @@ class BackupDocument {
|
|||||||
'products': products,
|
'products': products,
|
||||||
'shops': shops,
|
'shops': shops,
|
||||||
'shopItems': shopItems,
|
'shopItems': shopItems,
|
||||||
|
'shopTags': shopTags,
|
||||||
|
'shoppingLists': shoppingLists,
|
||||||
|
'shoppingListItems': shoppingListItems,
|
||||||
'images': images,
|
'images': images,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,8 +57,9 @@ class BackupDocument {
|
|||||||
if (decoded is! Map<String, dynamic>) {
|
if (decoded is! Map<String, dynamic>) {
|
||||||
throw const FormatException('Backup is not a JSON object.');
|
throw const FormatException('Backup is not a JSON object.');
|
||||||
}
|
}
|
||||||
if (decoded['format'] != 'receipity-backup') {
|
final format = decoded['format'];
|
||||||
throw const FormatException('This file is not a Receipity backup.');
|
if (format != kBackupFormat && format != kLegacyBackupFormat) {
|
||||||
|
throw const FormatException('This file is not a Groci backup.');
|
||||||
}
|
}
|
||||||
final version = decoded['version'];
|
final version = decoded['version'];
|
||||||
if (version is! int || version < 1 || version > kBackupVersion) {
|
if (version is! int || version < 1 || version > kBackupVersion) {
|
||||||
@@ -60,6 +73,9 @@ class BackupDocument {
|
|||||||
products: _maps(decoded['products']),
|
products: _maps(decoded['products']),
|
||||||
shops: _maps(decoded['shops']),
|
shops: _maps(decoded['shops']),
|
||||||
shopItems: _maps(decoded['shopItems']),
|
shopItems: _maps(decoded['shopItems']),
|
||||||
|
shopTags: _maps(decoded['shopTags']),
|
||||||
|
shoppingLists: _maps(decoded['shoppingLists']),
|
||||||
|
shoppingListItems: _maps(decoded['shoppingListItems']),
|
||||||
images: _strings(decoded['images']),
|
images: _strings(decoded['images']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/// Build, share, and restore a Receipity JSON backup.
|
/// Build, share, and restore a Groci JSON backup.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
@@ -57,6 +57,9 @@ class BackupService {
|
|||||||
products: rewrittenProducts,
|
products: rewrittenProducts,
|
||||||
shops: rewrittenShops,
|
shops: rewrittenShops,
|
||||||
shopItems: await repository.dumpTable('shop_items'),
|
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,
|
images: images,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,11 +68,11 @@ class BackupService {
|
|||||||
final document = await buildBackup();
|
final document = await buildBackup();
|
||||||
final bytes = Uint8List.fromList(utf8.encode(document.encode()));
|
final bytes = Uint8List.fromList(utf8.encode(document.encode()));
|
||||||
final stamp = DateTime.now().toIso8601String().split('T').first;
|
final stamp = DateTime.now().toIso8601String().split('T').first;
|
||||||
final fileName = 'receipity-backup-$stamp.json';
|
final fileName = 'groci-backup-$stamp.json';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final saved = await FilePicker.saveFile(
|
final saved = await FilePicker.saveFile(
|
||||||
dialogTitle: 'Save Receipity backup',
|
dialogTitle: 'Save Groci backup',
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
bytes: bytes,
|
bytes: bytes,
|
||||||
mimeType: 'application/json',
|
mimeType: 'application/json',
|
||||||
@@ -87,8 +90,8 @@ class BackupService {
|
|||||||
final result = await SharePlus.instance.share(
|
final result = await SharePlus.instance.share(
|
||||||
ShareParams(
|
ShareParams(
|
||||||
files: [XFile(file.path, mimeType: 'application/json')],
|
files: [XFile(file.path, mimeType: 'application/json')],
|
||||||
subject: 'Receipity backup',
|
subject: 'Groci backup',
|
||||||
text: 'Receipity backup $stamp',
|
text: 'Groci backup $stamp',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return result.status != ShareResultStatus.dismissed;
|
return result.status != ShareResultStatus.dismissed;
|
||||||
@@ -96,7 +99,7 @@ class BackupService {
|
|||||||
|
|
||||||
Future<bool> importBackup() async {
|
Future<bool> importBackup() async {
|
||||||
final picked = await FilePicker.pickFile(
|
final picked = await FilePicker.pickFile(
|
||||||
dialogTitle: 'Import Receipity backup',
|
dialogTitle: 'Import Groci backup',
|
||||||
type: FileType.custom,
|
type: FileType.custom,
|
||||||
allowedExtensions: const ['json'],
|
allowedExtensions: const ['json'],
|
||||||
);
|
);
|
||||||
@@ -114,12 +117,16 @@ class BackupService {
|
|||||||
final now = DateTime.now().toIso8601String();
|
final now = DateTime.now().toIso8601String();
|
||||||
for (final row in document.products) {
|
for (final row in document.products) {
|
||||||
final copy = Map<String, Object?>.from(row);
|
final copy = Map<String, Object?>.from(row);
|
||||||
copy['image_path'] = await _restoreImage(copy['image_path'], document.images);
|
copy['image_path'] = await _restoreImage(
|
||||||
|
copy['image_path'],
|
||||||
|
document.images,
|
||||||
|
);
|
||||||
copy['created_at'] ??= now;
|
copy['created_at'] ??= now;
|
||||||
copy['updated_at'] ??= copy['created_at'];
|
copy['updated_at'] ??= copy['created_at'];
|
||||||
if (copy['normalized_name'] == null && copy['name'] is String) {
|
if (copy['normalized_name'] == null && copy['name'] is String) {
|
||||||
copy['normalized_name'] = ProductRepository.normalizeName(
|
copy['normalized_name'] = ProductRepository.normalizeName(
|
||||||
copy['name']! as String,
|
copy['name']! as String,
|
||||||
|
supermarket: copy['supermarket'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
products.add(copy);
|
products.add(copy);
|
||||||
@@ -141,14 +148,14 @@ class BackupService {
|
|||||||
products: products,
|
products: products,
|
||||||
shops: shops,
|
shops: shops,
|
||||||
shopItems: document.shopItems,
|
shopItems: document.shopItems,
|
||||||
|
shopTags: document.shopTags,
|
||||||
|
shoppingLists: document.shoppingLists,
|
||||||
|
shoppingListItems: document.shoppingListItems,
|
||||||
darkMode: document.darkMode,
|
darkMode: document.darkMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _restoreImage(
|
Future<String?> _restoreImage(Object? key, Map<String, String> images) async {
|
||||||
Object? key,
|
|
||||||
Map<String, String> images,
|
|
||||||
) async {
|
|
||||||
if (key is! String || key.isEmpty) return null;
|
if (key is! String || key.isEmpty) return null;
|
||||||
final encoded = images[key];
|
final encoded = images[key];
|
||||||
if (encoded == null) return null;
|
if (encoded == null) return null;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
|||||||
|
/// 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>[];
|
||||||
|
var maxRight = 0.0;
|
||||||
|
var maxBottom = 0.0;
|
||||||
|
|
||||||
|
for (final block in recognized.blocks) {
|
||||||
|
for (final line in block.lines) {
|
||||||
|
if (line.elements.isNotEmpty) {
|
||||||
|
for (final element in line.elements) {
|
||||||
|
final box = element.boundingBox;
|
||||||
|
elements.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;
|
||||||
|
elements.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final width = imageWidth > 0 ? imageWidth : maxRight;
|
||||||
|
final height = imageHeight > 0 ? imageHeight : maxBottom;
|
||||||
|
|
||||||
|
return OcrDocument(
|
||||||
|
elements: elements,
|
||||||
|
imageWidth: width,
|
||||||
|
imageHeight: height,
|
||||||
|
flattenedText: recognized.text.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = 1.2,
|
||||||
|
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: 1.2,
|
||||||
|
),
|
||||||
|
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>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,204 +1,42 @@
|
|||||||
/// 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;
|
library;
|
||||||
|
|
||||||
|
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
||||||
|
|
||||||
import '../models/line_item.dart';
|
import '../models/line_item.dart';
|
||||||
import '../utils/money.dart';
|
import '../models/ocr_element.dart';
|
||||||
|
import 'receipt_ocr.dart';
|
||||||
/// Headers, payments, and totals — never treated as products.
|
import 'receipt_parse_config.dart';
|
||||||
const Set<String> kHeaderKeywords = {
|
export 'receipt_parse_config.dart' show loadReceiptParseConfig;
|
||||||
'TOTAL',
|
export 'receipt_text_parser.dart' show ReceiptParseResult;
|
||||||
'SUBTOTAL',
|
import 'receipt_spatial_parser.dart';
|
||||||
'TOTAAL',
|
import 'receipt_text_parser.dart';
|
||||||
'TAX',
|
|
||||||
'VAT',
|
|
||||||
'BTW',
|
|
||||||
'CASH',
|
|
||||||
'CARD',
|
|
||||||
'CHANGE',
|
|
||||||
'VISA',
|
|
||||||
'MASTERCARD',
|
|
||||||
'MAESTRO',
|
|
||||||
'PIN',
|
|
||||||
'DEBIT',
|
|
||||||
'CREDIT',
|
|
||||||
'OMSCHRIJVING',
|
|
||||||
'BEDRAG',
|
|
||||||
'AMOUNT',
|
|
||||||
'DESCRIPTION',
|
|
||||||
'THANK',
|
|
||||||
'BEDANKT',
|
|
||||||
'WISSELGELD',
|
|
||||||
'BETAALD',
|
|
||||||
'BETAAL',
|
|
||||||
'CONTANT',
|
|
||||||
'KASSA',
|
|
||||||
'RECEIPT',
|
|
||||||
'WWW',
|
|
||||||
'HTTP',
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Discount / promo rows. Paired with a price, then dropped.
|
|
||||||
const Set<String> kDropKeywords = {
|
|
||||||
'DISCOUNT',
|
|
||||||
'KORTING',
|
|
||||||
'ACTIE',
|
|
||||||
};
|
|
||||||
|
|
||||||
final _separatorPattern = RegExp(r'^[=\-_.:\s]+$');
|
|
||||||
final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b');
|
|
||||||
final _phonePattern = RegExp(r'^\d{8,}$');
|
|
||||||
final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}$");
|
|
||||||
final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false);
|
|
||||||
|
|
||||||
class _QtyLine {
|
|
||||||
const _QtyLine({required this.count, required this.unitPrice, this.total});
|
|
||||||
|
|
||||||
final int count;
|
|
||||||
final double unitPrice;
|
|
||||||
final double? total;
|
|
||||||
|
|
||||||
double get lineTotal => total ?? roundMoney(count * unitPrice);
|
|
||||||
}
|
|
||||||
|
|
||||||
class ReceiptParser {
|
class ReceiptParser {
|
||||||
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.
|
/// Fallback when only flattened OCR text is available (tests, re-parse).
|
||||||
///
|
static ReceiptParseResult parseReceipt(String rawText) =>
|
||||||
/// Prefers same-line `name … price` (with a following `2 x 1.50` line when
|
ReceiptTextParser.parseReceipt(rawText);
|
||||||
/// the name has no price). If OCR split the receipt into a name column and a
|
|
||||||
/// price column, falls back to pairing those columns.
|
|
||||||
static List<LineItem> parse(String rawText) {
|
|
||||||
final lines = rawText
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.where((line) => line.isNotEmpty)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final inline = _parseInline(lines);
|
/// Primary path: positioned ML Kit elements → row clustering → lookahead.
|
||||||
final columns = _parseColumns(lines);
|
static ReceiptParseResult parseReceiptFromOcr(OcrDocument document) =>
|
||||||
final chosen = columns.length > inline.length ? columns : inline;
|
ReceiptSpatialParser.parse(document);
|
||||||
return chosen.where(_keepItem).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<LineItem> _parseInline(List<String> lines) {
|
static ReceiptParseResult parseRecognizedText(
|
||||||
final items = <LineItem>[];
|
RecognizedText recognized, {
|
||||||
|
required double imageWidth,
|
||||||
for (var i = 0; i < lines.length; i++) {
|
required double imageHeight,
|
||||||
final line = lines[i];
|
}) {
|
||||||
if (_shouldSkipLine(line) || isPriceOnly(line)) continue;
|
final document = ReceiptOcr.fromRecognizedText(
|
||||||
|
recognized,
|
||||||
final qty = _parseQty(line);
|
imageWidth: imageWidth,
|
||||||
if (qty != null) continue;
|
imageHeight: imageHeight,
|
||||||
|
|
||||||
final trailing = trailingPricePattern.firstMatch(line);
|
|
||||||
if (trailing != null) {
|
|
||||||
final name = trailing.group(1)!.trim();
|
|
||||||
final price = tryParsePrice(trailing.group(2)!);
|
|
||||||
if (name.isEmpty || price == null || _shouldSkipLine(name)) continue;
|
|
||||||
items.add(LineItem(id: _id(), name: name, price: price));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (i + 1 >= lines.length) continue;
|
|
||||||
final nextQty = _parseQty(lines[i + 1]);
|
|
||||||
if (nextQty == null) continue;
|
|
||||||
items.add(
|
|
||||||
LineItem(
|
|
||||||
id: _id(),
|
|
||||||
name: line,
|
|
||||||
price: nextQty.lineTotal,
|
|
||||||
quantity: nextQty.count,
|
|
||||||
unitPrice: nextQty.unitPrice,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
i++;
|
return parseReceiptFromOcr(document);
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<LineItem> _parseColumns(List<String> lines) {
|
|
||||||
final names = <({String name, int? quantity, double? unitPrice})>[];
|
|
||||||
final prices = <double>[];
|
|
||||||
|
|
||||||
for (final line in lines) {
|
|
||||||
if (isPriceOnly(line)) {
|
|
||||||
final price = tryParsePrice(line);
|
|
||||||
if (price != null) prices.add(price);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (_shouldSkipLine(line)) continue;
|
|
||||||
|
|
||||||
final qty = _parseQty(line);
|
|
||||||
if (qty != null) {
|
|
||||||
if (names.isNotEmpty) {
|
|
||||||
final last = names.removeLast();
|
|
||||||
names.add((
|
|
||||||
name: last.name,
|
|
||||||
quantity: qty.count,
|
|
||||||
unitPrice: qty.unitPrice,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
names.add((name: line, quantity: null, unitPrice: null));
|
|
||||||
}
|
|
||||||
|
|
||||||
final count = names.length < prices.length ? names.length : prices.length;
|
|
||||||
return [
|
|
||||||
for (var i = 0; i < count; i++)
|
|
||||||
LineItem(
|
|
||||||
id: _id(),
|
|
||||||
name: names[i].name,
|
|
||||||
price: prices[i],
|
|
||||||
quantity: names[i].quantity,
|
|
||||||
unitPrice: names[i].unitPrice,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool _keepItem(LineItem item) {
|
|
||||||
if (item.price < 0) return false;
|
|
||||||
if (item.name.trim().isEmpty) return false;
|
|
||||||
return !_containsKeyword(item.name, kDropKeywords);
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool _shouldSkipLine(String line) {
|
|
||||||
if (_separatorPattern.hasMatch(line)) return true;
|
|
||||||
if (_urlPattern.hasMatch(line)) return true;
|
|
||||||
if (_postalPattern.hasMatch(line)) return true;
|
|
||||||
if (_phonePattern.hasMatch(line.replaceAll(RegExp(r'\s'), ''))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (_streetPattern.hasMatch(line)) return true;
|
|
||||||
if (_containsKeyword(line, kHeaderKeywords)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool _containsKeyword(String line, Set<String> keywords) {
|
|
||||||
final upper = line.toUpperCase();
|
|
||||||
for (final keyword in keywords) {
|
|
||||||
if (RegExp('\\b${RegExp.escape(keyword)}\\b').hasMatch(upper)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static _QtyLine? _parseQty(String line) {
|
|
||||||
final match = quantityPattern.firstMatch(line.trim());
|
|
||||||
if (match == null) return null;
|
|
||||||
final count = int.tryParse(match.group(1)!);
|
|
||||||
final unit = tryParsePrice(match.group(2)!);
|
|
||||||
if (count == null || count < 1 || unit == null) return null;
|
|
||||||
final totalRaw = match.group(3);
|
|
||||||
final total = totalRaw == null ? null : tryParsePrice(totalRaw);
|
|
||||||
return _QtyLine(count: count, unitPrice: unit, total: total);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
/// 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 rowClusterThreshold = _rowClusterThreshold(document.elements);
|
||||||
|
final clustered = _clusterByY(document.elements, rowClusterThreshold);
|
||||||
|
final contentWidth = _contentWidth(document.elements);
|
||||||
|
final contentLeft = _contentLeft(document.elements);
|
||||||
|
final priceColumnStart =
|
||||||
|
contentLeft + contentWidth * (1 - config.priceColumnWidthFraction);
|
||||||
|
|
||||||
|
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,
|
||||||
|
}) {
|
||||||
|
final text = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -5,11 +5,16 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'models/line_item.dart';
|
||||||
import 'models/shop.dart';
|
import 'models/shop.dart';
|
||||||
import 'product_detail_screen.dart';
|
import 'product_detail_screen.dart';
|
||||||
import 'services/product_repository.dart';
|
import 'services/product_repository.dart';
|
||||||
import 'utils/dates.dart';
|
import 'utils/dates.dart';
|
||||||
import 'utils/money.dart';
|
import 'utils/money.dart';
|
||||||
|
import 'widgets/confirm_dialog.dart';
|
||||||
|
import 'widgets/item_edit_sheet.dart';
|
||||||
|
import 'widgets/name_dialog.dart';
|
||||||
|
import 'widgets/tag_editor.dart';
|
||||||
|
|
||||||
class ShopDetailScreen extends StatefulWidget {
|
class ShopDetailScreen extends StatefulWidget {
|
||||||
const ShopDetailScreen({
|
const ShopDetailScreen({
|
||||||
@@ -26,21 +31,48 @@ class ShopDetailScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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 {
|
@override
|
||||||
final shop = await widget.repository.getShop(widget.shopId);
|
void dispose() {
|
||||||
if (shop == null) {
|
widget.repository.removeListener(_load);
|
||||||
throw StateError('Shop not found');
|
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);
|
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 {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Shopping trip')),
|
appBar: AppBar(
|
||||||
body: FutureBuilder(
|
title: const Text('Shopping trip'),
|
||||||
future: _future,
|
actions: [
|
||||||
builder: (context, snapshot) {
|
IconButton(
|
||||||
if (snapshot.hasError) {
|
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(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Could not load this trip.',
|
'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());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
final shop = snapshot.data!.shop;
|
|
||||||
final items = snapshot.data!.items;
|
|
||||||
final receipt = shop.receiptImagePath;
|
final receipt = shop.receiptImagePath;
|
||||||
final receiptFile =
|
final receiptFile = receipt == null ? null : File(receipt);
|
||||||
receipt == null ? null : File(receipt);
|
final hasReceipt = receiptFile != null && receiptFile.existsSync();
|
||||||
final hasReceipt =
|
|
||||||
receiptFile != null && receiptFile.existsSync();
|
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 96),
|
||||||
children: [
|
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),
|
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(
|
Text(
|
||||||
formatDate(shop.shoppedAt),
|
formatDate(shop.shoppedAt),
|
||||||
style: theme.textTheme.bodyLarge,
|
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) ...[
|
if (hasReceipt) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
@@ -125,20 +391,41 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text('Products', style: theme.textTheme.titleMedium),
|
Text('Products', style: theme.textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
if (items.isEmpty)
|
||||||
|
Text(
|
||||||
|
'No items on this trip.',
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
),
|
||||||
for (final item in items)
|
for (final item in items)
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: Text(item.name),
|
title: Text(item.name),
|
||||||
subtitle: item.quantity == null
|
subtitle: _itemSubtitle(item),
|
||||||
? null
|
trailing: _editing
|
||||||
: Text(
|
? Row(
|
||||||
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Edit',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () => _editItem(item),
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
),
|
),
|
||||||
trailing: Text(
|
IconButton(
|
||||||
|
tooltip: 'Remove',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () => _removeItem(item),
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
formatEuro(item.price),
|
formatEuro(item.price),
|
||||||
style: theme.textTheme.titleMedium,
|
style: theme.textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
onTap: () => _openProduct(item.productId),
|
onTap: _editing || item.productId == null
|
||||||
|
? null
|
||||||
|
: () => _openProduct(item.productId!),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
|
|||||||
+362
-78
@@ -1,18 +1,23 @@
|
|||||||
/// Log of saved shopping trips.
|
/// Log of saved shopping trips, with a monthly heatmap and product search.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'models/shop.dart';
|
import 'models/shop.dart';
|
||||||
|
import 'models/supermarket.dart';
|
||||||
import 'services/app_settings.dart';
|
import 'services/app_settings.dart';
|
||||||
import 'services/product_repository.dart';
|
import 'services/product_repository.dart';
|
||||||
import 'shop_detail_screen.dart';
|
import 'shop_detail_screen.dart';
|
||||||
import 'utils/dates.dart';
|
import 'utils/dates.dart';
|
||||||
import 'utils/money.dart';
|
import 'utils/money.dart';
|
||||||
|
import '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/settings_button.dart';
|
||||||
|
import 'widgets/shop_heatmap.dart';
|
||||||
import 'widgets/store_tag.dart';
|
import 'widgets/store_tag.dart';
|
||||||
|
import 'widgets/tag_editor.dart';
|
||||||
|
|
||||||
class ShopLogScreen extends StatefulWidget {
|
class ShopLogScreen extends StatefulWidget {
|
||||||
const ShopLogScreen({super.key, required this.repository});
|
const ShopLogScreen({super.key, required this.repository});
|
||||||
@@ -24,44 +29,226 @@ class ShopLogScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ShopLogScreenState extends State<ShopLogScreen> {
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_future = widget.repository.getShops();
|
widget.repository.addListener(_load);
|
||||||
|
_search.addListener(() => setState(() {}));
|
||||||
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reload() {
|
@override
|
||||||
setState(() => _future = widget.repository.getShops());
|
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 {
|
Future<void> _openShop(Shop shop) async {
|
||||||
await Navigator.of(context).push<void>(
|
await Navigator.of(context).push<void>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => ShopDetailScreen(
|
builder: (_) =>
|
||||||
repository: widget.repository,
|
ShopDetailScreen(repository: widget.repository, shopId: shop.id),
|
||||||
shopId: shop.id,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (mounted) _reload();
|
if (mounted) _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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
|
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
|
||||||
|
final nowMonth = monthStart(DateTime.now());
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Shop log'),
|
title: _searching
|
||||||
actions: const [SettingsButton()],
|
? 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,
|
: const Text('Shop log'),
|
||||||
builder: (context, snapshot) {
|
actions: [
|
||||||
if (snapshot.hasError) {
|
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(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Could not load the shop log.',
|
'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());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
final shops = snapshot.data!;
|
final allShops = shops!;
|
||||||
if (shops.isEmpty) {
|
final counts = tripCountsByDay([
|
||||||
return Center(
|
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(
|
child: Text(
|
||||||
'Scan a receipt to log a shopping trip.',
|
'Scan a receipt or enter a trip to log it.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: theme.textTheme.bodyLarge,
|
style: theme.textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
}
|
else if (visible.isEmpty)
|
||||||
|
Padding(
|
||||||
return ListView.separated(
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
|
child: Text(
|
||||||
itemCount: shops.length,
|
query.trim().isNotEmpty
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
? 'No trips contain that product.'
|
||||||
itemBuilder: (context, index) {
|
: 'No trips match this filter.',
|
||||||
final shop = shops[index];
|
textAlign: TextAlign.center,
|
||||||
final count = shop.itemCount;
|
style: theme.textTheme.bodyLarge,
|
||||||
return ListTile(
|
|
||||||
leading: _ReceiptThumb(path: shop.receiptImagePath),
|
|
||||||
title: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: StoreTag(name: shop.supermarket, stores: stores),
|
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
)
|
||||||
[
|
else
|
||||||
formatDate(shop.shoppedAt),
|
for (final shop in visible)
|
||||||
count == 1 ? '1 product' : '$count products',
|
Padding(
|
||||||
].join(' · '),
|
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 {
|
class _ShopLogTile extends StatelessWidget {
|
||||||
const _ReceiptThumb({this.path});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final file = path == null ? null : File(path!);
|
final theme = Theme.of(context);
|
||||||
final hasFile = file != null && file.existsSync();
|
final count = shop.itemCount;
|
||||||
final scheme = Theme.of(context).colorScheme;
|
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),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: SizedBox(
|
child: Padding(
|
||||||
width: 48,
|
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
|
||||||
height: 48,
|
child: Row(
|
||||||
child: hasFile
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
? Image.file(
|
children: [
|
||||||
file,
|
if (editing)
|
||||||
fit: BoxFit.cover,
|
IconButton(
|
||||||
cacheWidth: 144,
|
tooltip: 'Remove',
|
||||||
errorBuilder: (_, _, _) => ColoredBox(
|
onPressed: onRemove,
|
||||||
color: scheme.secondaryContainer,
|
icon: const Icon(Icons.delete_outline),
|
||||||
child: Icon(
|
),
|
||||||
Icons.receipt_long_outlined,
|
Expanded(
|
||||||
color: scheme.onSecondaryContainer,
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
const SizedBox(width: 8),
|
||||||
: ColoredBox(
|
Padding(
|
||||||
color: scheme.secondaryContainer,
|
padding: const EdgeInsets.only(top: 8, right: 8),
|
||||||
child: Icon(
|
child: Text(
|
||||||
Icons.receipt_long_outlined,
|
formatEuro(shop.total),
|
||||||
color: scheme.onSecondaryContainer,
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,9 +39,7 @@ abstract final class AppTheme {
|
|||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.surfaceContainerLow,
|
color: colorScheme.surfaceContainerLow,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
@@ -60,9 +58,7 @@ abstract final class AppTheme {
|
|||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
|
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
@@ -88,9 +84,7 @@ abstract final class AppTheme {
|
|||||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||||
backgroundColor: colorScheme.primary,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: colorScheme.onPrimary,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
dividerTheme: DividerThemeData(
|
dividerTheme: DividerThemeData(
|
||||||
color: colorScheme.outlineVariant,
|
color: colorScheme.outlineVariant,
|
||||||
@@ -98,14 +92,10 @@ abstract final class AppTheme {
|
|||||||
),
|
),
|
||||||
snackBarTheme: SnackBarThemeData(
|
snackBarTheme: SnackBarThemeData(
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
listTileTheme: ListTileThemeData(
|
listTileTheme: ListTileThemeData(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
iconColor: colorScheme.primary,
|
iconColor: colorScheme.primary,
|
||||||
),
|
),
|
||||||
shadowColor: isDark ? Colors.black : const Color(0x1A0F172A),
|
shadowColor: isDark ? Colors.black : const Color(0x1A0F172A),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// Shared strings used across the app.
|
/// Shared strings used across the app.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
const String kAppName = 'Receipity';
|
const String kAppName = 'Groci';
|
||||||
|
|
||||||
/// Bundled Jumbo receipt used to test OCR without the camera.
|
/// Bundled Jumbo receipt used to test OCR without the camera.
|
||||||
const String kSampleReceiptAsset = 'assets/receipt.jpeg';
|
const String kSampleReceiptAsset = 'assets/receipt.jpeg';
|
||||||
|
|||||||
@@ -16,5 +16,23 @@ const _months = [
|
|||||||
'Dec',
|
'Dec',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const _monthNames = [
|
||||||
|
'January',
|
||||||
|
'February',
|
||||||
|
'March',
|
||||||
|
'April',
|
||||||
|
'May',
|
||||||
|
'June',
|
||||||
|
'July',
|
||||||
|
'August',
|
||||||
|
'September',
|
||||||
|
'October',
|
||||||
|
'November',
|
||||||
|
'December',
|
||||||
|
];
|
||||||
|
|
||||||
String formatDate(DateTime date) =>
|
String formatDate(DateTime date) =>
|
||||||
'${date.day} ${_months[date.month - 1]} ${date.year}';
|
'${date.day} ${_months[date.month - 1]} ${date.year}';
|
||||||
|
|
||||||
|
String formatMonthYear(DateTime date) =>
|
||||||
|
'${_monthNames[date.month - 1]} ${date.year}';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,13 +17,18 @@ final List<Supermarket> kDefaultSupermarkets = [
|
|||||||
const Supermarket(name: 'Nettorama', colorValue: 0xFFE87722, sortOrder: 8),
|
const Supermarket(name: 'Nettorama', colorValue: 0xFFE87722, sortOrder: 8),
|
||||||
const Supermarket(name: 'Hoogvliet', colorValue: 0xFFD32F2F, sortOrder: 9),
|
const Supermarket(name: 'Hoogvliet', colorValue: 0xFFD32F2F, sortOrder: 9),
|
||||||
const Supermarket(name: 'Picnic', colorValue: 0xFFE31C5F, sortOrder: 10),
|
const Supermarket(name: 'Picnic', colorValue: 0xFFE31C5F, sortOrder: 10),
|
||||||
const Supermarket(name: kOtherSupermarket, colorValue: 0xFF78909C, sortOrder: 11),
|
const Supermarket(
|
||||||
|
name: kOtherSupermarket,
|
||||||
|
colorValue: 0xFF78909C,
|
||||||
|
sortOrder: 11,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
const Map<String, List<String>> kSupermarketAliases = {
|
const Map<String, List<String>> kSupermarketAliases = {
|
||||||
'Albert Heijn': ['ALBERT HEIJN', 'AH TO GO', 'AH XL'],
|
'Albert Heijn': ['ALBERT HEIJN', 'AH TO GO', 'AH XL'],
|
||||||
'Jumbo': ['JUMBO'],
|
'Jumbo': ['JUMBO'],
|
||||||
'Lidl': ['LIDL'],
|
'Lidl': ['LIDL'],
|
||||||
|
'Kruidvat': ['KRUIDVAT'],
|
||||||
'Aldi': ['ALDI'],
|
'Aldi': ['ALDI'],
|
||||||
'Plus': ['PLUS'],
|
'Plus': ['PLUS'],
|
||||||
'Dirk': ['DIRK'],
|
'Dirk': ['DIRK'],
|
||||||
@@ -31,6 +36,10 @@ const Map<String, List<String>> kSupermarketAliases = {
|
|||||||
'SPAR': ['SPAR'],
|
'SPAR': ['SPAR'],
|
||||||
'Nettorama': ['NETTORAMA'],
|
'Nettorama': ['NETTORAMA'],
|
||||||
'Hoogvliet': ['HOOGVLIET'],
|
'Hoogvliet': ['HOOGVLIET'],
|
||||||
|
'Ekoplaza': ['EKOPLAZA'],
|
||||||
|
'Action': ['ACTION'],
|
||||||
|
'Etos': ['ETOS'],
|
||||||
|
'World Toko': ['WORLD TOKO'],
|
||||||
'Picnic': ['PICNIC'],
|
'Picnic': ['PICNIC'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,20 +60,20 @@ const List<String> kSupermarkets = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Returns a supermarket name if it appears in [rawText].
|
/// Returns a supermarket name if it appears in [rawText].
|
||||||
String? detectSupermarket(
|
String? detectSupermarket(String rawText, {Iterable<String> names = const []}) {
|
||||||
String rawText, {
|
|
||||||
Iterable<String> names = const [],
|
|
||||||
}) {
|
|
||||||
final upper = rawText.toUpperCase();
|
final upper = rawText.toUpperCase();
|
||||||
final extra = names
|
final extra =
|
||||||
|
names
|
||||||
.map((name) => name.trim())
|
.map((name) => name.trim())
|
||||||
.where((name) => name.isNotEmpty && name != kOtherSupermarket)
|
.where((name) => name.isNotEmpty && name != kOtherSupermarket)
|
||||||
.toList()
|
.toList()
|
||||||
..sort((a, b) => b.length.compareTo(a.length));
|
..sort((a, b) => b.length.compareTo(a.length));
|
||||||
|
|
||||||
for (final name in extra) {
|
for (final name in extra) {
|
||||||
if (RegExp('\\b${RegExp.escape(name)}\\b', caseSensitive: false)
|
if (RegExp(
|
||||||
.hasMatch(rawText)) {
|
'\\b${RegExp.escape(name)}\\b',
|
||||||
|
caseSensitive: false,
|
||||||
|
).hasMatch(rawText)) {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/// Confirm a destructive action before it runs.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
Future<bool> confirmAction(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required String message,
|
||||||
|
String confirmLabel = 'Delete',
|
||||||
|
}) async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: Text(message),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: Text(confirmLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result == true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,9 +28,7 @@ class PriceChart extends StatelessWidget {
|
|||||||
height: 160,
|
height: 160,
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
painter: _PriceChartPainter(
|
painter: _PriceChartPainter(
|
||||||
prices: [
|
prices: [for (final purchase in points) purchase.unitOrLinePrice],
|
||||||
for (final purchase in points) purchase.unitOrLinePrice,
|
|
||||||
],
|
|
||||||
lineColor: theme.colorScheme.primary,
|
lineColor: theme.colorScheme.primary,
|
||||||
fillColor: theme.colorScheme.primary.withValues(alpha: 0.12),
|
fillColor: theme.colorScheme.primary.withValues(alpha: 0.12),
|
||||||
gridColor: theme.colorScheme.outlineVariant,
|
gridColor: theme.colorScheme.outlineVariant,
|
||||||
@@ -87,7 +85,12 @@ class _PriceChartPainter extends CustomPainter {
|
|||||||
const right = 8.0;
|
const right = 8.0;
|
||||||
const top = 12.0;
|
const top = 12.0;
|
||||||
const bottom = 8.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()
|
final gridPaint = Paint()
|
||||||
..color = gridColor
|
..color = gridColor
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ class _LetterAvatar extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final trimmed = name.trim();
|
final trimmed = name.trim();
|
||||||
final letter = trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase();
|
final letter = trimmed.isEmpty
|
||||||
|
? '?'
|
||||||
|
: trimmed.substring(0, 1).toUpperCase();
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: colorScheme.secondaryContainer,
|
color: colorScheme.secondaryContainer,
|
||||||
child: Center(
|
child: Center(
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@ library;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../models/supermarket.dart';
|
|
||||||
import '../services/app_settings.dart';
|
import '../services/app_settings.dart';
|
||||||
import '../services/backup_service.dart';
|
import '../services/backup_service.dart';
|
||||||
|
import '../supermarkets_screen.dart';
|
||||||
|
import 'confirm_dialog.dart';
|
||||||
|
|
||||||
class SettingsButton extends StatelessWidget {
|
class SettingsButton extends StatelessWidget {
|
||||||
const SettingsButton({super.key});
|
const SettingsButton({super.key});
|
||||||
@@ -47,9 +48,8 @@ class _SettingsSheetState extends State<_SettingsSheet> {
|
|||||||
final shared = await BackupService(settings.repository).exportBackup();
|
final shared = await BackupService(settings.repository).exportBackup();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (shared) {
|
if (shared) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context)
|
||||||
const SnackBar(content: Text('Backup ready.')),
|
.showSnackBar(const SnackBar(content: Text('Backup ready.')));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -92,9 +92,8 @@ class _SettingsSheetState extends State<_SettingsSheet> {
|
|||||||
if (imported) {
|
if (imported) {
|
||||||
await settings.reload();
|
await settings.reload();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context)
|
||||||
const SnackBar(content: Text('Backup imported.')),
|
.showSnackBar(const SnackBar(content: Text('Backup imported.')));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!mounted) return;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final settings = SettingsScope.of(context);
|
final settings = SettingsScope.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final scheme = theme.colorScheme;
|
||||||
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
||||||
|
final maxHeight = MediaQuery.sizeOf(context).height * 0.85;
|
||||||
|
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: settings,
|
listenable: settings,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
return Padding(
|
return SizedBox(
|
||||||
|
height: maxHeight,
|
||||||
|
child: ListView(
|
||||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
|
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
|
||||||
child: SizedBox(
|
|
||||||
height: MediaQuery.sizeOf(context).height * 0.75,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
children: [
|
||||||
Text('Settings', style: Theme.of(context).textTheme.titleLarge),
|
Text('Settings', style: theme.textTheme.titleLarge),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
@@ -129,10 +169,25 @@ class _SettingsSheetState extends State<_SettingsSheet> {
|
|||||||
value: settings.darkMode,
|
value: settings.darkMode,
|
||||||
onChanged: _busy ? null : settings.setDarkMode,
|
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(
|
Text(
|
||||||
'Save everything to a JSON file, or replace this device from a backup.',
|
'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),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
@@ -158,215 +213,32 @@ class _SettingsSheetState extends State<_SettingsSheet> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const LinearProgressIndicator(),
|
const LinearProgressIndicator(),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 32),
|
||||||
Text(
|
Text(
|
||||||
'Supermarkets',
|
'Danger zone',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: scheme.error,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Name and color used as tags on products.',
|
'Permanently delete everything on this device.',
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
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),
|
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: _busy
|
style: OutlinedButton.styleFrom(
|
||||||
? null
|
foregroundColor: scheme.error,
|
||||||
: () => _editStore(context, settings, null),
|
side: BorderSide(color: scheme.error),
|
||||||
icon: const Icon(Icons.add),
|
),
|
||||||
label: const Text('Add supermarket'),
|
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)!;
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
version: "1.0.3"
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.0"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -65,6 +73,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
crop_your_image:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: crop_your_image
|
||||||
|
sha256: "14c8977b11a009dc5e73e0f6522970f93363e38183f1b2ffefe1676dc9c3f49d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
cross_file:
|
cross_file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -240,6 +256,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
google_mlkit_commons:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -280,6 +304,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.9.2"
|
||||||
image_picker:
|
image_picker:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -440,6 +472,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -536,6 +576,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
posix:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: posix
|
||||||
|
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.5.2"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -605,6 +653,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.11"
|
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:
|
sqflite_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -621,6 +677,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.1"
|
||||||
|
sqlite3:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqlite3
|
||||||
|
sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.5.2"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,4 @@
|
|||||||
name: receipity
|
name: groci
|
||||||
description: Capture a shopping receipt and show Google ML Kit text recognition output.
|
description: Capture a shopping receipt and show Google ML Kit text recognition output.
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.0.0+1
|
version: 1.0.0+1
|
||||||
@@ -18,13 +18,16 @@ dependencies:
|
|||||||
sqflite: ^2.4.2
|
sqflite: ^2.4.2
|
||||||
file_picker: ^12.1.2
|
file_picker: ^12.1.2
|
||||||
share_plus: ^13.3.0
|
share_plus: ^13.3.0
|
||||||
|
crop_your_image: ^2.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
sqflite_common_ffi: ^2.4.0
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
assets:
|
||||||
- assets/receipt.jpeg
|
- assets/receipt.jpeg
|
||||||
|
- receipt-parsing-keywords.json
|
||||||
|
|||||||
@@ -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 rows. price_column_width_fraction is the right-hand slice of receipt content width treated as the price column.",
|
||||||
|
"row_cluster_height_factor": 1.2,
|
||||||
|
"price_column_width_fraction": 0.25
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:receipity/services/backup_format.dart';
|
import 'package:groci/services/backup_format.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('round-trips a backup document', () {
|
test('round-trips a backup document', () {
|
||||||
final original = const BackupDocument(
|
const original = BackupDocument(
|
||||||
version: 1,
|
version: 3,
|
||||||
exportedAt: '2026-08-29T12:00:00.000',
|
exportedAt: '2026-08-29T12:00:00.000',
|
||||||
darkMode: true,
|
darkMode: true,
|
||||||
supermarkets: [
|
supermarkets: [
|
||||||
@@ -29,6 +29,7 @@ void main() {
|
|||||||
{
|
{
|
||||||
'id': 8,
|
'id': 8,
|
||||||
'supermarket': 'Jumbo',
|
'supermarket': 'Jumbo',
|
||||||
|
'name': 'Weekly shop',
|
||||||
'shopped_at': '2026-08-29T12:00:00.000',
|
'shopped_at': '2026-08-29T12:00:00.000',
|
||||||
'receipt_image_path': 'receipts/shop_8.jpg',
|
'receipt_image_path': 'receipts/shop_8.jpg',
|
||||||
'total': 1.5,
|
'total': 1.5,
|
||||||
@@ -46,6 +47,27 @@ void main() {
|
|||||||
'unit_price': 1.5,
|
'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'},
|
images: {'products/product_1.jpg': 'abc123'},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -53,11 +75,37 @@ void main() {
|
|||||||
expect(parsed.darkMode, isTrue);
|
expect(parsed.darkMode, isTrue);
|
||||||
expect(parsed.products.single['name'], 'Milk');
|
expect(parsed.products.single['name'], 'Milk');
|
||||||
expect(parsed.shops.single['id'], 8);
|
expect(parsed.shops.single['id'], 8);
|
||||||
|
expect(parsed.shops.single['name'], 'Weekly shop');
|
||||||
expect(parsed.shopItems.single['product_id'], 1);
|
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.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(
|
expect(
|
||||||
() => BackupDocument.parse('{"hello":"world"}'),
|
() => BackupDocument.parse('{"hello":"world"}'),
|
||||||
throwsFormatException,
|
throwsFormatException,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:receipity/utils/fuzzy.dart';
|
import 'package:groci/utils/fuzzy.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('matches substrings and character subsequences', () {
|
test('matches substrings and character subsequences', () {
|
||||||
@@ -10,6 +10,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('ranks closer matches higher', () {
|
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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:receipity/services/receipt_parser.dart';
|
import 'package:groci/services/receipt_parser.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('parses name and price at the end of a line', () {
|
test('parses name and price at the end of a line', () {
|
||||||
@@ -15,6 +15,35 @@ CHANGE 1.50
|
|||||||
expect(items.map((item) => item.price), [1.50, 2.00]);
|
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', () {
|
test('uses the next line when quantity and total are split', () {
|
||||||
final items = ReceiptParser.parse('''
|
final items = ReceiptParser.parse('''
|
||||||
EGGS
|
EGGS
|
||||||
@@ -44,17 +73,60 @@ BROODSTICKS OLIJF
|
|||||||
expect(items.single.quantity, 4);
|
expect(items.single.quantity, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('drops discount rows and Dutch totals', () {
|
test('folds a discount into the previous item', () {
|
||||||
final items = ReceiptParser.parse('''
|
final result = ReceiptParser.parseReceipt('''
|
||||||
AARDBEIEN 7,98
|
AARDBEIEN 7,98
|
||||||
ACTIE AARDBEIEN -2,00
|
ACTIE AARDBEIEN -2,00
|
||||||
Totaal 82,99
|
Totaal 5,98
|
||||||
VISA 82,99
|
VISA 5,98
|
||||||
Totaal korting: -2,21
|
Totaal korting: -2,00
|
||||||
''');
|
''');
|
||||||
|
|
||||||
expect(items.map((item) => item.name), ['AARDBEIEN']);
|
expect(result.items.map((item) => item.name), ['AARDBEIEN']);
|
||||||
expect(items.single.price, 7.98);
|
expect(result.items.single.price, 5.98);
|
||||||
|
expect(result.items.single.discount, -2.00);
|
||||||
|
expect(result.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', () {
|
test('pairs a name column with a price column from OCR', () {
|
||||||
@@ -66,10 +138,16 @@ Totaal korting: -2,21
|
|||||||
expect(items.first.name, 'JUMBO BIO VOLLE MELK');
|
expect(items.first.name, 'JUMBO BIO VOLLE MELK');
|
||||||
expect(items.first.price, 2.09);
|
expect(items.first.price, 2.09);
|
||||||
|
|
||||||
final eggs = items.firstWhere((item) => item.name.startsWith('BIOLOGISCH EI'));
|
final eggs = items.firstWhere(
|
||||||
|
(item) => item.name.startsWith('BIOLOGISCH EI'),
|
||||||
|
);
|
||||||
expect(eggs.quantity, 2);
|
expect(eggs.quantity, 2);
|
||||||
expect(eggs.price, 5.58);
|
expect(eggs.price, 5.58);
|
||||||
|
|
||||||
|
final berries = items.firstWhere((item) => item.name == 'AARDBEIEN');
|
||||||
|
expect(berries.quantity, 2);
|
||||||
|
expect(berries.price, 5.98);
|
||||||
|
|
||||||
expect(items.last.name, 'JUMBO PREM LUIER M4');
|
expect(items.last.name, 'JUMBO PREM LUIER M4');
|
||||||
expect(items.last.price, 6.20);
|
expect(items.last.price, 6.20);
|
||||||
expect(items, hasLength(26));
|
expect(items, hasLength(26));
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
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).
|
||||||
|
///
|
||||||
|
/// Y increases downward; name column ~left 40–420, price column ~right 620–760.
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
OcrDocument _document(List<OcrElement> elements, {String? flattened}) {
|
||||||
|
return OcrDocument(
|
||||||
|
elements: elements,
|
||||||
|
imageWidth: _imageWidth,
|
||||||
|
imageHeight: _imageHeight,
|
||||||
|
flattenedText: flattened ?? elements.map((e) => e.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', 0),
|
||||||
|
_word('X', 0),
|
||||||
|
_word('2,99', 0, 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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', 1),
|
||||||
|
_word('Artikel:', 1),
|
||||||
|
_word('3', 1, price: true),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.expectedItemCount, 3);
|
||||||
|
expect(result.validationPassed, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
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() {
|
void main() {
|
||||||
test('detects Jumbo and Lidl from receipt text', () {
|
test('detects Jumbo and Lidl from receipt text', () {
|
||||||
@@ -20,4 +21,32 @@ void main() {
|
|||||||
test('returns null when no supermarket is present', () {
|
test('returns null when no supermarket is present', () {
|
||||||
expect(detectSupermarket('Milk 1.50\nBread 2.00'), isNull);
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,27 +1,192 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:receipity/main.dart';
|
import 'package:groci/main.dart';
|
||||||
import 'package:receipity/models/line_item.dart';
|
import 'package:groci/models/line_item.dart';
|
||||||
import 'package:receipity/review_screen.dart';
|
import 'package:groci/review_screen.dart';
|
||||||
import 'package:receipity/services/product_repository.dart';
|
import 'package:groci/widgets/shop_heatmap.dart';
|
||||||
|
|
||||||
|
import 'test_helpers.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('scan screen shows capture actions and bottom navigation', (
|
ensureTestSqlite();
|
||||||
|
|
||||||
|
testWidgets('home screen shows overview and bottom navigation', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) 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('Take photo'), findsOneWidget);
|
||||||
expect(find.text('Pick from gallery'), findsOneWidget);
|
expect(find.text('Pick from gallery'), findsOneWidget);
|
||||||
|
expect(find.text('Enter manually'), findsOneWidget);
|
||||||
expect(find.text('Use sample receipt'), findsOneWidget);
|
expect(find.text('Use sample receipt'), findsOneWidget);
|
||||||
expect(
|
expect(
|
||||||
find.textContaining('Take a photo of a shopping receipt'),
|
find.textContaining('Scan a receipt or enter a trip by hand'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.text('Products'), findsOneWidget);
|
});
|
||||||
expect(find.text('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 {
|
testWidgets('review screen lists items and can delete one', (tester) async {
|
||||||
@@ -33,7 +198,7 @@ void main() {
|
|||||||
LineItem(id: '2', name: 'Bread', price: 2),
|
LineItem(id: '2', name: 'Bread', price: 2),
|
||||||
],
|
],
|
||||||
rawText: 'Milk 1.50\nBread 2.00',
|
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.text('Add item'), findsOneWidget);
|
||||||
expect(find.textContaining('Save trip'), findsOneWidget);
|
expect(find.textContaining('Save trip'), findsOneWidget);
|
||||||
expect(find.text('Supermarket'), 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.drag(find.text('Milk'), const Offset(-500, 0));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -58,11 +225,122 @@ void main() {
|
|||||||
home: ReviewScreen(
|
home: ReviewScreen(
|
||||||
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
|
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
|
||||||
rawText: 'Jumbo\nMilk 1.50',
|
rawText: 'Jumbo\nMilk 1.50',
|
||||||
repository: ProductRepository(databasePath: ':memory:'),
|
repository: memoryRepository(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(find.text('Jumbo'), findsWidgets);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user