11 Commits
Author SHA1 Message Date
davmar ae42ba8193 improve parsing 3 2026-09-02 13:38:47 +02:00
davmar 98574af903 improve parsing 2 2026-09-02 12:35:41 +02:00
davmar e98fb0e7f4 improve parsing 2026-09-02 12:35:35 +02:00
davmar d532af6111 fix low count of items scanned 2026-09-01 17:52:52 +02:00
gitea cdf46bb12a fixes before build 3 2026-08-31 17:35:50 +02:00
gitea edbfade477 fixes before build 3 2026-08-31 17:35:46 +02:00
gitea b0708101b0 fixes before build 2 2026-08-31 17:05:22 +02:00
gitea 14fcb9a533 fixes before build 2026-08-31 17:03:03 +02:00
gitea 756f9db356 fixes before build 2026-08-31 16:51:31 +02:00
gitea 7692809219 home added 2026-08-31 12:57:17 +02:00
davmar 92581e6d55 new plus button 2026-08-31 09:40:38 +02:00
71 changed files with 8935 additions and 1850 deletions
+3 -5
View File
@@ -1,8 +1,6 @@
# Receipity # Groci
Single-screen Flutter app that proves **capture → OCR**: take a photo of a shopping receipt (or pick one from the gallery), run Google ML Kit Text Recognition, and show the raw extracted text. 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.
+1 -1
View File
@@ -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
+24 -4
View File
@@ -1,6 +1,6 @@
# Receipt parsing (implementation) # Receipt parsing (implementation)
This is the working guide for Receipity's receipt parser. Use it when a new This is the working guide for Groci's receipt parser. Use it when a new
shop's receipt does not parse well. shop's receipt does not parse well.
The original design notes live in [`receipt-parsing-spec.md`](../receipt-parsing-spec.md). The original design notes live in [`receipt-parsing-spec.md`](../receipt-parsing-spec.md).
@@ -23,21 +23,41 @@ For each receipt:
| `store` | Best-effort store from the header + `known_stores` | | `store` | Best-effort store from the header + `known_stores` |
| `receiptTotal` | First grand-total amount after the items | | `receiptTotal` | First grand-total amount after the items |
| `totalDiscount` | Receipt-level `Totaal korting` (and similar) | | `totalDiscount` | Receipt-level `Totaal korting` (and similar) |
| `validationPassed` | Item sum matches `receiptTotal` within €0.01 | | `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: *Parsed totals do not match the receipt.* If validation fails, Review shows a banner asking you to check items before saving.
--- ---
## Pipeline ## 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. 1. **Preprocess** each OCR line: trim, collapse spaces, drop empty lines.
2. **Choose a layout** 2. **Choose a layout**
- **Sequential** (default): name, price, quantity and discount appear in - **Sequential** (default): name, price, quantity and discount appear in
reading order. Jumbo (printed), Lidl, Kruidvat, World Toko. reading order. Jumbo (printed), Lidl, Kruidvat, World Toko.
- **Column OCR fallback:** ML Kit sometimes dumps every name, then every - **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 amount. Detected when there is a run of 4+ price-only lines *and* enough
name-like lines. Used for the Jumbo sample dump in tests. name-like lines.
3. **Classify** each line (first match wins) using the keyword JSON. 3. **Classify** each line (first match wins) using the keyword JSON.
4. **Build items** (pending name, then quantity/discount/price). 4. **Build items** (pending name, then quantity/discount/price).
5. **Validate** against the receipt total when one was found. 5. **Validate** against the receipt total when one was found.
+1 -1
View File
@@ -57,7 +57,7 @@ class _CropReceiptScreenState extends State<CropReceiptScreen> {
Future<void> _saveAndPop(Uint8List bytes) async { Future<void> _saveAndPop(Uint8List bytes) async {
try { try {
final file = File( final file = File(
'${Directory.systemTemp.path}/receipity_crop_${DateTime.now().millisecondsSinceEpoch}.jpg', '${Directory.systemTemp.path}/groci_crop_${DateTime.now().millisecondsSinceEpoch}.jpg',
); );
await file.writeAsBytes(bytes, flush: true); await file.writeAsBytes(bytes, flush: true);
if (!mounted) return; if (!mounted) return;
+452
View File
@@ -0,0 +1,452 @@
/// Dashboard of catalog, trips, lists, and this month's spend.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'models/app_overview.dart';
import 'models/shop.dart';
import 'models/supermarket.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'shop_detail_screen.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'widgets/profile_button.dart';
import 'widgets/settings_button.dart';
import 'widgets/store_tag.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({
super.key,
required this.repository,
required this.onOpenProducts,
required this.onOpenLists,
required this.onOpenShopLog,
required this.onOpenAdd,
});
final ProductRepository repository;
final VoidCallback onOpenProducts;
final VoidCallback onOpenLists;
final VoidCallback onOpenShopLog;
final VoidCallback onOpenAdd;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
AppOverview? _overview;
List<Supermarket> _stores = const [];
Object? _error;
int _loadToken = 0;
final ImagePicker _picker = ImagePicker();
@override
void initState() {
super.initState();
widget.repository.addListener(_load);
_load();
}
@override
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final token = ++_loadToken;
try {
final overview = await widget.repository.getOverview();
final stores = await widget.repository.getSupermarkets();
final lastTripStores = await widget.repository.getLastTripSupermarkets();
if (!mounted || token != _loadToken) return;
setState(() {
_overview = overview;
_stores = sortStoresByLastTrip(stores, lastTripStores);
_error = null;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
}
Future<void> _openShop(Shop shop) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) =>
ShopDetailScreen(repository: widget.repository, shopId: shop.id),
),
);
if (mounted) _load();
}
Future<void> _openBonusCard(Supermarket store) async {
final path = store.bonusImagePath;
if (path != null && path.isNotEmpty && File(path).existsSync()) {
await _viewBonusCard(path);
return;
}
await _pickBonusCard(store);
}
Future<void> _viewBonusCard(String path) async {
await showDialog<void>(
context: context,
builder: (context) {
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: InteractiveViewer(
child: Image.file(File(path), fit: BoxFit.contain),
),
);
},
);
}
Future<void> _pickBonusCard(Supermarket store) async {
final source = await showModalBottomSheet<ImageSource>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Take photo'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Pick from gallery'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
);
},
);
if (source == null || store.id == null) return;
final file = await _picker.pickImage(source: source, imageQuality: 85);
if (file == null) return;
await widget.repository.setSupermarketBonusCard(store.id!, file.path);
if (mounted) _load();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Home'),
actions: const [ProfileButton(), SettingsButton()],
),
body: _error != null && _overview == null
? Center(
child: Text(
'Could not load an overview.',
style: theme.textTheme.bodyLarge,
),
)
: _overview == null
? const Center(child: CircularProgressIndicator())
: _HomeBody(
overview: _overview!,
stores: _stores,
onOpenProducts: widget.onOpenProducts,
onOpenLists: widget.onOpenLists,
onOpenShopLog: widget.onOpenShopLog,
onOpenAdd: widget.onOpenAdd,
onOpenShop: _openShop,
onOpenBonusCard: _openBonusCard,
),
);
}
}
class _HomeBody extends StatelessWidget {
const _HomeBody({
required this.overview,
required this.stores,
required this.onOpenProducts,
required this.onOpenLists,
required this.onOpenShopLog,
required this.onOpenAdd,
required this.onOpenShop,
required this.onOpenBonusCard,
});
final AppOverview overview;
final List<Supermarket> stores;
final VoidCallback onOpenProducts;
final VoidCallback onOpenLists;
final VoidCallback onOpenShopLog;
final VoidCallback onOpenAdd;
final ValueChanged<Shop> onOpenShop;
final ValueChanged<Supermarket> onOpenBonusCard;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final settingsStores =
SettingsScope.maybeOf(context)?.supermarkets ?? stores;
final empty =
overview.productCount == 0 &&
overview.tripCount == 0 &&
overview.listCount == 0;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Text('Overview', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
if (empty)
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Text(
'Nothing logged yet.',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Scan a receipt or add a trip to see products, lists, and spending here.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onOpenAdd,
icon: const Icon(Icons.add),
label: const Text('Add a trip'),
),
],
),
),
)
else ...[
Row(
children: [
Expanded(
child: _StatCard(
icon: Icons.receipt_long_outlined,
label: formatMonthYear(DateTime.now()),
value: formatEuro(overview.monthTotal),
detail: overview.monthTripCount == 0
? 'No trips yet'
: overview.monthTripCount == 1
? '1 trip'
: '${overview.monthTripCount} trips',
onTap: onOpenShopLog,
),
),
const SizedBox(width: 8),
Expanded(
child: _StatCard(
icon: Icons.inventory_2_outlined,
label: 'Products',
value: '${overview.productCount}',
detail: overview.favouriteCount == 0
? 'No favourites'
: overview.favouriteCount == 1
? '1 favourite'
: '${overview.favouriteCount} favourites',
onTap: onOpenProducts,
),
),
],
),
const SizedBox(height: 8),
_StatCard(
icon: Icons.checklist_outlined,
label: 'Shopping lists',
value: '${overview.listCount}',
detail: overview.listCount == 1 ? '1 list' : 'Open your lists',
onTap: onOpenLists,
),
],
if (stores.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Bonus cards', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 148,
child: ListView.separated(
key: ValueKey(
stores.isEmpty ? 'bonus-empty' : 'bonus-${stores.first.name}',
),
scrollDirection: Axis.horizontal,
itemCount: stores.length,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final store = stores[index];
return _BonusCardTile(
store: store,
stores: settingsStores,
onTap: () => onOpenBonusCard(store),
);
},
),
),
],
if (overview.recentShops.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Recent trips', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
for (final shop in overview.recentShops)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: shop.name == null || shop.name!.isEmpty
? StoreTag(name: shop.supermarket, stores: settingsStores)
: Text(shop.name!),
subtitle: Text(
[
formatDate(shop.shoppedAt),
formatEuro(shop.total),
].join(' · '),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => onOpenShop(shop),
),
),
],
],
);
}
}
class _BonusCardTile extends StatelessWidget {
const _BonusCardTile({
required this.store,
required this.stores,
required this.onTap,
});
final Supermarket store;
final List<Supermarket> stores;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final path = store.bonusImagePath;
final hasCard =
path != null && path.isNotEmpty && File(path).existsSync();
return SizedBox(
width: 220,
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: !hasCard
? ColoredBox(
color: store.color.withValues(alpha: 0.18),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.card_membership_outlined,
color: store.color,
size: 32,
),
const SizedBox(height: 8),
Text(
'Add bonus card',
style: theme.textTheme.bodySmall,
),
],
),
),
)
: Image.file(
File(path),
fit: BoxFit.cover,
width: double.infinity,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
child: Row(
children: [
StoreTag(name: store.name, stores: stores, compact: true),
const Spacer(),
Icon(
hasCard
? Icons.zoom_in
: Icons.camera_alt_outlined,
size: 16,
color: scheme.onSurfaceVariant,
),
],
),
),
],
),
),
),
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({
required this.icon,
required this.label,
required this.value,
required this.detail,
required this.onTap,
});
final IconData icon;
final String label;
final String value;
final String detail;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: scheme.primary),
const SizedBox(height: 12),
Text(label, style: theme.textTheme.bodyMedium),
const SizedBox(height: 4),
Text(value, style: theme.textTheme.titleLarge),
const SizedBox(height: 2),
Text(detail, style: theme.textTheme.bodyMedium),
],
),
),
),
);
}
}
+140 -29
View File
@@ -1,8 +1,9 @@
/// Bottom navigation: products, lists, add, 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';
@@ -20,10 +21,14 @@ class HomeShell extends StatefulWidget {
} }
class _HomeShellState extends State<HomeShell> { class _HomeShellState extends State<HomeShell> {
static const _homeIndex = 0;
static const _productsIndex = 1;
static const _addIndex = 2; static const _addIndex = 2;
static const _shopLogIndex = 3; static const _listsIndex = 3;
static const _shopLogIndex = 4;
static const _fabGap = 56.0;
int _index = _addIndex; int _index = _homeIndex;
int _revision = 0; int _revision = 0;
AppSettings? _settings; AppSettings? _settings;
int _seenGeneration = 0; int _seenGeneration = 0;
@@ -65,19 +70,34 @@ class _HomeShellState extends State<HomeShell> {
}); });
} }
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 => ShoppingListsScreen( _listsIndex => ShoppingListsScreen(
key: ValueKey('lists-$_revision'), key: ValueKey('lists-$_revision'),
repository: widget.repository, repository: widget.repository,
), ),
2 => ScanScreen( _addIndex => ScanScreen(
repository: widget.repository, repository: widget.repository,
onTripSaved: _onTripSaved, onTripSaved: _onTripSaved,
), ),
@@ -86,29 +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,
label: 'Products', foregroundColor: scheme.onPrimary,
), child: const Icon(Icons.add, size: 24),
NavigationDestination( ),
icon: Icon(Icons.checklist_outlined), bottomNavigationBar: BottomAppBar(
selectedIcon: Icon(Icons.checklist), shape: const CircularNotchedRectangle(),
label: 'Lists', notchMargin: 6,
), elevation: 2,
NavigationDestination( height: 52,
icon: Icon(Icons.add), padding: EdgeInsets.zero,
selectedIcon: Icon(Icons.add_circle), color: scheme.surfaceContainer,
label: 'Add', child: Row(
), children: [
NavigationDestination( Expanded(
icon: Icon(Icons.receipt_long_outlined), child: Row(
selectedIcon: Icon(Icons.receipt_long), children: [
label: 'Shop log', Expanded(
child: _NavButton(
icon: Icons.home_outlined,
selectedIcon: Icons.home,
label: 'Home',
selected: _index == _homeIndex,
onTap: () => _select(_homeIndex),
),
),
Expanded(
child: _NavButton(
icon: Icons.inventory_2_outlined,
selectedIcon: Icons.inventory_2,
label: 'Products',
selected: _index == _productsIndex,
onTap: () => _select(_productsIndex),
),
),
],
),
),
const SizedBox(width: _fabGap),
Expanded(
child: Row(
children: [
Expanded(
child: _NavButton(
icon: Icons.checklist_outlined,
selectedIcon: Icons.checklist,
label: 'Lists',
selected: _index == _listsIndex,
onTap: () => _select(_listsIndex),
),
),
Expanded(
child: _NavButton(
icon: Icons.receipt_long_outlined,
selectedIcon: Icons.receipt_long,
label: 'Shop log',
selected: _index == _shopLogIndex,
onTap: () => _select(_shopLogIndex),
),
),
],
),
),
],
),
),
);
}
}
class _NavButton extends StatelessWidget {
const _NavButton({
required this.icon,
required this.selectedIcon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final IconData selectedIcon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final color = selected ? scheme.primary : scheme.onSurfaceVariant;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(selected ? selectedIcon : icon, color: color, size: 20),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: color,
height: 1.1,
fontSize: 10,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
), ),
], ],
), ),
+5 -5
View File
@@ -13,20 +13,20 @@ import 'utils/constants.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await ReceiptParser.loadConfig(); await ReceiptParser.loadConfig();
runApp(ReceipityApp()); 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
+24
View File
@@ -0,0 +1,24 @@
/// Counts and recent trips shown on the home dashboard.
library;
import 'shop.dart';
class AppOverview {
const AppOverview({
required this.productCount,
required this.favouriteCount,
required this.tripCount,
required this.monthTripCount,
required this.monthTotal,
required this.listCount,
required this.recentShops,
});
final int productCount;
final int favouriteCount;
final int tripCount;
final int monthTripCount;
final double monthTotal;
final int listCount;
final List<Shop> recentShops;
}
+23
View File
@@ -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,
@@ -9,6 +11,8 @@ class LineItem {
this.quantity, this.quantity,
this.unitPrice, this.unitPrice,
this.discount = 0, this.discount = 0,
this.sourceBounds,
this.needsManualReview = false,
}); });
final String id; final String id;
@@ -22,6 +26,20 @@ class LineItem {
/// Negative amount when a discount was folded into [price]. /// Negative amount when a discount was folded into [price].
final double discount; 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,
@@ -29,7 +47,10 @@ class LineItem {
int? quantity, int? quantity,
double? unitPrice, double? unitPrice,
double? discount, 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,
@@ -38,6 +59,8 @@ class LineItem {
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, discount: discount ?? this.discount,
sourceBounds: clearSourceBounds ? null : (sourceBounds ?? this.sourceBounds),
needsManualReview: needsManualReview ?? this.needsManualReview,
); );
} }
} }
+61
View File
@@ -0,0 +1,61 @@
/// One OCR text fragment with pixel bounding box on the receipt image.
library;
class OcrElement {
const OcrElement({
required this.text,
required this.left,
required this.top,
required this.right,
required this.bottom,
});
final String text;
final double left;
final double top;
final double right;
final double bottom;
double get centerX => (left + right) / 2;
double get centerY => (top + bottom) / 2;
double get height => (bottom - top).abs();
double get width => (right - left).abs();
}
/// One ML Kit text line with its word-level elements.
class OcrLine {
const OcrLine({
required this.text,
required this.elements,
required this.left,
required this.top,
required this.right,
required this.bottom,
});
final String text;
final List<OcrElement> elements;
final double left;
final double top;
final double right;
final double bottom;
double get centerY => (top + bottom) / 2;
}
/// All positioned elements from one ML Kit recognition pass.
class OcrDocument {
const OcrDocument({
required this.elements,
required this.lines,
required this.imageWidth,
required this.imageHeight,
required this.flattenedText,
});
final List<OcrElement> elements;
final List<OcrLine> lines;
final double imageWidth;
final double imageHeight;
final String flattenedText;
}
+1 -1
View File
@@ -72,4 +72,4 @@ class Product {
} }
} }
enum ProductSort { name, preferred } enum ProductSort { name, preferred, favourite }
+57
View File
@@ -0,0 +1,57 @@
/// Normalized rectangle (0–1) on the receipt photo for review highlights.
library;
class ReceiptBounds {
const ReceiptBounds({
required this.left,
required this.top,
required this.right,
required this.bottom,
});
final double left;
final double top;
final double right;
final double bottom;
bool get isEmpty => right <= left || bottom <= top;
ReceiptBounds union(ReceiptBounds other) {
if (isEmpty) return other;
if (other.isEmpty) return this;
return ReceiptBounds(
left: left < other.left ? left : other.left,
top: top < other.top ? top : other.top,
right: right > other.right ? right : other.right,
bottom: bottom > other.bottom ? bottom : other.bottom,
);
}
static ReceiptBounds? merge(Iterable<ReceiptBounds> bounds) {
ReceiptBounds? merged;
for (final box in bounds) {
if (box.isEmpty) continue;
merged = merged == null ? box : merged.union(box);
}
return merged;
}
static ReceiptBounds fromPixelRect({
required double left,
required double top,
required double right,
required double bottom,
required double imageWidth,
required double imageHeight,
}) {
if (imageWidth <= 0 || imageHeight <= 0) {
return const ReceiptBounds(left: 0, top: 0, right: 0, bottom: 0);
}
return ReceiptBounds(
left: left / imageWidth,
top: top / imageHeight,
right: right / imageWidth,
bottom: bottom / imageHeight,
);
}
}
+39
View File
@@ -0,0 +1,39 @@
/// One visually reconstructed receipt row from clustered OCR elements.
library;
import 'ocr_element.dart';
import 'receipt_bounds.dart';
enum ReceiptRowKind {
skip,
quantityModifier,
nameOnly,
item,
discount,
grandTotal,
totalDiscount,
}
class ReceiptRow {
const ReceiptRow({
required this.kind,
required this.text,
required this.nameText,
this.price,
this.quantity,
this.unitPrice,
this.lineTotal,
this.elements = const [],
this.bounds,
});
final ReceiptRowKind kind;
final String text;
final String nameText;
final double? price;
final int? quantity;
final double? unitPrice;
final double? lineTotal;
final List<OcrElement> elements;
final ReceiptBounds? bounds;
}
+30 -5
View File
@@ -8,22 +8,47 @@ class Shop {
required this.shoppedAt, required this.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(),
); );
+5
View File
@@ -7,6 +7,7 @@ class ShoppingList {
required this.name, required this.name,
required this.itemCount, required this.itemCount,
required this.checkedCount, required this.checkedCount,
required this.createdAt,
required this.updatedAt, required this.updatedAt,
}); });
@@ -14,6 +15,7 @@ class ShoppingList {
final String name; final String name;
final int itemCount; final int itemCount;
final int checkedCount; final int checkedCount;
final DateTime createdAt;
final DateTime updatedAt; final DateTime updatedAt;
factory ShoppingList.fromMap(Map<String, Object?> map) { factory ShoppingList.fromMap(Map<String, Object?> map) {
@@ -22,6 +24,9 @@ class ShoppingList {
name: map['name']! as String, name: map['name']! as String,
itemCount: (map['item_count'] as num?)?.toInt() ?? 0, itemCount: (map['item_count'] as num?)?.toInt() ?? 0,
checkedCount: (map['checked_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), updatedAt: DateTime.parse(map['updated_at']! as String),
); );
} }
+34
View File
@@ -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
+231 -178
View File
@@ -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,186 +286,206 @@ 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(
appBar: AppBar(title: const Text('Product')), canPop: false,
body: FutureBuilder( onPopInvokedWithResult: (didPop, _) {
future: _future, if (didPop) return;
builder: (context, snapshot) { _leave();
if (snapshot.hasError) { },
return Center( child: Scaffold(
child: Text( appBar: AppBar(title: const Text('Product')),
'Could not load this product.', body: FutureBuilder(
style: theme.textTheme.bodyLarge, future: _future,
), builder: (context, snapshot) {
); if (snapshot.hasError) {
} return Center(
if (!snapshot.hasData) { child: Text(
return const Center(child: CircularProgressIndicator()); 'Could not load this product.',
} style: theme.textTheme.bodyLarge,
),
);
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final product = snapshot.data!.product; final product = snapshot.data!.product;
final purchases = snapshot.data!.purchases; final purchases = snapshot.data!.purchases;
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
GestureDetector( GestureDetector(
onTap: _chooseImageSource, onTap: _chooseImageSource,
child: Stack( child: Stack(
children: [ children: [
ProductImage( ProductImage(
name: product.name, name: product.name,
path: product.imagePath, path: product.imagePath,
size: 88, size: 88,
),
Positioned(
right: 0,
bottom: 0,
child: Material(
color: theme.colorScheme.primary,
shape: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.camera_alt,
size: 14,
color: theme.colorScheme.onPrimary,
),
),
), ),
), Positioned(
], right: 0,
), bottom: 0,
), child: Material(
const SizedBox(width: 12), color: theme.colorScheme.primary,
Expanded( shape: const CircleBorder(),
child: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.stretch, padding: const EdgeInsets.all(4),
children: [ child: Icon(
Row( Icons.camera_alt,
children: [ size: 14,
Expanded( color: theme.colorScheme.onPrimary,
child: TextField(
controller: _nameController,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Name',
isDense: true,
), ),
onSubmitted: (_) => _saveName(),
onEditingComplete: _saveName,
), ),
), ),
IconButton(
tooltip: product.favourite
? 'Remove favourite'
: 'Add favourite',
onPressed: () => _toggleFavourite(product),
icon: Icon(
product.favourite
? Icons.star
: Icons.star_outline,
color: product.favourite
? theme.colorScheme.primary
: null,
),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: InkWell(
onTap: _pickStore,
borderRadius: BorderRadius.circular(8),
child: product.supermarket == null
? Text(
'Add supermarket',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
),
)
: StoreTag(
name: product.supermarket,
stores: stores,
),
), ),
), ],
], ),
), ),
), const SizedBox(width: 12),
], Expanded(
), child: Column(
const SizedBox(height: 24), crossAxisAlignment: CrossAxisAlignment.stretch,
Text( children: [
formatEuro(product.lastPrice), Row(
style: theme.textTheme.headlineMedium, children: [
), Expanded(
Text('Price per unit', style: theme.textTheme.bodyMedium), child: TextField(
const SizedBox(height: 12), controller: _nameController,
Align( textCapitalization:
alignment: Alignment.centerLeft, TextCapitalization.sentences,
child: ActionChip( decoration: const InputDecoration(
avatar: const Icon(Icons.category_outlined, size: 18), labelText: 'Name',
label: Text(product.category ?? 'Add category'), isDense: true,
onPressed: () => _pickCategory(product), ),
onSubmitted: (_) => _saveName(),
onEditingComplete: _saveName,
onTapOutside: (_) => _saveName(),
),
),
IconButton(
tooltip: product.favourite
? 'Remove favourite'
: 'Add favourite',
onPressed: () => _toggleFavourite(product),
icon: Icon(
product.favourite
? Icons.star
: Icons.star_outline,
color: product.favourite
? theme.colorScheme.primary
: null,
),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: InkWell(
onTap: _pickStore,
borderRadius: BorderRadius.circular(8),
child: product.supermarket == null
? Text(
'Add supermarket',
style: theme.textTheme.labelMedium
?.copyWith(
color: theme.colorScheme.primary,
),
)
: StoreTag(
name: product.supermarket,
stores: stores,
),
),
),
],
),
),
],
), ),
), const SizedBox(height: 24),
const SizedBox(height: 24),
Text(
'Price and purchase history',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
if (purchases.isEmpty)
Text( Text(
'Scan a receipt to start a history.', formatEuro(product.lastPrice),
style: theme.textTheme.bodyMedium, style: theme.textTheme.headlineMedium,
)
else ...[
PriceChart(purchases: purchases),
const SizedBox(height: 8),
...purchases.map((purchase) {
return ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: const Icon(Icons.shopping_bag_outlined),
title: Text(formatEuro(purchase.unitOrLinePrice)),
subtitle: Text(
[
formatDate(purchase.boughtAt),
purchase.supermarket,
if (purchase.quantity != null) '${purchase.quantity}×',
].join(' · '),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _openShop(purchase.shopId),
);
}),
],
const SizedBox(height: 24),
Text('Notes', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
TextField(
controller: _notesController,
minLines: 3,
maxLines: 6,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
hintText: 'Add a note about this product',
), ),
onEditingComplete: _saveNotes, Text('Price per unit', style: theme.textTheme.bodyMedium),
onTapOutside: (_) => _saveNotes(), const SizedBox(height: 12),
), Align(
], alignment: Alignment.centerLeft,
); child: ActionChip(
}, avatar: const Icon(Icons.category_outlined, size: 18),
label: Text(product.category ?? 'Add category'),
onPressed: () => _pickCategory(product),
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
onPressed: () => _addToList(product),
icon: const Icon(Icons.playlist_add),
label: const Text('Add to list'),
),
),
const SizedBox(height: 24),
Text(
'Price and purchase history',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
if (purchases.isEmpty)
Text(
'Scan a receipt to start a history.',
style: theme.textTheme.bodyMedium,
)
else ...[
PriceChart(purchases: purchases),
const SizedBox(height: 8),
...purchases.map((purchase) {
return ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: const Icon(Icons.shopping_bag_outlined),
title: Text(formatEuro(purchase.unitOrLinePrice)),
subtitle: Text(
[
formatDate(purchase.boughtAt),
purchase.supermarket,
if (purchase.quantity != null)
'${purchase.quantity}×',
].join(' · '),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _openShop(purchase.shopId),
);
}),
],
const SizedBox(height: 24),
Text('Notes', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
TextField(
controller: _notesController,
minLines: 3,
maxLines: 6,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
hintText: 'Add a note about this product',
),
onEditingComplete: _saveNotes,
onTapOutside: (_) => _saveNotes(),
),
],
);
},
),
), ),
); );
} }
@@ -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'),
),
], ],
); );
} }
+333 -143
View File
@@ -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(() {
void _reload() { _products = products;
setState(() => _future = _load()); _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);
}
} }
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,150 +219,227 @@ class _ProductsScreenState extends State<ProductsScreen> {
) )
: const Text('Products'), : const Text('Products'),
actions: [ actions: [
IconButton( if (!_editing)
tooltip: _searching ? 'Close search' : 'Search', IconButton(
onPressed: () { tooltip: _searching ? 'Close search' : 'Search',
setState(() { onPressed: _hasProducts
_searching = !_searching; ? () {
if (!_searching) _search.clear(); setState(() {
}); _searching = !_searching;
}, if (!_searching) _search.clear();
icon: Icon(_searching ? Icons.close : Icons.search), });
), }
const SettingsButton(), : null,
icon: Icon(_searching ? Icons.close : Icons.search),
),
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( child: SegmentedButton<ProductSort>(
children: [ segments: const [
Expanded( ButtonSegment(
child: SegmentedButton<ProductSort>( value: ProductSort.name,
segments: const [ label: Text('A–Z'),
ButtonSegment(
value: ProductSort.preferred,
label: Text('Preferred'),
),
ButtonSegment(
value: ProductSort.name,
label: Text('A–Z'),
),
],
selected: {_sort},
onSelectionChanged: (selected) {
setState(() {
_sort = selected.first;
_future = _load();
});
},
),
), ),
const SizedBox(width: 8), ButtonSegment(
IconButton.filledTonal( value: ProductSort.preferred,
tooltip: 'Favourites', label: Text('Frequent'),
isSelected: _favouritesOnly, ),
onPressed: () { ButtonSegment(
setState(() { value: ProductSort.favourite,
_favouritesOnly = !_favouritesOnly; icon: Icon(Icons.star),
_future = _load(); label: Text('Star'),
});
},
icon: Icon(
_favouritesOnly ? Icons.star : Icons.star_outline,
),
), ),
], ],
), selected: {_sort},
), onSelectionChanged: (selected) {
Expanded( setState(() {
child: FutureBuilder<List<Product>>( _sort = selected.first;
future: _future, });
builder: (context, snapshot) { _load();
if (snapshot.hasError) {
return Center(
child: Text(
'Could not load products.',
style: theme.textTheme.bodyLarge,
),
);
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final products = _filter(snapshot.data!);
if (snapshot.data!.isEmpty) {
return Center(
child: Text(
_favouritesOnly
? 'Star products to keep them as favourites.'
: 'Confirmed receipt items will show up here.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
);
}
if (products.isEmpty) {
return Center(
child: Text(
'No products match this search.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 16),
itemCount: products.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 0,
),
leading: ProductImage(
name: product.name,
path: product.imagePath,
size: 36,
),
title: Text(
product.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: product.supermarket == null
? null
: Padding(
padding: const EdgeInsets.only(top: 2),
child: Align(
alignment: Alignment.centerLeft,
child: StoreTag(
name: product.supermarket,
stores: stores,
compact: true,
),
),
),
trailing: Text(
formatEuro(product.lastPrice),
style: theme.textTheme.titleMedium,
),
onTap: () => _openProduct(product),
);
},
);
}, },
), ),
), ),
if (_nameIssues.isNotEmpty && !_editing)
ProductNameConflictBanner(
issues: _nameIssues,
onTap: _resolveNameIssues,
),
Expanded(child: _buildList(theme, stores, products)),
], ],
), ),
); );
} }
Widget _buildList(
ThemeData theme,
List<Supermarket> stores,
List<Product> products,
) {
if (_error != null && _products == null) {
return Center(
child: Text(
'Could not load products.',
style: theme.textTheme.bodyLarge,
),
);
}
if (_products == null) {
return const Center(child: CircularProgressIndicator());
}
if (_products!.isEmpty) {
return Center(
child: Text(
_sort == ProductSort.favourite
? 'Star products to keep them as favourites.'
: 'Confirmed receipt items will show up here.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
);
}
if (products.isEmpty) {
return Center(
child: Text(
'No products match this search.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 16),
itemCount: products.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final product = products[index];
final hasIssue = _issueProductIds.contains(product.id);
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 0,
),
minLeadingWidth: _editing ? 72 : 36,
leading: _editing
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Delete',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
onPressed: () => _removeProduct(product),
icon: const Icon(Icons.delete_outline),
),
IconButton(
tooltip: product.favourite
? 'Remove favourite'
: 'Favourite',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
onPressed: () => _toggleFavourite(product),
icon: Icon(
product.favourite ? Icons.star : Icons.star_outline,
),
),
],
)
: Stack(
clipBehavior: Clip.none,
children: [
ProductImage(
name: product.name,
path: product.imagePath,
size: 36,
),
if (hasIssue && !_editing)
Positioned(
right: -2,
top: -2,
child: ProductNameConflictMarker(
compact: true,
onTap: () =>
_resolveNameIssues(focusProductId: product.id),
),
),
],
),
title: Text(
product.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: product.supermarket == null
? null
: Padding(
padding: const EdgeInsets.only(top: 2),
child: Align(
alignment: Alignment.centerLeft,
child: StoreTag(
name: product.supermarket,
stores: stores,
compact: true,
),
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (hasIssue && !_editing)
Padding(
padding: const EdgeInsets.only(right: 4),
child: ProductNameConflictMarker(
onTap: () => _resolveNameIssues(focusProductId: product.id),
),
),
Text(
formatEuro(product.lastPrice),
style: theme.textTheme.titleMedium,
),
],
),
onTap: _editing ? null : () => _openProduct(product),
);
},
);
}
} }
+44
View File
@@ -0,0 +1,44 @@
/// User profile placeholder. Sign-in will be added later.
library;
import 'package:flutter/material.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: ListView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 32),
children: [
CircleAvatar(
radius: 40,
backgroundColor: scheme.secondaryContainer,
child: Icon(
Icons.person_outline,
size: 40,
color: scheme.onSecondaryContainer,
),
),
const SizedBox(height: 16),
Text(
'Guest',
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Sign in, cloud sync, and account settings will show up here later.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
],
),
);
}
}
+303 -62
View File
@@ -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,8 +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/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({
@@ -22,6 +27,8 @@ class ReviewScreen extends StatefulWidget {
this.supermarketNames = kSupermarkets, this.supermarketNames = kSupermarkets,
this.needsReview = false, this.needsReview = false,
this.detectedStore, this.detectedStore,
this.ocrImageWidth,
this.ocrImageHeight,
}); });
final List<LineItem> items; final List<LineItem> items;
@@ -31,6 +38,8 @@ class ReviewScreen extends StatefulWidget {
final List<String> supermarketNames; final List<String> supermarketNames;
final bool needsReview; final bool needsReview;
final String? detectedStore; final String? detectedStore;
final double? ocrImageWidth;
final double? ocrImageHeight;
@override @override
State<ReviewScreen> createState() => _ReviewScreenState(); State<ReviewScreen> createState() => _ReviewScreenState();
@@ -42,6 +51,11 @@ 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;
bool _showTripExtras = false;
@override @override
void initState() { void initState() {
@@ -57,6 +71,18 @@ class _ReviewScreenState extends State<ReviewScreen> {
} else if (widget.detectedStore != null) { } else if (widget.detectedStore != null) {
_supermarket = widget.detectedStore; _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 {
@@ -69,6 +95,11 @@ 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 showItemEditSheet(context, item: existing); final result = await showItemEditSheet(context, item: existing);
if (result == null || !mounted) return; if (result == null || !mounted) return;
@@ -79,9 +110,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;
}); });
} }
@@ -96,6 +138,97 @@ class _ReviewScreenState extends State<ReviewScreen> {
setState(() => _shoppedAt = picked); setState(() => _shoppedAt = picked);
} }
Future<void> _openReceiptPanel() async {
final path = widget.imagePath;
if (path == null || path.isEmpty) return;
final file = File(path);
if (!file.existsSync()) return;
final highlighted = _highlightedItemId == null
? null
: _items.cast<LineItem?>().firstWhere(
(item) => item?.id == _highlightedItemId,
orElse: () => null,
);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Receipt', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
if (highlighted?.sourceBounds != null)
ReceiptHighlightOverlay(
imagePath: path,
bounds: highlighted!.sourceBounds!,
maxHeight: 360,
)
else
GestureDetector(
onTap: () async {
await showDialog<void>(
context: context,
builder: (ctx) => Dialog(
insetPadding: const EdgeInsets.all(16),
child: InteractiveViewer(
child: Image.file(file, fit: BoxFit.contain),
),
),
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(file, fit: BoxFit.contain, height: 360),
),
),
],
),
),
);
},
);
}
Future<void> _openOcrPanel() async {
if (widget.rawText.isEmpty) return;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Raw OCR text', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.55,
),
child: SingleChildScrollView(
child: SelectableText(widget.rawText),
),
),
],
),
),
);
},
);
}
Future<void> _confirm() async { Future<void> _confirm() async {
final supermarket = _resolvedSupermarket; final supermarket = _resolvedSupermarket;
if (supermarket == null) { if (supermarket == null) {
@@ -117,6 +250,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);
@@ -141,6 +276,14 @@ class _ReviewScreenState extends State<ReviewScreen> {
if (_supermarket != null && !names.contains(_supermarket)) { if (_supermarket != null && !names.contains(_supermarket)) {
names.add(_supermarket!); names.add(_supermarket!);
} }
final flaggedCount =
_items.where((item) => item.needsManualReview).length;
final reviewMessage = flaggedCount > 0
? 'Some lines could not be parsed confidently — '
'${flaggedCount == 1 ? '1 item is' : '$flaggedCount items are'} '
'marked below. Check them against the receipt before saving.'
: 'Item count or totals do not match the receipt. '
'Check the items below before saving.';
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -164,7 +307,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Text( child: Text(
'Parsed totals do not match the receipt. Check the items before saving.', reviewMessage,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onErrorContainer, color: theme.colorScheme.onErrorContainer,
), ),
@@ -205,75 +348,173 @@ class _ReviewScreenState extends State<ReviewScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.calendar_today_outlined), leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
_showTripExtras
? Icons.expand_less
: Icons.edit_note_outlined,
),
tooltip: _showTripExtras
? 'Hide trip name & tags'
: 'Trip name & tags',
onPressed: _saving
? null
: () => setState(
() => _showTripExtras = !_showTripExtras,
),
),
const Icon(Icons.calendar_today_outlined),
],
),
title: Text(formatDate(_shoppedAt)), title: Text(formatDate(_shoppedAt)),
subtitle: const Text('Shopping date'), subtitle: const Text('Shopping date'),
onTap: _saving ? null : _pickDate, onTap: _saving ? null : _pickDate,
), ),
if (_showTripExtras) ...[
const SizedBox(height: 8),
TextField(
controller: _tripName,
enabled: !_saving,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Trip name',
hintText: 'Optional, e.g. Weekly shop',
),
),
const SizedBox(height: 12),
TagEditor(
tags: _tags,
suggestions: _tagSuggestions,
enabled: !_saving,
onChanged: (tags) => setState(() => _tags = tags),
),
const SizedBox(height: 8),
],
], ],
), ),
), ),
Expanded( Expanded(
child: _items.isEmpty child: CustomScrollView(
? Center( slivers: [
child: Text( if (_items.isEmpty)
'No line items yet.\nAdd products for this trip.', SliverFillRemaining(
textAlign: TextAlign.center, hasScrollBody: false,
style: theme.textTheme.bodyLarge, child: Center(
child: Text(
'No line items yet.\nAdd products for this trip.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
), ),
) )
: ListView.separated( else
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), SliverPadding(
itemCount: _items.length, padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
separatorBuilder: (_, _) => const SizedBox(height: 4), sliver: SliverList.separated(
itemBuilder: (context, index) { itemCount: _items.length,
final item = _items[index]; separatorBuilder: (_, _) => const SizedBox(height: 4),
return Dismissible( itemBuilder: (context, index) {
key: ValueKey(item.id), final item = _items[index];
direction: DismissDirection.endToStart, final flagged = item.needsManualReview;
background: Container( final onReceipt = _highlightedItemId == item.id;
alignment: Alignment.centerRight, return Dismissible(
padding: const EdgeInsets.only(right: 20), key: ValueKey(item.id),
decoration: BoxDecoration( direction: DismissDirection.endToStart,
color: theme.colorScheme.errorContainer, background: Container(
borderRadius: BorderRadius.circular(12), alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.delete_outline,
color: theme.colorScheme.onErrorContainer,
),
), ),
child: Icon( onDismissed: (_) {
Icons.delete_outline, setState(() {
color: theme.colorScheme.onErrorContainer, _items.removeWhere((row) => row.id == item.id);
});
},
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: flagged
? BorderSide(
color: theme.colorScheme.error
.withValues(alpha: 0.55),
)
: BorderSide.none,
),
tileColor: flagged
? theme.colorScheme.errorContainer
.withValues(alpha: 0.35)
: onReceipt
? theme.colorScheme.primaryContainer
.withValues(alpha: 0.35)
: null,
leading: item.sourceBounds != null && _hasReceipt
? IconButton(
icon: Icon(
onReceipt
? Icons.visibility
: Icons.visibility_outlined,
size: 20,
),
tooltip: 'Show on receipt',
onPressed: _saving
? null
: () => _toggleHighlight(item),
)
: flagged
? Icon(
Icons.flag_outlined,
color: theme.colorScheme.error,
size: 20,
)
: null,
title: Text(item.name),
subtitle: _itemSubtitle(item, flagged: flagged),
trailing: DiscountedPrice(item: item),
onTap: _saving ? null : () => _edit(existing: item),
), ),
), );
onDismissed: (_) { },
setState(() { ),
_items.removeWhere((row) => row.id == item.id);
});
},
child: ListTile(
title: Text(item.name),
subtitle: _itemSubtitle(item),
trailing: Text(
formatEuro(item.price),
style: theme.textTheme.titleMedium,
),
onTap: _saving ? null : () => _edit(existing: item),
),
);
},
), ),
),
if (widget.rawText.isNotEmpty)
ExpansionTile(
title: const Text('Raw OCR text'),
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 180),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SelectableText(widget.rawText),
),
),
], ],
), ),
),
if (_hasReceipt || widget.rawText.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
child: Row(
children: [
if (_hasReceipt)
Expanded(
child: OutlinedButton.icon(
onPressed: _saving ? null : _openReceiptPanel,
icon: const Icon(Icons.receipt_long_outlined, size: 18),
label: const Text('Receipt'),
),
),
if (_hasReceipt && widget.rawText.isNotEmpty)
const SizedBox(width: 8),
if (widget.rawText.isNotEmpty)
Expanded(
child: OutlinedButton.icon(
onPressed: _saving ? null : _openOcrPanel,
icon: const Icon(Icons.text_snippet_outlined, size: 18),
label: const Text('OCR text'),
),
),
],
),
),
SafeArea( SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
@@ -301,12 +542,12 @@ class _ReviewScreenState extends State<ReviewScreen> {
); );
} }
Widget? _itemSubtitle(LineItem item) { Widget? _itemSubtitle(LineItem item, {bool flagged = false}) {
final parts = [ final parts = <String>[];
if (item.quantity != null) if (flagged) parts.add('Needs review');
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}', if (item.quantity != null) {
if (item.discount != 0) 'Discount ${formatEuro(item.discount.abs())}', parts.add('${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}');
]; }
if (parts.isEmpty) return null; if (parts.isEmpty) return null;
return Text(parts.join(' · ')); return Text(parts.join(' · '));
} }
+40 -7
View File
@@ -2,6 +2,7 @@
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';
@@ -16,6 +17,7 @@ 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 {
@@ -53,6 +55,8 @@ class _ScanScreenState extends State<ScanScreen> {
} }
} 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.
} }
} }
@@ -66,7 +70,7 @@ class _ScanScreenState extends State<ScanScreen> {
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;
@@ -99,12 +103,22 @@ 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.';
} }
@@ -128,8 +142,14 @@ 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,
);
} }
} }
@@ -147,11 +167,22 @@ class _ScanScreenState extends State<ScanScreen> {
String rawText, { String rawText, {
List<LineItem>? items, List<LineItem>? items,
String? imagePath, String? imagePath,
RecognizedText? recognized,
double? imageWidth,
double? imageHeight,
}) async { }) async {
final parsedItems = items; final parsedItems = items;
ReceiptParseResult? parsed; ReceiptParseResult? parsed;
if (parsedItems == null) { if (parsedItems == null) {
parsed = ReceiptParser.parseReceipt(rawText); 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 =
@@ -167,6 +198,8 @@ class _ScanScreenState extends State<ScanScreen> {
supermarketNames: names.toList(), supermarketNames: names.toList(),
needsReview: parsed != null && !parsed.validationPassed, needsReview: parsed != null && !parsed.validationPassed,
detectedStore: parsed?.store, detectedStore: parsed?.store,
ocrImageWidth: parsed?.ocrImageWidth,
ocrImageHeight: parsed?.ocrImageHeight,
), ),
), ),
); );
@@ -182,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(
+12 -1
View File
@@ -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;
} }
} }
+12 -4
View File
@@ -3,7 +3,10 @@ library;
import 'dart:convert'; import 'dart:convert';
const int kBackupVersion = 2; const int kBackupVersion = 3;
const String kBackupFormat = 'groci-backup';
const String kLegacyBackupFormat = 'receipity-backup';
class BackupDocument { class BackupDocument {
const BackupDocument({ const BackupDocument({
@@ -17,6 +20,7 @@ class BackupDocument {
required this.shoppingLists, required this.shoppingLists,
required this.shoppingListItems, required this.shoppingListItems,
required this.images, required this.images,
this.shopTags = const [],
}); });
final int version; final int version;
@@ -26,12 +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?>> shoppingLists;
final List<Map<String, Object?>> shoppingListItems; 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,
@@ -39,6 +44,7 @@ class BackupDocument {
'products': products, 'products': products,
'shops': shops, 'shops': shops,
'shopItems': shopItems, 'shopItems': shopItems,
'shopTags': shopTags,
'shoppingLists': shoppingLists, 'shoppingLists': shoppingLists,
'shoppingListItems': shoppingListItems, 'shoppingListItems': shoppingListItems,
'images': images, 'images': images,
@@ -51,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) {
@@ -66,6 +73,7 @@ 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']), shoppingLists: _maps(decoded['shoppingLists']),
shoppingListItems: _maps(decoded['shoppingListItems']), shoppingListItems: _maps(decoded['shoppingListItems']),
images: _strings(decoded['images']), images: _strings(decoded['images']),
+9 -6
View File
@@ -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,7 @@ 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'), shoppingLists: await repository.dumpTable('shopping_lists'),
shoppingListItems: await repository.dumpTable('shopping_list_items'), shoppingListItems: await repository.dumpTable('shopping_list_items'),
images: images, images: images,
@@ -67,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',
@@ -89,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;
@@ -98,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'],
); );
@@ -125,6 +126,7 @@ class BackupService {
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);
@@ -146,6 +148,7 @@ class BackupService {
products: products, products: products,
shops: shops, shops: shops,
shopItems: document.shopItems, shopItems: document.shopItems,
shopTags: document.shopTags,
shoppingLists: document.shoppingLists, shoppingLists: document.shoppingLists,
shoppingListItems: document.shoppingListItems, shoppingListItems: document.shoppingListItems,
darkMode: document.darkMode, darkMode: document.darkMode,
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
/// Extract positioned OCR elements from ML Kit structured output.
library;
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import '../models/ocr_element.dart';
class ReceiptOcr {
ReceiptOcr._();
/// Reads blocks → lines → elements. Falls back to line-level boxes when a
/// line has no element children.
static OcrDocument fromRecognizedText(
RecognizedText recognized, {
required double imageWidth,
required double imageHeight,
}) {
final elements = <OcrElement>[];
final lines = <OcrLine>[];
var maxRight = 0.0;
var maxBottom = 0.0;
for (final block in recognized.blocks) {
for (final line in block.lines) {
final lineElements = <OcrElement>[];
if (line.elements.isNotEmpty) {
for (final element in line.elements) {
final box = element.boundingBox;
lineElements.add(
OcrElement(
text: element.text,
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
),
);
if (box.right > maxRight) maxRight = box.right;
if (box.bottom > maxBottom) maxBottom = box.bottom;
}
} else {
final box = line.boundingBox;
lineElements.add(
OcrElement(
text: line.text,
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
),
);
if (box.right > maxRight) maxRight = box.right;
if (box.bottom > maxBottom) maxBottom = box.bottom;
}
elements.addAll(lineElements);
final box = line.boundingBox;
lines.add(
OcrLine(
text: line.text,
elements: lineElements,
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
),
);
}
}
final width = imageWidth > 0 ? imageWidth : maxRight;
final height = imageHeight > 0 ? imageHeight : maxBottom;
return OcrDocument(
elements: elements,
lines: lines,
imageWidth: width,
imageHeight: height,
flattenedText: recognized.text.trim(),
);
}
}
+26
View File
@@ -25,6 +25,8 @@ class ReceiptParseConfig {
required this.trailingCategoryLetter, required this.trailingCategoryLetter,
required this.noiseKeywords, required this.noiseKeywords,
required this.itemCountKeywords, required this.itemCountKeywords,
this.rowClusterHeightFactor = 0.55,
this.priceColumnWidthFraction = 0.25,
}); });
final List<String> knownStores; final List<String> knownStores;
@@ -44,6 +46,8 @@ class ReceiptParseConfig {
final RegExp trailingCategoryLetter; final RegExp trailingCategoryLetter;
final List<String> noiseKeywords; final List<String> noiseKeywords;
final List<String> itemCountKeywords; final List<String> itemCountKeywords;
final double rowClusterHeightFactor;
final double priceColumnWidthFraction;
factory ReceiptParseConfig.fromJson(Map<String, dynamic> json) { factory ReceiptParseConfig.fromJson(Map<String, dynamic> json) {
List<String> list(String section, [String key = 'list']) { List<String> list(String section, [String key = 'list']) {
@@ -99,9 +103,31 @@ class ReceiptParseConfig {
), ),
noiseKeywords: list('noise_footer_keywords'), noiseKeywords: list('noise_footer_keywords'),
itemCountKeywords: list('line_item_count_keywords'), itemCountKeywords: list('line_item_count_keywords'),
rowClusterHeightFactor: _spatialDouble(
json,
'row_cluster_height_factor',
fallback: 0.55,
),
priceColumnWidthFraction: _spatialDouble(
json,
'price_column_width_fraction',
fallback: 0.25,
),
); );
} }
static double _spatialDouble(
Map<String, dynamic> json,
String key, {
required double fallback,
}) {
final spatial = json['spatial_parsing'];
if (spatial is! Map) return fallback;
final value = spatial[key];
if (value is num && value > 0) return value.toDouble();
return fallback;
}
static const assetPath = 'receipt-parsing-keywords.json'; static const assetPath = 'receipt-parsing-keywords.json';
/// True if [line] matches a config keyword. /// True if [line] matches a config keyword.
+55 -559
View File
@@ -1,590 +1,86 @@
/// Turns raw OCR text into [LineItem]s using receipt-parsing-keywords.json. /// 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';
import 'receipt_parse_config.dart'; import 'receipt_parse_config.dart';
export 'receipt_parse_config.dart' show loadReceiptParseConfig; export 'receipt_parse_config.dart' show loadReceiptParseConfig;
export 'receipt_text_parser.dart' show ReceiptParseResult;
/// Result of a full receipt parse, including totals used for review. import 'receipt_spatial_parser.dart';
class ReceiptParseResult { import 'receipt_text_parser.dart';
const ReceiptParseResult({
required this.items,
this.store,
this.receiptTotal,
this.totalDiscount = 0,
this.validationPassed = true,
});
final List<LineItem> items;
final String? store;
final double? receiptTotal;
final double totalDiscount;
final bool validationPassed;
}
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 ReceiptParser { class ReceiptParser {
ReceiptParser._(); ReceiptParser._();
static int _nextId = 0;
static String _id() => 'item-${++_nextId}';
static Future<void> loadConfig() => loadReceiptParseConfig(); static Future<void> loadConfig() => loadReceiptParseConfig();
static List<LineItem> parse(String rawText) => parseReceipt(rawText).items; static List<LineItem> parse(String rawText) => parseReceipt(rawText).items;
static ReceiptParseResult parseReceipt(String rawText) { /// Fallback when only flattened OCR text is available (tests, re-parse).
final config = receiptParseConfig; static ReceiptParseResult parseReceipt(String rawText) =>
final lines = rawText ReceiptTextParser.parseReceipt(rawText);
.split('\n')
.map(_preprocess)
.where((line) => line.isNotEmpty)
.toList();
if (_looksLikeColumnOcr(lines, config)) { /// Primary path: positioned ML Kit elements → row clustering → lookahead.
return _parseColumns(lines, config, rawText); static ReceiptParseResult parseReceiptFromOcr(OcrDocument document) =>
} _parseDocument(document);
return _parseSequential(lines, config, rawText);
}
static String _preprocess(String line) => static ReceiptParseResult parseRecognizedText(
line.trim().replaceAll(RegExp(r'\s+'), ' '); RecognizedText recognized, {
required double imageWidth,
static String _stripTax(String line, ReceiptParseConfig config) { required double imageHeight,
return line.replaceFirst(config.trailingCategoryLetter, '').trim();
}
static bool _looksLikeColumnOcr(
List<String> lines,
ReceiptParseConfig config,
) {
var run = 0;
var maxRun = 0;
var prices = 0;
var names = 0;
for (final line in lines) {
final stripped = _stripTax(line, config);
if (isPriceOnly(stripped)) {
prices++;
run++;
if (run > maxRun) maxRun = run;
} else {
run = 0;
if (_parseQty(stripped, config) == null &&
!_isIgnorable(stripped, config)) {
names++;
}
}
}
return maxRun >= 4 && prices >= 4 && names >= 4;
}
static ReceiptParseResult _parseSequential(
List<String> lines,
ReceiptParseConfig config,
String rawText,
) {
final items = <LineItem>[];
_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 (amount < 0 && pending != null) {
pending!.discount += amount;
pending!.lineTotal = roundMoney(
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
);
}
}
}
flush();
return _result(
items: items,
rawText: rawText,
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
);
}
static 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 items = <LineItem>[
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,
),
];
for (var i = 0; i < discountSlots.length && i < discounts.length; i++) {
final index = discountSlots[i];
if (index < 0 || index >= items.length) continue;
final item = items[index];
final discount = discounts[i];
items[index] = item.copyWith(
price: roundMoney(item.price + discount),
discount: roundMoney(item.discount + discount),
);
}
final receiptTotal = _readAmountAfterKeyword(
lines,
config,
config.grandTotalKeywords,
skipIf: (line) => config.matchesAny(line, [
...config.totalDiscountKeywords,
...config.taxInclusiveTotalKeywords,
...config.subtotalKeywords,
]),
);
var totalDiscount = discounts.fold<double>(0, (sum, value) => sum + value);
totalDiscount +=
_readAmountAfterKeyword(lines, config, config.totalDiscountKeywords) ??
0;
return _result(
items: items,
rawText: rawText,
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
);
}
static 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,
}) { }) {
final kept = items final document = ReceiptOcr.fromRecognizedText(
.where((item) => item.name.trim().isNotEmpty && item.price > 0) recognized,
.toList(); imageWidth: imageWidth,
final sum = roundMoney( imageHeight: imageHeight,
kept.fold<double>(0, (total, item) => total + item.price),
);
final passed =
receiptTotal == null ||
(sum - receiptTotal).abs() <= 0.01 ||
(sum + totalDiscount - receiptTotal).abs() <= 0.01;
return ReceiptParseResult(
items: kept,
store: _detectStore(rawText),
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
validationPassed: passed,
); );
return _parseDocument(document);
} }
static String? _detectStore(String rawText) { static ReceiptParseResult _parseDocument(OcrDocument document) {
final config = receiptParseConfig; if (ReceiptTextParser.looksLikeColumnOcr(document.flattenedText)) {
final header = rawText return _withImageDimensions(
.split('\n') ReceiptTextParser.parseReceipt(document.flattenedText),
.map(_preprocess) document,
.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); final spatial = ReceiptSpatialParser.parse(document);
if (qty != null) { if (_shouldFallbackToText(spatial)) {
return _Line(kind: _Kind.quantity, raw: line, qty: qty); return _withImageDimensions(
} ReceiptTextParser.parseReceipt(document.flattenedText),
document,
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),
); );
} }
return spatial;
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) { static bool _shouldFallbackToText(ReceiptParseResult spatial) {
if (config.separatorPattern.hasMatch(line)) return true; if (spatial.items.isEmpty) return true;
if (_urlPattern.hasMatch(line)) return true; final flagged =
if (_postalPattern.hasMatch(line)) return true; spatial.items.where((item) => item.needsManualReview).length;
if (_phonePattern.hasMatch(line.replaceAll(RegExp(r'\s'), ''))) { if (flagged > 0) return true;
return true; if (spatial.items.any((item) => item.price <= 0)) return true;
} if (spatial.receiptTotal != null && !spatial.validationPassed) 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; return false;
} }
static double? _trailingPrice(String line, ReceiptParseConfig config) { static ReceiptParseResult _withImageDimensions(
final match = RegExp(r'(-?\d+[.,]\d{2})\s*$').firstMatch(line); ReceiptParseResult result,
if (match == null) return null; OcrDocument document,
return tryParsePrice(match.group(1)!); ) {
} return ReceiptParseResult(
items: result.items,
static _Qty? _parseQty(String line, ReceiptParseConfig config) { store: result.store,
final match = receiptTotal: result.receiptTotal,
_qtyWithTotal.firstMatch(line) ?? totalDiscount: result.totalDiscount,
config.quantityStandalone.firstMatch(line); validationPassed: result.validationPassed,
if (match == null) return null; expectedItemCount: result.expectedItemCount,
final count = int.tryParse(match.group(1)!); ocrImageWidth: document.imageWidth,
final unit = tryParsePrice(match.group(2)!); ocrImageHeight: document.imageHeight,
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);
} }
} }
+362
View File
@@ -0,0 +1,362 @@
/// Cluster OCR elements into rows and classify using column positions.
library;
import '../models/ocr_element.dart';
import '../models/receipt_bounds.dart';
import '../models/receipt_row.dart';
import '../utils/money.dart';
import 'receipt_parse_config.dart';
final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b');
final _phonePattern = RegExp(r'^\d{8,}$');
final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}[A-Za-z]?$");
final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false);
/// Pure quantity row — checked before any other row classification.
final _pureQuantityRow = RegExp(
r'^\s*(\d+)\s*[xX×]\s*(\d+[,.]\d{2})\s*(PER\s+STUK|PER\s+KG|ST\.?|STUKS?)?\s*$',
caseSensitive: false,
);
final _priceToken = RegExp(r'^-?\d+[,.]\d{2}$');
class ReceiptRowBuilder {
ReceiptRowBuilder(this.config);
final ReceiptParseConfig config;
List<ReceiptRow> buildRows(OcrDocument document) {
if (document.elements.isEmpty) return const [];
final contentWidth = _contentWidth(document.elements);
final contentLeft = _contentLeft(document.elements);
final priceColumnStart =
contentLeft + contentWidth * (1 - config.priceColumnWidthFraction);
// Prefer ML Kit line boundaries — element Y-clustering merges adjacent lines.
if (document.lines.isNotEmpty) {
return [
for (final line in document.lines)
_classifyRow(
line.elements,
priceColumnStart: priceColumnStart,
imageWidth: document.imageWidth,
imageHeight: document.imageHeight,
rowText: line.text,
),
];
}
final rowClusterThreshold = _rowClusterThreshold(document.elements);
final clustered = _clusterByY(document.elements, rowClusterThreshold);
return [
for (final rowElements in clustered)
_classifyRow(
rowElements,
priceColumnStart: priceColumnStart,
imageWidth: document.imageWidth,
imageHeight: document.imageHeight,
),
];
}
double _rowClusterThreshold(List<OcrElement> elements) {
final heights = [
for (final element in elements)
if (element.height > 0) element.height,
]..sort();
if (heights.isEmpty) return 12;
final median = heights[heights.length ~/ 2];
return median * config.rowClusterHeightFactor;
}
List<List<OcrElement>> _clusterByY(
List<OcrElement> elements,
double threshold,
) {
final sorted = [...elements]..sort((a, b) {
final dy = a.centerY.compareTo(b.centerY);
if (dy != 0) return dy;
return a.left.compareTo(b.left);
});
final rows = <List<OcrElement>>[];
List<OcrElement>? current;
double? anchorY;
for (final element in sorted) {
if (current == null) {
current = [element];
anchorY = element.centerY;
continue;
}
if ((element.centerY - anchorY!).abs() <= threshold) {
current.add(element);
} else {
current.sort((a, b) => a.left.compareTo(b.left));
rows.add(current);
current = [element];
anchorY = element.centerY;
}
}
if (current != null) {
current.sort((a, b) => a.left.compareTo(b.left));
rows.add(current);
}
return rows;
}
double _contentWidth(List<OcrElement> elements) {
var minLeft = double.infinity;
var maxRight = 0.0;
for (final element in elements) {
if (element.left < minLeft) minLeft = element.left;
if (element.right > maxRight) maxRight = element.right;
}
if (minLeft == double.infinity) return 1;
return (maxRight - minLeft).clamp(1, double.infinity);
}
double _contentLeft(List<OcrElement> elements) {
var minLeft = double.infinity;
for (final element in elements) {
if (element.left < minLeft) minLeft = element.left;
}
return minLeft == double.infinity ? 0 : minLeft;
}
ReceiptRow _classifyRow(
List<OcrElement> rowElements, {
required double priceColumnStart,
required double imageWidth,
required double imageHeight,
String? rowText,
}) {
final text = rowText ?? rowElements.map((e) => e.text).join(' ').trim();
final normalized = _preprocess(text);
final bounds = _rowBounds(rowElements, imageWidth, imageHeight);
// 4. Quantity modifier — entire row, checked first.
final pureQty = _pureQuantityRow.firstMatch(normalized);
if (pureQty != null) {
final count = int.tryParse(pureQty.group(1)!);
final unit = tryParsePrice(pureQty.group(2)!);
return ReceiptRow(
kind: ReceiptRowKind.quantityModifier,
text: normalized,
nameText: '',
quantity: count,
unitPrice: unit,
lineTotal: count != null && unit != null
? roundMoney(count * unit)
: null,
elements: rowElements,
bounds: bounds,
);
}
if (_isIgnorable(normalized)) {
return ReceiptRow(
kind: ReceiptRowKind.skip,
text: normalized,
nameText: '',
elements: rowElements,
bounds: bounds,
);
}
if (config.matchesAny(normalized, config.totalDiscountKeywords)) {
return ReceiptRow(
kind: ReceiptRowKind.totalDiscount,
text: normalized,
nameText: normalized,
price: _rowPrice(rowElements, priceColumnStart) ??
_trailingPrice(normalized),
elements: rowElements,
bounds: bounds,
);
}
if (config.matchesAny(normalized, config.taxInclusiveTotalKeywords) ||
config.matchesAny(normalized, config.subtotalKeywords)) {
return ReceiptRow(
kind: ReceiptRowKind.skip,
text: normalized,
nameText: '',
elements: rowElements,
bounds: bounds,
);
}
if (config.matchesAny(normalized, config.grandTotalKeywords)) {
return ReceiptRow(
kind: ReceiptRowKind.grandTotal,
text: normalized,
nameText: normalized,
price: _rowPrice(rowElements, priceColumnStart) ??
_trailingPrice(normalized),
elements: rowElements,
bounds: bounds,
);
}
final isDiscount =
config.matchesAny(normalized, config.discountKeywords) ||
config.negativeAmount.hasMatch(normalized);
if (isDiscount) {
return ReceiptRow(
kind: ReceiptRowKind.discount,
text: normalized,
nameText: normalized,
price: _rowPrice(rowElements, priceColumnStart) ??
_trailingPrice(normalized) ??
tryParsePrice(normalized),
elements: rowElements,
bounds: bounds,
);
}
final price = _rowPrice(rowElements, priceColumnStart);
final inlineQty = config.quantityInline.firstMatch(normalized);
var nameText = _nameFromElements(rowElements, priceColumnStart);
if (price != null) {
if (inlineQty != null) {
nameText = normalized.substring(0, inlineQty.start).trim();
final count = int.tryParse(inlineQty.group(1)!);
final unit = tryParsePrice(inlineQty.group(2)!);
if (nameText.isNotEmpty && count != null && unit != null) {
return ReceiptRow(
kind: ReceiptRowKind.item,
text: normalized,
nameText: nameText,
price: price,
quantity: count,
unitPrice: unit,
lineTotal: price,
elements: rowElements,
bounds: bounds,
);
}
}
if (nameText.isEmpty || _isIgnorable(nameText)) {
return ReceiptRow(
kind: ReceiptRowKind.skip,
text: normalized,
nameText: nameText,
elements: rowElements,
bounds: bounds,
);
}
return ReceiptRow(
kind: ReceiptRowKind.item,
text: normalized,
nameText: nameText,
price: price,
lineTotal: price,
elements: rowElements,
bounds: bounds,
);
}
if (nameText.isEmpty) nameText = normalized;
return ReceiptRow(
kind: ReceiptRowKind.nameOnly,
text: normalized,
nameText: nameText,
elements: rowElements,
bounds: bounds,
);
}
double? _rowPrice(List<OcrElement> elements, double priceColumnStart) {
double? best;
var bestX = -1.0;
for (final element in elements) {
if (element.centerX < priceColumnStart) continue;
final token = element.text.trim().replaceAll(
config.trailingCategoryLetter,
'',
);
if (!_priceToken.hasMatch(token)) continue;
final value = tryParsePrice(token);
if (value == null) continue;
if (element.centerX >= bestX) {
bestX = element.centerX;
best = value;
}
}
return best;
}
String _nameFromElements(List<OcrElement> elements, double priceColumnStart) {
final parts = <String>[];
for (final element in elements) {
if (element.centerX >= priceColumnStart &&
_priceToken.hasMatch(element.text.trim())) {
continue;
}
final cleaned = element.text
.replaceFirst(config.trailingCategoryLetter, '')
.trim();
if (cleaned.isNotEmpty) parts.add(cleaned);
}
return _preprocess(parts.join(' '));
}
ReceiptBounds? _rowBounds(
List<OcrElement> elements,
double imageWidth,
double imageHeight,
) {
if (elements.isEmpty || imageWidth <= 0 || imageHeight <= 0) return null;
var left = double.infinity;
var top = double.infinity;
var right = 0.0;
var bottom = 0.0;
for (final element in elements) {
if (element.left < left) left = element.left;
if (element.top < top) top = element.top;
if (element.right > right) right = element.right;
if (element.bottom > bottom) bottom = element.bottom;
}
return ReceiptBounds.fromPixelRect(
left: left,
top: top,
right: right,
bottom: bottom,
imageWidth: imageWidth,
imageHeight: imageHeight,
);
}
String _preprocess(String line) =>
line.trim().replaceAll(RegExp(r'\s+'), ' ');
double? _trailingPrice(String line) {
final stripped = line.replaceFirst(config.trailingCategoryLetter, '').trim();
final match = RegExp(r'(-?\d+[.,]\d{2})\s*$').firstMatch(stripped);
if (match == null) return null;
return tryParsePrice(match.group(1)!);
}
bool _isIgnorable(String line) {
if (config.separatorPattern.hasMatch(line)) return true;
if (_urlPattern.hasMatch(line)) return true;
if (_postalPattern.hasMatch(line)) return true;
if (_phonePattern.hasMatch(line.replaceAll(RegExp(r'\s'), ''))) return true;
if (_streetPattern.hasMatch(line)) return true;
if (config.categoryRow.hasMatch(line)) return true;
if (config.isStandaloneKeywordLine(line, config.columnHeaderKeywords)) {
return true;
}
if (config.isStandaloneKeywordLine(line, config.itemCountKeywords)) {
return true;
}
if (config.matchesAny(line, config.taxKeywords)) return true;
if (config.matchesAny(line, config.paymentKeywords)) return true;
if (config.matchesAny(line, config.noiseKeywords)) return true;
return false;
}
}
+274
View File
@@ -0,0 +1,274 @@
/// Spatial receipt parser: row clustering, column logic, and lookahead buffer.
library;
import '../models/line_item.dart';
import '../models/ocr_element.dart';
import '../models/receipt_bounds.dart';
import '../models/receipt_row.dart';
import '../utils/money.dart';
import 'receipt_parse_config.dart';
import 'receipt_row_builder.dart';
import 'receipt_text_parser.dart';
class _PendingItem {
_PendingItem({
required this.name,
this.unitPrice,
this.lineTotal,
this.quantity = 1,
this.bounds,
});
String name;
double? unitPrice;
double? lineTotal;
int quantity;
double discount = 0;
ReceiptBounds? bounds;
void mergeBounds(ReceiptBounds? other) {
if (other == null) return;
bounds = bounds == null ? other : bounds!.union(other);
}
}
class ReceiptSpatialParser {
ReceiptSpatialParser._();
static int _nextId = 0;
static String _id() => 'item-${++_nextId}';
static ReceiptParseResult parse(OcrDocument document) {
_nextId = 0;
final config = receiptParseConfig;
final rows = ReceiptRowBuilder(config).buildRows(document);
return _parseRows(
rows,
rawText: document.flattenedText,
imageWidth: document.imageWidth,
imageHeight: document.imageHeight,
);
}
static ReceiptParseResult _parseRows(
List<ReceiptRow> rows, {
required String rawText,
required double imageWidth,
required double imageHeight,
}) {
final config = receiptParseConfig;
final items = <LineItem>[];
_PendingItem? pending;
var receiptTotal = _readTotalFromRows(rows, config);
var totalDiscount = 0.0;
var stop = false;
void emitPending({required bool needsReview}) {
final current = pending;
pending = null;
if (current == null) return;
final total = current.lineTotal ?? current.unitPrice;
if (total == null) {
items.add(
LineItem(
id: _id(),
name: current.name,
price: 0,
sourceBounds: current.bounds,
needsManualReview: true,
),
);
return;
}
items.add(
LineItem(
id: _id(),
name: current.name,
price: roundMoney(total),
quantity: current.quantity == 1 ? null : current.quantity,
unitPrice: current.quantity == 1 ? null : current.unitPrice,
discount: current.discount == 0 ? 0 : roundMoney(current.discount),
sourceBounds: current.bounds,
needsManualReview: needsReview,
),
);
}
for (var i = 0; i < rows.length && !stop; i++) {
final row = rows[i];
switch (row.kind) {
case ReceiptRowKind.skip:
continue;
case ReceiptRowKind.quantityModifier:
if (pending != null &&
row.quantity != null &&
row.unitPrice != null) {
pending!
..quantity = row.quantity!
..unitPrice = row.unitPrice
..lineTotal = row.lineTotal ?? roundMoney(row.quantity! * row.unitPrice!);
pending!.mergeBounds(row.bounds);
emitPending(needsReview: false);
}
continue;
case ReceiptRowKind.nameOnly:
emitPending(needsReview: pending?.lineTotal == null);
pending = _PendingItem(name: row.nameText, bounds: row.bounds);
final next = i + 1 < rows.length ? rows[i + 1] : null;
if (next?.kind == ReceiptRowKind.quantityModifier) {
continue;
}
emitPending(needsReview: true);
continue;
case ReceiptRowKind.item:
emitPending(needsReview: pending?.lineTotal == null);
pending = _PendingItem(
name: row.nameText,
unitPrice: row.unitPrice ?? row.price,
lineTotal: row.lineTotal ?? row.price,
quantity: row.quantity ?? 1,
bounds: row.bounds,
);
if (row.quantity != null) {
pending!.unitPrice = row.unitPrice;
}
final nextItem = i + 1 < rows.length ? rows[i + 1] : null;
if (nextItem?.kind == ReceiptRowKind.quantityModifier) {
continue;
}
emitPending(needsReview: false);
continue;
case ReceiptRowKind.discount:
var amount = row.price;
if (amount == null && i + 1 < rows.length) {
amount = rows[i + 1].price;
if (amount != null) i++;
}
if (amount == null) continue;
if (amount > 0) amount = -amount;
if (pending != null) {
pending!.discount += amount;
pending!.lineTotal = roundMoney(
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
);
pending!.mergeBounds(row.bounds);
} else {
totalDiscount += amount;
}
continue;
case ReceiptRowKind.totalDiscount:
var amount = row.price;
if (amount == null && i + 1 < rows.length) {
amount = rows[i + 1].price;
if (amount != null) i++;
}
if (amount != null) {
totalDiscount += amount > 0 ? -amount : amount;
}
continue;
case ReceiptRowKind.grandTotal:
emitPending(needsReview: pending?.lineTotal == null);
if (row.price != null) {
receiptTotal = row.price;
stop = true;
}
continue;
}
}
emitPending(needsReview: pending?.lineTotal == null);
final expectedItemCount = _readExpectedItemCount(rows, config);
return _finalize(
items: items,
rawText: rawText,
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
expectedItemCount: expectedItemCount,
imageWidth: imageWidth,
imageHeight: imageHeight,
);
}
static double? _readTotalFromRows(
List<ReceiptRow> rows,
ReceiptParseConfig config,
) {
for (final row in rows) {
if (row.kind != ReceiptRowKind.grandTotal) continue;
if (config.matchesAny(row.text, [
...config.totalDiscountKeywords,
...config.taxInclusiveTotalKeywords,
...config.subtotalKeywords,
])) {
continue;
}
return row.price;
}
return null;
}
static int? _readExpectedItemCount(
List<ReceiptRow> rows,
ReceiptParseConfig config,
) {
final pattern = RegExp(
r'(\d+)\s*$',
caseSensitive: false,
);
for (final row in rows) {
if (!config.matchesAny(row.text, config.itemCountKeywords)) continue;
final match = pattern.firstMatch(row.text);
if (match != null) {
return int.tryParse(match.group(1)!);
}
}
return null;
}
static ReceiptParseResult _finalize({
required List<LineItem> items,
required String rawText,
double? receiptTotal,
double totalDiscount = 0,
int? expectedItemCount,
required double imageWidth,
required double imageHeight,
}) {
final kept = items
.where(
(item) =>
item.name.trim().isNotEmpty &&
(item.price > 0 || item.needsManualReview),
)
.toList();
final sum = roundMoney(
kept
.where((item) => item.price > 0)
.fold<double>(0, (total, item) => total + item.price),
);
var passed = true;
if (receiptTotal != null) {
passed =
(sum - receiptTotal).abs() <= 0.01 ||
(sum + totalDiscount - receiptTotal).abs() <= 0.01;
}
if (expectedItemCount != null && kept.length != expectedItemCount) {
passed = false;
}
if (kept.any((item) => item.needsManualReview)) {
passed = false;
}
return ReceiptParseResult(
items: kept,
store: ReceiptTextParser.detectStore(rawText),
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
validationPassed: passed,
expectedItemCount: expectedItemCount,
ocrImageWidth: imageWidth,
ocrImageHeight: imageHeight,
);
}
}
+649
View File
@@ -0,0 +1,649 @@
/// Flat text-line receipt parser (fallback when spatial OCR is unavailable).
library;
import '../models/line_item.dart';
import '../utils/money.dart';
import 'receipt_parse_config.dart';
/// Result of a full receipt parse, including totals used for review.
class ReceiptParseResult {
const ReceiptParseResult({
required this.items,
this.store,
this.receiptTotal,
this.totalDiscount = 0,
this.validationPassed = true,
this.expectedItemCount,
this.ocrImageWidth,
this.ocrImageHeight,
});
final List<LineItem> items;
final String? store;
final double? receiptTotal;
final double totalDiscount;
final bool validationPassed;
final int? expectedItemCount;
final double? ocrImageWidth;
final double? ocrImageHeight;
}
class _Qty {
const _Qty({required this.count, required this.unitPrice, this.total});
final int count;
final double unitPrice;
final double? total;
double get lineTotal => total ?? roundMoney(count * unitPrice);
}
class _Pending {
_Pending({
required this.name,
this.unitPrice,
this.quantity = 1,
this.lineTotal,
});
String name;
double? unitPrice;
int quantity;
double? lineTotal;
double discount = 0;
}
enum _Kind {
skip,
item,
nameOnly,
quantity,
discount,
grandTotal,
totalDiscount,
priceOnly,
}
class _Line {
const _Line({
required this.kind,
required this.raw,
this.name,
this.amount,
this.qty,
});
final _Kind kind;
final String raw;
final String? name;
final double? amount;
final _Qty? qty;
}
final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b');
final _phonePattern = RegExp(r'^\d{8,}$');
final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}[A-Za-z]?$");
final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false);
/// Jumbo-style OCR often adds a line total after `N X unit`.
final _qtyWithTotal = RegExp(
r'^\s*(\d+)\s*[xX×]\s*(-?\d+[,.]\d{2})(?:\s+(?:PER\s+STUK|PER\s+KG|ST\.?|STUKS?))?(?:\s+(-?\d+[,.]\d{2}))?\s*$',
caseSensitive: false,
);
class ReceiptTextParser {
ReceiptTextParser._();
static int _nextId = 0;
static String _id() => 'item-${++_nextId}';
static Future<void> loadConfig() => loadReceiptParseConfig();
static List<LineItem> parse(String rawText) => parseReceipt(rawText).items;
static ReceiptParseResult parseReceipt(String rawText) {
final config = receiptParseConfig;
final lines = rawText
.split('\n')
.map(_preprocess)
.where((line) => line.isNotEmpty)
.toList();
if (_looksLikeColumnOcr(lines, config)) {
return _parseColumns(lines, config, rawText);
}
return _parseSequential(lines, config, rawText);
}
/// True when OCR dumped an entire name column before a price column (Jumbo-style).
static bool looksLikeColumnOcr(String rawText) {
final config = receiptParseConfig;
final lines = rawText
.split('\n')
.map(_preprocess)
.where((line) => line.isNotEmpty)
.toList();
return _looksLikeColumnOcr(lines, config);
}
static String _preprocess(String line) =>
line.trim().replaceAll(RegExp(r'\s+'), ' ');
static String _stripTax(String line, ReceiptParseConfig config) {
return line.replaceFirst(config.trailingCategoryLetter, '').trim();
}
static bool _looksLikeColumnOcr(
List<String> lines,
ReceiptParseConfig config,
) {
var run = 0;
var maxRun = 0;
var prices = 0;
var names = 0;
for (final line in lines) {
final stripped = _stripTax(line, config);
if (isPriceOnly(stripped)) {
prices++;
run++;
if (run > maxRun) maxRun = run;
} else {
run = 0;
if (_parseQty(stripped, config) == null &&
!_isIgnorable(stripped, config) &&
!config.matchesAny(stripped, [
...config.grandTotalKeywords,
...config.subtotalKeywords,
...config.taxInclusiveTotalKeywords,
...config.totalDiscountKeywords,
]) &&
_trailingPrice(stripped, config) == null) {
names++;
}
}
}
if (maxRun >= 4 && prices >= 4 && names >= 4) return true;
// ML Kit sometimes splits a short receipt into names, then prices (2–3 items).
return maxRun >= 2 && prices >= 2 && names >= prices && maxRun >= prices;
}
static ReceiptParseResult _parseSequential(
List<String> lines,
ReceiptParseConfig config,
String rawText,
) {
final items = <LineItem>[];
_Pending? pending;
var receiptTotal = _readAmountAfterKeyword(
lines,
config,
config.grandTotalKeywords,
skipIf: (line) => config.matchesAny(line, [
...config.totalDiscountKeywords,
...config.taxInclusiveTotalKeywords,
...config.subtotalKeywords,
]),
);
var totalDiscount = 0.0;
var stop = false;
void flush() {
final current = pending;
pending = null;
if (current == null) return;
final total = current.lineTotal ?? current.unitPrice;
if (total == null) return;
items.add(_toItem(current, total));
}
for (var i = 0; i < lines.length && !stop; i++) {
final classified = _classify(lines[i], config);
switch (classified.kind) {
case _Kind.skip:
continue;
case _Kind.item:
flush();
pending = _Pending(
name: classified.name!,
unitPrice: classified.amount,
quantity: classified.qty?.count ?? 1,
lineTotal: classified.qty == null
? classified.amount
: classified.qty!.lineTotal,
);
if (classified.qty != null) {
pending!.unitPrice = classified.qty!.unitPrice;
}
case _Kind.nameOnly:
flush();
pending = _Pending(name: classified.name ?? classified.raw);
case _Kind.quantity:
final qty = classified.qty!;
if (pending != null) {
pending!
..quantity = qty.count
..unitPrice = qty.unitPrice
..lineTotal = qty.lineTotal;
}
case _Kind.discount:
var amount = classified.amount;
if (amount == null && i + 1 < lines.length) {
amount = tryParsePrice(_stripTax(lines[i + 1], config));
if (amount != null) i++;
}
if (amount == null) continue;
if (amount > 0) amount = -amount;
if (pending != null) {
pending!.discount += amount;
pending!.lineTotal = roundMoney(
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
);
} else {
totalDiscount += amount;
}
case _Kind.totalDiscount:
var amount = classified.amount;
if (amount == null && i + 1 < lines.length) {
amount = tryParsePrice(_stripTax(lines[i + 1], config));
if (amount != null) i++;
}
if (amount != null) {
totalDiscount += amount > 0 ? -amount : amount;
}
case _Kind.grandTotal:
flush();
if (classified.amount != null) {
receiptTotal = classified.amount;
stop = true;
}
case _Kind.priceOnly:
final amount = classified.amount!;
if (pending != null) {
if (amount < 0) {
pending!.discount += amount;
pending!.lineTotal = roundMoney(
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
);
} else if (pending!.lineTotal == null &&
pending!.unitPrice == null) {
pending!.lineTotal = amount;
flush();
}
}
}
}
flush();
return _result(
items: items,
rawText: rawText,
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
lines: lines,
);
}
static ReceiptParseResult _parseColumns(
List<String> lines,
ReceiptParseConfig config,
String rawText,
) {
final names = <({String name, int? quantity, double? unitPrice})>[];
final prices = <double>[];
final discountSlots = <int>[];
final discounts = <double>[];
for (final raw in lines) {
final line = _stripTax(raw, config);
if (isPriceOnly(line)) {
final price = tryParsePrice(line);
if (price == null) continue;
if (price < 0) {
discounts.add(price);
} else {
prices.add(price);
}
continue;
}
if (_isIgnorable(line, config)) continue;
if (config.matchesAny(line, [
...config.grandTotalKeywords,
...config.subtotalKeywords,
...config.taxInclusiveTotalKeywords,
...config.totalDiscountKeywords,
])) {
continue;
}
final qty = _parseQty(line, config);
if (qty != null) {
if (names.isNotEmpty) {
final last = names.removeLast();
names.add((
name: last.name,
quantity: qty.count,
unitPrice: qty.unitPrice,
));
}
continue;
}
if (config.matchesAny(line, config.discountKeywords)) {
if (names.isNotEmpty) discountSlots.add(names.length - 1);
continue;
}
names.add((name: line, quantity: null, unitPrice: null));
}
final count = names.length < prices.length ? names.length : prices.length;
final pairedNames = names.length > count
? names.sublist(names.length - count)
: names;
final nameOffset = names.length - pairedNames.length;
final items = <LineItem>[
for (var i = 0; i < count; i++)
LineItem(
id: _id(),
name: pairedNames[i].name,
price: prices[i],
quantity: pairedNames[i].quantity,
unitPrice: pairedNames[i].unitPrice,
),
];
for (var i = 0; i < discountSlots.length && i < discounts.length; i++) {
final index = discountSlots[i] - nameOffset;
if (index < 0 || index >= items.length) continue;
final item = items[index];
final discount = discounts[i];
items[index] = item.copyWith(
price: roundMoney(item.price + discount),
discount: roundMoney(item.discount + discount),
);
}
final receiptTotal = _readAmountAfterKeyword(
lines,
config,
config.grandTotalKeywords,
skipIf: (line) => config.matchesAny(line, [
...config.totalDiscountKeywords,
...config.taxInclusiveTotalKeywords,
...config.subtotalKeywords,
]),
);
var totalDiscount = discounts.fold<double>(0, (sum, value) => sum + value);
totalDiscount +=
_readAmountAfterKeyword(lines, config, config.totalDiscountKeywords) ??
0;
return _result(
items: items,
rawText: rawText,
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
lines: lines,
);
}
static LineItem _toItem(_Pending pending, double total) {
return LineItem(
id: _id(),
name: pending.name,
price: roundMoney(total),
quantity: pending.quantity == 1 ? null : pending.quantity,
unitPrice: pending.quantity == 1 ? null : pending.unitPrice,
discount: pending.discount == 0 ? 0 : roundMoney(pending.discount),
);
}
static ReceiptParseResult _result({
required List<LineItem> items,
required String rawText,
double? receiptTotal,
double totalDiscount = 0,
List<String>? lines,
}) {
final config = receiptParseConfig;
final kept = items
.where((item) => item.name.trim().isNotEmpty && item.price > 0)
.toList();
final sum = roundMoney(
kept.fold<double>(0, (total, item) => total + item.price),
);
final expectedItemCount = lines == null
? null
: _readExpectedItemCountFromLines(lines, config);
var passed =
receiptTotal == null ||
(sum - receiptTotal).abs() <= 0.01 ||
(sum + totalDiscount - receiptTotal).abs() <= 0.01;
if (expectedItemCount != null && kept.length != expectedItemCount) {
passed = false;
}
return ReceiptParseResult(
items: kept,
store: detectStore(rawText),
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
validationPassed: passed,
expectedItemCount: expectedItemCount,
);
}
static int? _readExpectedItemCountFromLines(
List<String> lines,
ReceiptParseConfig config,
) {
final pattern = RegExp(r'(\d+)\s*$', caseSensitive: false);
for (final raw in lines) {
final line = _preprocess(raw);
if (!config.matchesAny(line, config.itemCountKeywords)) continue;
final match = pattern.firstMatch(line);
if (match != null) return int.tryParse(match.group(1)!);
}
return null;
}
static String? detectStore(String rawText) {
final config = receiptParseConfig;
final header = rawText
.split('\n')
.map(_preprocess)
.where((line) => line.isNotEmpty)
.take(8)
.join('\n')
.toUpperCase();
final stores = [...config.knownStores]
..sort((a, b) => b.length.compareTo(a.length));
for (final store in stores) {
if (RegExp('\\b${RegExp.escape(store)}\\b').hasMatch(header)) {
return _canonicalStore(store);
}
}
return null;
}
static String _canonicalStore(String raw) {
const aliases = {
'AH': 'Albert Heijn',
'ALBERT HEIJN': 'Albert Heijn',
'JUMBO': 'Jumbo',
'LIDL': 'Lidl',
'KRUIDVAT': 'Kruidvat',
'ALDI': 'Aldi',
'PLUS': 'Plus',
'DIRK': 'Dirk',
'COOP': 'Coop',
'SPAR': 'SPAR',
'VOMAR': 'Vomar',
'EKOPLAZA': 'Ekoplaza',
'HOOGVLIET': 'Hoogvliet',
'ACTION': 'Action',
'ETOS': 'Etos',
'WORLD TOKO': 'World Toko',
};
return aliases[raw.toUpperCase()] ?? raw;
}
static double? _readAmountAfterKeyword(
List<String> lines,
ReceiptParseConfig config,
List<String> keywords, {
bool Function(String line)? skipIf,
}) {
for (var i = 0; i < lines.length; i++) {
final line = _stripTax(lines[i], config);
if (!config.matchesAny(line, keywords)) continue;
if (skipIf != null && skipIf(line)) continue;
final trailing = _trailingPrice(line, config);
if (trailing != null) return trailing;
if (i + 1 < lines.length) {
final next = tryParsePrice(_stripTax(lines[i + 1], config));
if (next != null) return next;
}
}
return null;
}
static _Line _classify(String raw, ReceiptParseConfig config) {
final line = _stripTax(raw, config);
if (_isIgnorable(line, config)) {
return _Line(kind: _Kind.skip, raw: line);
}
if (config.matchesAny(line, config.totalDiscountKeywords)) {
return _Line(
kind: _Kind.totalDiscount,
raw: line,
amount: _trailingPrice(line, config),
);
}
if (config.matchesAny(line, config.taxInclusiveTotalKeywords) ||
config.matchesAny(line, config.subtotalKeywords)) {
return _Line(kind: _Kind.skip, raw: line);
}
if (config.matchesAny(line, config.grandTotalKeywords)) {
return _Line(
kind: _Kind.grandTotal,
raw: line,
amount: _trailingPrice(line, config),
);
}
final qty = _parseQty(line, config);
if (qty != null) {
return _Line(kind: _Kind.quantity, raw: line, qty: qty);
}
final isDiscount =
config.matchesAny(line, config.discountKeywords) ||
config.negativeAmount.hasMatch(line);
if (isDiscount &&
(config.matchesAny(line, config.discountKeywords) ||
isPriceOnly(line))) {
return _Line(
kind: _Kind.discount,
raw: line,
name: line,
amount: _trailingPrice(line, config) ?? tryParsePrice(line),
);
}
if (isPriceOnly(line)) {
return _Line(
kind: _Kind.priceOnly,
raw: line,
amount: tryParsePrice(line),
);
}
final inline = config.quantityInline.firstMatch(line);
final trailing = _trailingPrice(line, config);
if (trailing != null) {
var name = line.replaceFirst(RegExp(r'\s+-?\d+[.,]\d{2}\s*$'), '').trim();
_Qty? inlineQty;
if (inline != null) {
name = line.substring(0, inline.start).trim();
final count = int.tryParse(inline.group(1)!);
final unit = tryParsePrice(inline.group(2)!);
if (count != null && unit != null) {
inlineQty = _Qty(count: count, unitPrice: unit, total: trailing);
}
}
if (name.isEmpty || _isIgnorable(name, config)) {
return _Line(kind: _Kind.skip, raw: line);
}
return _Line(
kind: _Kind.item,
raw: line,
name: name,
amount: trailing,
qty: inlineQty,
);
}
if (inline != null) {
final name = line.substring(0, inline.start).trim();
final count = int.tryParse(inline.group(1)!);
final unit = tryParsePrice(inline.group(2)!);
if (name.isNotEmpty && count != null && unit != null) {
return _Line(
kind: _Kind.item,
raw: line,
name: name,
amount: roundMoney(count * unit),
qty: _Qty(count: count, unitPrice: unit),
);
}
}
return _Line(kind: _Kind.nameOnly, raw: line, name: line);
}
static bool _isIgnorable(String line, ReceiptParseConfig config) {
if (config.separatorPattern.hasMatch(line)) return true;
if (_urlPattern.hasMatch(line)) return true;
if (_postalPattern.hasMatch(line)) return true;
if (_phonePattern.hasMatch(line.replaceAll(RegExp(r'\s'), ''))) {
return true;
}
if (_streetPattern.hasMatch(line)) return true;
if (config.categoryRow.hasMatch(line)) return true;
if (config.isStandaloneKeywordLine(line, config.columnHeaderKeywords)) {
return true;
}
if (config.isStandaloneKeywordLine(line, config.itemCountKeywords)) {
return true;
}
if (config.matchesAny(line, config.taxKeywords)) return true;
if (config.matchesAny(line, config.paymentKeywords)) return true;
if (config.matchesAny(line, config.noiseKeywords)) return true;
return false;
}
static double? _trailingPrice(String line, ReceiptParseConfig config) {
final match = RegExp(r'(-?\d+[.,]\d{2})\s*$').firstMatch(line);
if (match == null) return null;
return tryParsePrice(match.group(1)!);
}
static _Qty? _parseQty(String line, ReceiptParseConfig config) {
final match =
_qtyWithTotal.firstMatch(line) ??
config.quantityStandalone.firstMatch(line);
if (match == null) return null;
final count = int.tryParse(match.group(1)!);
final unit = tryParsePrice(match.group(2)!);
if (count == null || count < 1 || unit == null) return null;
double? total;
if (match.groupCount >= 3) {
final raw = match.group(3);
if (raw != null && RegExp(r'^-?\d+[.,]\d{2}$').hasMatch(raw)) {
total = tryParsePrice(raw);
}
}
return _Qty(count: count, unitPrice: unit, total: total);
}
}
+237 -29
View File
@@ -13,6 +13,8 @@ import 'utils/dates.dart';
import 'utils/money.dart'; import 'utils/money.dart';
import 'widgets/confirm_dialog.dart'; import 'widgets/confirm_dialog.dart';
import 'widgets/item_edit_sheet.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({
@@ -29,26 +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; 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);
if (!mounted || token != _loadToken) return;
setState(() {
_shop = shop;
_items = items;
_error = null;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
} }
final items = await widget.repository.getShopItems(widget.shopId);
return (shop: shop, items: items);
}
void _reload() {
setState(() => _future = _load());
} }
Future<void> _openReceipt(File file) async { Future<void> _openReceipt(File file) async {
@@ -76,6 +100,56 @@ 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 { Future<void> _editItem(ShopItem item) async {
final result = await showItemEditSheet( final result = await showItemEditSheet(
context, context,
@@ -94,8 +168,9 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
price: result.price, price: result.price,
quantity: result.quantity, quantity: result.quantity,
unitPrice: result.unitPrice, unitPrice: result.unitPrice,
clearQuantity: result.quantity == null,
); );
if (mounted) _reload(); if (mounted) _load();
} }
Future<void> _removeItem(ShopItem item) async { Future<void> _removeItem(ShopItem item) async {
@@ -107,8 +182,96 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
confirmLabel: 'Remove', confirmLabel: 'Remove',
); );
if (!confirmed || !mounted) return; if (!confirmed || !mounted) return;
await widget.repository.deleteShopItem(item.id);
if (mounted) _reload(); 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) { Widget? _itemSubtitle(ShopItem item) {
@@ -136,10 +299,16 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
), ),
], ],
), ),
body: FutureBuilder( floatingActionButton: _editing
future: _future, ? FloatingActionButton.extended(
builder: (context, snapshot) { onPressed: _addItem,
if (snapshot.hasError) { 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.',
@@ -147,25 +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 = receipt == null ? null : File(receipt); final receiptFile = 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),
Text(shop.supermarket, style: theme.textTheme.titleMedium),
],
const SizedBox(height: 4), const SizedBox(height: 4),
Text( if (_editing)
formatDate(shop.shoppedAt), ListTile(
style: theme.textTheme.bodyLarge, contentPadding: EdgeInsets.zero,
), title: Text(formatDate(shop.shoppedAt)),
subtitle: const Text('Trip date'),
trailing: const Icon(Icons.calendar_today_outlined),
onTap: () => _editDate(shop),
)
else
Text(
formatDate(shop.shoppedAt),
style: theme.textTheme.bodyLarge,
),
if (_editing) ...[
const SizedBox(height: 16),
TagEditor(
tags: shop.tags,
suggestions: _tagSuggestions,
onChanged: (tags) => _setTags(shop, tags),
),
] else if (shop.tags.isNotEmpty) ...[
const SizedBox(height: 12),
TagChips(tags: shop.tags),
],
if (hasReceipt) ...[ if (hasReceipt) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
GestureDetector( GestureDetector(
@@ -217,7 +423,9 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
formatEuro(item.price), formatEuro(item.price),
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium,
), ),
onTap: _editing ? null : () => _openProduct(item.productId), onTap: _editing || item.productId == null
? null
: () => _openProduct(item.productId!),
), ),
const Divider(), const Divider(),
ListTile( ListTile(
+360 -100
View File
@@ -1,19 +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/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});
@@ -25,17 +29,56 @@ 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 _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 {
@@ -45,7 +88,7 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
ShopDetailScreen(repository: widget.repository, shopId: shop.id), ShopDetailScreen(repository: widget.repository, shopId: shop.id),
), ),
); );
if (mounted) _reload(); if (mounted) _load();
} }
Future<void> _removeShop(Shop shop) async { Future<void> _removeShop(Shop shop) async {
@@ -55,128 +98,345 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
message: 'This trip will be removed from the shop log. Products from this trip stay in your catalog.', message: 'This trip will be removed from the shop log. Products from this trip stay in your catalog.',
); );
if (!confirmed || !mounted) return; if (!confirmed || !mounted) return;
await widget.repository.deleteShop(shop.id); setState(() {
if (mounted) _reload(); _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
? TextField(
controller: _search,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search products in trips',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
),
)
: const Text('Shop log'),
actions: [ actions: [
IconButton( IconButton(
tooltip: _editing ? 'Done' : 'Edit', tooltip: _searching ? 'Close search' : 'Search products',
onPressed: () => setState(() => _editing = !_editing), onPressed: _hasTrips
icon: Icon(_editing ? Icons.check : Icons.edit_outlined), ? () {
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(), const SettingsButton(),
], ],
), ),
body: FutureBuilder<List<Shop>>( body: _ShopLogBody(
future: _future, theme: theme,
builder: (context, snapshot) { stores: stores,
if (snapshot.hasError) { nowMonth: nowMonth,
return Center( shops: _shops,
child: Text( productNames: _productNames,
'Could not load the shop log.', error: _error,
style: theme.textTheme.bodyLarge, month: _month,
), selectedDay: _selectedDay,
); tagFilter: _tagFilter,
} query: _search.text,
if (!snapshot.hasData) { editing: _editing,
return const Center(child: CircularProgressIndicator()); onPreviousMonth: () => _goMonth(-1),
} onNextMonth: () => _goMonth(1),
onSelectDay: _selectDay,
final shops = snapshot.data!; onClearTag: () => setState(() => _tagFilter = null),
if (shops.isEmpty) { onTagTap: (tag) {
return Center( setState(() {
child: Text( _tagFilter = _tagFilter?.toLowerCase() == tag.toLowerCase()
'Scan a receipt or enter a trip to log it.', ? null
textAlign: TextAlign.center, : tag;
style: theme.textTheme.bodyLarge, });
),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
itemCount: shops.length,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final shop = shops[index];
final count = shop.itemCount;
return ListTile(
leading: _ReceiptThumb(path: shop.receiptImagePath),
title: Align(
alignment: Alignment.centerLeft,
child: StoreTag(name: shop.supermarket, stores: stores),
),
subtitle: Text(
[
formatDate(shop.shoppedAt),
count == 1 ? '1 product' : '$count products',
].join(' · '),
),
trailing: _editing
? IconButton(
tooltip: 'Remove',
onPressed: () => _removeShop(shop),
icon: const Icon(Icons.delete_outline),
)
: Text(
formatEuro(shop.total),
style: theme.textTheme.titleMedium,
),
onTap: _editing ? null : () => _openShop(shop),
);
},
);
}, },
onOpen: _openShop,
onRemove: _removeShop,
), ),
); );
} }
} }
class _ReceiptThumb extends StatelessWidget { class _ShopLogBody extends StatelessWidget {
const _ReceiptThumb({this.path}); 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 String? path; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final file = path == null ? null : File(path!); if (error != null && shops == null) {
final hasFile = file != null && file.existsSync(); return Center(
final scheme = Theme.of(context).colorScheme; child: Text(
'Could not load the shop log.',
style: theme.textTheme.bodyLarge,
),
);
}
if (shops == null) {
return const Center(child: CircularProgressIndicator());
}
return ClipRRect( final allShops = shops!;
borderRadius: BorderRadius.circular(12), final counts = tripCountsByDay([
child: SizedBox( for (final shop in allShops)
width: 48, if (isSameMonth(shop.shoppedAt, month)) shop.shoppedAt,
height: 48, ]);
child: hasFile final monthShops = [
? Image.file( for (final shop in allShops)
file, if (isSameMonth(shop.shoppedAt, month)) shop,
fit: BoxFit.cover, ];
cacheWidth: 144, final monthTotal = monthShops.fold<double>(
errorBuilder: (_, _, _) => ColoredBox( 0,
color: scheme.secondaryContainer, (sum, shop) => sum + shop.total,
child: Icon( );
Icons.receipt_long_outlined, final visible = filterShops(
color: scheme.onSecondaryContainer, shops: allShops,
productNames: productNames,
selectedDay: selectedDay,
tag: tagFilter,
query: query,
);
return ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
ShopHeatmap(
month: month,
counts: counts,
monthTotal: monthTotal,
monthTripCount: monthShops.length,
canGoForward: month.isBefore(nowMonth),
selectedDay: selectedDay,
onPreviousMonth: onPreviousMonth,
onNextMonth: onNextMonth,
onSelectDay: onSelectDay,
),
if (selectedDay != null || tagFilter != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (selectedDay != null)
InputChip(
label: Text(formatDate(selectedDay!)),
onDeleted: () => onSelectDay(null),
), ),
if (tagFilter != null)
InputChip(label: Text(tagFilter!), onDeleted: onClearTag),
],
),
),
if (allShops.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Scan a receipt or enter a trip to log it.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
)
else if (visible.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
query.trim().isNotEmpty
? 'No trips contain that product.'
: 'No trips match this filter.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
)
else
for (final shop in visible)
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4),
child: _ShopLogTile(
shop: shop,
stores: stores,
editing: editing,
selectedTag: tagFilter,
onOpen: () => onOpen(shop),
onRemove: () => onRemove(shop),
onTagTap: onTagTap,
),
),
],
);
}
}
class _ShopLogTile extends StatelessWidget {
const _ShopLogTile({
required this.shop,
required this.stores,
required this.editing,
required this.onOpen,
required this.onRemove,
required this.onTagTap,
this.selectedTag,
});
final Shop shop;
final List<Supermarket> stores;
final bool editing;
final VoidCallback onOpen;
final VoidCallback onRemove;
final ValueChanged<String> onTagTap;
final String? selectedTag;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final count = shop.itemCount;
final hasName = shop.name != null && shop.name!.isNotEmpty;
final meta = [
formatDate(shop.shoppedAt),
count == 1 ? '1 product' : '$count products',
].join(' · ');
return Material(
color: Colors.transparent,
child: InkWell(
onTap: editing ? null : onOpen,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (editing)
IconButton(
tooltip: 'Remove',
onPressed: onRemove,
icon: const Icon(Icons.delete_outline),
), ),
) Expanded(
: ColoredBox( child: Column(
color: scheme.secondaryContainer, crossAxisAlignment: CrossAxisAlignment.start,
child: Icon( children: [
Icons.receipt_long_outlined, if (hasName)
color: scheme.onSecondaryContainer, 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),
Padding(
padding: const EdgeInsets.only(top: 8, right: 8),
child: Text(
formatEuro(shop.total),
style: theme.textTheme.titleMedium,
),
),
],
),
),
), ),
); );
} }
+283 -218
View File
@@ -3,10 +3,11 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'models/product.dart';
import 'models/shopping_list.dart'; import 'models/shopping_list.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 'widgets/add_list_items_sheet.dart';
import 'widgets/confirm_dialog.dart'; import 'widgets/confirm_dialog.dart';
import 'widgets/name_dialog.dart'; import 'widgets/name_dialog.dart';
@@ -26,26 +27,41 @@ class ShoppingListDetailScreen extends StatefulWidget {
} }
class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> { class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
late Future<({ShoppingList list, List<ShoppingListItem> items})> _future; ShoppingList? _list;
List<ShoppingListItem>? _items;
Object? _error;
bool _editing = false; bool _editing = false;
int _loadToken = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_future = _load(); widget.repository.addListener(_load);
_load();
} }
Future<({ShoppingList list, List<ShoppingListItem> items})> _load() async { @override
final list = await widget.repository.getShoppingList(widget.listId); void dispose() {
if (list == null) { widget.repository.removeListener(_load);
throw StateError('List not found'); 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);
} }
final items = await widget.repository.getShoppingListItems(widget.listId);
return (list: list, items: items);
}
void _reload() {
setState(() => _future = _load());
} }
Future<void> _rename(ShoppingList list) async { Future<void> _rename(ShoppingList list) async {
@@ -57,25 +73,70 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
); );
if (name == null || !mounted) return; if (name == null || !mounted) return;
await widget.repository.renameShoppingList(widget.listId, name); await widget.repository.renameShoppingList(widget.listId, name);
if (mounted) _reload(); 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 { Future<void> _openAddSheet() async {
final products = await widget.repository.getAll(); final products = await widget.repository.getAll();
if (!mounted) return; if (!mounted) return;
final result = await showModalBottomSheet<({String name, int? productId})>( final selections = await showAddListItemsSheet(context, products: products);
context: context, if (selections == null || selections.isEmpty || !mounted) return;
isScrollControlled: true,
showDragHandle: true, final pending = [
builder: (context) => _AddListItemSheet(products: products), for (var i = 0; i < selections.length; i++)
); ShoppingListItem(
if (result == null || !mounted) return; id: -(i + 1),
await widget.repository.addShoppingListItem( listId: widget.listId,
listId: widget.listId, name: selections[i].name,
name: result.name, checked: false,
productId: result.productId, sortOrder: (_items?.length ?? 0) + i,
); productId: selections[i].productId,
if (mounted) _reload(); ),
];
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 { Future<void> _editItem(ShoppingListItem item) async {
@@ -87,7 +148,7 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
); );
if (name == null || !mounted) return; if (name == null || !mounted) return;
await widget.repository.updateShoppingListItem(id: item.id, name: name); await widget.repository.updateShoppingListItem(id: item.id, name: name);
if (mounted) _reload(); if (mounted) _load();
} }
Future<void> _removeItem(ShoppingListItem item) async { Future<void> _removeItem(ShoppingListItem item) async {
@@ -98,16 +159,100 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
confirmLabel: 'Remove', confirmLabel: 'Remove',
); );
if (!confirmed || !mounted) return; if (!confirmed || !mounted) return;
await widget.repository.deleteShoppingListItem(item.id); setState(() {
if (mounted) _reload(); _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 { Future<void> _toggle(ShoppingListItem item) async {
await widget.repository.updateShoppingListItem( final next = !item.checked;
id: item.id, setState(() {
checked: !item.checked, _items = [
); for (final entry in _items ?? const <ShoppingListItem>[])
if (mounted) _reload(); 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 { Future<void> _openProduct(int productId) async {
@@ -125,192 +270,112 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( if (_error != null && _list == null) {
body: FutureBuilder( return Scaffold(
future: _future, appBar: AppBar(),
builder: (context, snapshot) { body: Center(
if (snapshot.hasError) { child: Text(
return Scaffold( 'Could not load this list.',
appBar: AppBar(), style: theme.textTheme.bodyLarge,
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()),
if (!snapshot.hasData) { );
return Scaffold( }
appBar: AppBar(),
body: const Center(child: CircularProgressIndicator()), final list = _list!;
); final items = _items!;
}
return Scaffold(
final list = snapshot.data!.list; appBar: AppBar(
final items = snapshot.data!.items; title: Text('${list.name} (${formatDate(list.createdAt)})'),
actions: [
return Scaffold( if (list.checkedCount > 0 && !_editing)
appBar: AppBar( TextButton(
title: Text(list.name), onPressed: _shopAgain,
actions: [ child: const Text('Shop again'),
IconButton( ),
tooltip: 'Rename', IconButton(
onPressed: () => _rename(list), tooltip: 'Rename',
icon: const Icon(Icons.drive_file_rename_outline), onPressed: () => _rename(list),
), icon: const Icon(Icons.drive_file_rename_outline),
IconButton( ),
tooltip: _editing ? 'Done' : 'Edit', IconButton(
onPressed: () => setState(() => _editing = !_editing), tooltip: _editing ? 'Done' : 'Edit',
icon: Icon(_editing ? Icons.check : Icons.edit_outlined), onPressed: () => setState(() => _editing = !_editing),
), icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _openAddSheet,
tooltip: 'Add item',
child: const Icon(Icons.add),
),
body: items.isEmpty
? Center(
child: Text(
'Add things to buy.',
style: theme.textTheme.bodyLarge,
),
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
leading: Checkbox(
value: item.checked,
onChanged: _editing ? null : (_) => _toggle(item),
),
title: Text(
item.name,
style: item.checked
? theme.textTheme.bodyLarge?.copyWith(
decoration: TextDecoration.lineThrough,
color: theme.colorScheme.onSurfaceVariant,
)
: null,
),
trailing: _editing
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
onPressed: () => _editItem(item),
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Remove',
onPressed: () => _removeItem(item),
icon: const Icon(Icons.delete_outline),
),
],
)
: item.productId == null
? null
: IconButton(
tooltip: 'Product',
onPressed: () => _openProduct(item.productId!),
icon: const Icon(Icons.chevron_right),
),
onTap: _editing ? null : () => _toggle(item),
);
},
),
);
},
),
);
}
}
class _AddListItemSheet extends StatefulWidget {
const _AddListItemSheet({required this.products});
final List<Product> products;
@override
State<_AddListItemSheet> createState() => _AddListItemSheetState();
}
class _AddListItemSheetState extends State<_AddListItemSheet> {
final TextEditingController _controller = TextEditingController();
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
List<Product> get _matches {
final query = _controller.text.trim().toLowerCase();
if (query.isEmpty) {
return widget.products.take(8).toList();
}
return widget.products
.where((product) => product.name.toLowerCase().contains(query))
.take(8)
.toList();
}
void _add({String? name, int? productId}) {
final value = (name ?? _controller.text).trim();
if (value.isEmpty) {
setState(() => _error = 'Enter an item name.');
return;
}
Navigator.of(context).pop((name: value, productId: productId));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
final matches = _matches;
return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Add item', style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
TextField(
controller: _controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(labelText: 'Item name'),
onChanged: (_) => setState(() => _error = null),
onSubmitted: (_) => _add(),
), ),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
],
if (matches.isNotEmpty) ...[
const SizedBox(height: 12),
Text('From products', style: theme.textTheme.labelMedium),
const SizedBox(height: 4),
for (final product in matches)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(product.name),
onTap: () => _add(name: product.name, productId: product.id),
),
],
const SizedBox(height: 8),
FilledButton(onPressed: () => _add(), child: const Text('Add')),
], ],
), ),
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),
);
},
),
); );
} }
} }
+87 -61
View File
@@ -6,8 +6,10 @@ import 'package:flutter/material.dart';
import 'models/shopping_list.dart'; import 'models/shopping_list.dart';
import 'services/product_repository.dart'; import 'services/product_repository.dart';
import 'shopping_list_detail_screen.dart'; import 'shopping_list_detail_screen.dart';
import 'utils/dates.dart';
import 'widgets/confirm_dialog.dart'; import 'widgets/confirm_dialog.dart';
import 'widgets/name_dialog.dart'; import 'widgets/name_dialog.dart';
import 'widgets/profile_button.dart';
import 'widgets/settings_button.dart'; import 'widgets/settings_button.dart';
class ShoppingListsScreen extends StatefulWidget { class ShoppingListsScreen extends StatefulWidget {
@@ -20,17 +22,40 @@ class ShoppingListsScreen extends StatefulWidget {
} }
class _ShoppingListsScreenState extends State<ShoppingListsScreen> { class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
late Future<List<ShoppingList>> _future; List<ShoppingList>? _lists;
Object? _error;
bool _editing = false; bool _editing = false;
int _loadToken = 0;
bool get _hasLists => _lists != null && _lists!.isNotEmpty;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_future = widget.repository.getShoppingLists(); widget.repository.addListener(_load);
_load();
} }
void _reload() { @override
setState(() => _future = widget.repository.getShoppingLists()); 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 { Future<void> _create() async {
@@ -53,7 +78,7 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
ShoppingListDetailScreen(repository: widget.repository, listId: id), ShoppingListDetailScreen(repository: widget.repository, listId: id),
), ),
); );
if (mounted) _reload(); if (mounted) _load();
} }
Future<void> _removeList(ShoppingList list) async { Future<void> _removeList(ShoppingList list) async {
@@ -63,8 +88,18 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
message: '"${list.name}" and its items will be deleted.', message: '"${list.name}" and its items will be deleted.',
); );
if (!confirmed || !mounted) return; if (!confirmed || !mounted) return;
await widget.repository.deleteShoppingList(list.id); setState(() {
if (mounted) _reload(); _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 @override
@@ -75,11 +110,13 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('Shopping lists'), title: const Text('Shopping lists'),
actions: [ actions: [
IconButton( if (_hasLists)
tooltip: _editing ? 'Done' : 'Edit', IconButton(
onPressed: () => setState(() => _editing = !_editing), tooltip: _editing ? 'Done' : 'Edit',
icon: Icon(_editing ? Icons.check : Icons.edit_outlined), onPressed: () => setState(() => _editing = !_editing),
), icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
const ProfileButton(),
const SettingsButton(), const SettingsButton(),
], ],
), ),
@@ -88,64 +125,53 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
tooltip: 'New list', tooltip: 'New list',
child: const Icon(Icons.add), child: const Icon(Icons.add),
), ),
body: FutureBuilder<List<ShoppingList>>( body: _error != null && _lists == null
future: _future, ? Center(
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text( child: Text(
'Could not load shopping lists.', 'Could not load shopping lists.',
style: theme.textTheme.bodyLarge, style: theme.textTheme.bodyLarge,
), ),
); )
} : _lists == null
if (!snapshot.hasData) { ? const Center(child: CircularProgressIndicator())
return const Center(child: CircularProgressIndicator()); : _lists!.isEmpty
} ? Center(
final lists = snapshot.data!;
if (lists.isEmpty) {
return Center(
child: Text( child: Text(
'Create a list for your next shop.', 'Create a list for your next shop.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge, style: theme.textTheme.bodyLarge,
), ),
); )
} : ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
return ListView.separated( itemCount: _lists!.length,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88), separatorBuilder: (_, _) => const SizedBox(height: 4),
itemCount: lists.length, itemBuilder: (context, index) {
separatorBuilder: (_, _) => const SizedBox(height: 4), final list = _lists![index];
itemBuilder: (context, index) { final remaining = list.itemCount - list.checkedCount;
final list = lists[index]; return ListTile(
final remaining = list.itemCount - list.checkedCount; leading: CircleAvatar(child: Text('${list.itemCount}')),
return ListTile( title: Text('${list.name} (${formatDate(list.createdAt)})'),
leading: CircleAvatar(child: Text('${list.itemCount}')), subtitle: Text(
title: Text(list.name), list.itemCount == 0
subtitle: Text( ? 'Empty'
list.itemCount == 0 : remaining == 0
? 'Empty' ? 'All done'
: remaining == 0 : remaining == 1
? 'All done' ? '1 item left'
: remaining == 1 : '$remaining items left',
? '1 item left' ),
: '$remaining items left', trailing: _editing
), ? IconButton(
trailing: _editing tooltip: 'Delete',
? IconButton( onPressed: () => _removeList(list),
tooltip: 'Delete', icon: const Icon(Icons.delete_outline),
onPressed: () => _removeList(list), )
icon: const Icon(Icons.delete_outline), : const Icon(Icons.chevron_right),
) onTap: _editing ? null : () => _openList(list.id),
: const Icon(Icons.chevron_right), );
onTap: _editing ? null : () => _openList(list.id), },
); ),
},
);
},
),
); );
} }
} }
+284
View File
@@ -0,0 +1,284 @@
/// Manage supermarket names, colors, and bonus card photos.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'models/supermarket.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'widgets/store_edit_dialog.dart';
import 'widgets/store_tag.dart';
class SupermarketsScreen extends StatefulWidget {
const SupermarketsScreen({super.key, required this.repository});
final ProductRepository repository;
@override
State<SupermarketsScreen> createState() => _SupermarketsScreenState();
}
class _SupermarketsScreenState extends State<SupermarketsScreen> {
List<Supermarket>? _stores;
bool _busy = false;
final ImagePicker _picker = ImagePicker();
@override
void initState() {
super.initState();
widget.repository.addListener(_load);
_load();
}
@override
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
try {
final stores = await widget.repository.getSupermarkets();
if (!mounted) return;
setState(() => _stores = stores);
} catch (_) {
if (!mounted) return;
setState(() => _stores ??= const []);
}
}
Future<void> _syncSettings() async {
await _settings?.reload();
}
AppSettings? get _settings => SettingsScope.maybeOf(context);
Future<void> _editStore(Supermarket? existing) async {
final result = await showStoreEditDialog(context, store: existing);
if (result == null) return;
setState(() => _busy = true);
try {
final settings = _settings;
if (settings == null) return;
if (existing == null) {
await settings.addSupermarket(result.name, result.colorValue);
} else {
await settings.updateSupermarket(result);
}
await _load();
await _syncSettings();
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('That supermarket already exists.')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _deleteStore(Supermarket store) async {
if (store.id == null) return;
setState(() => _busy = true);
try {
await _settings?.deleteSupermarket(store.id!);
await _load();
await _syncSettings();
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _pickBonusCard(Supermarket store) async {
if (store.id == null) return;
final source = await showModalBottomSheet<ImageSource>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Take photo'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Pick from gallery'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
);
},
);
if (source == null) return;
final file = await _picker.pickImage(source: source, imageQuality: 85);
if (file == null) return;
setState(() => _busy = true);
try {
await widget.repository.setSupermarketBonusCard(store.id!, file.path);
await _load();
await _syncSettings();
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _viewBonusCard(String path) async {
await showDialog<void>(
context: context,
builder: (context) {
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: InteractiveViewer(
child: Image.file(File(path), fit: BoxFit.contain),
),
);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stores = _stores;
return Scaffold(
appBar: AppBar(title: const Text('Supermarkets')),
floatingActionButton: FloatingActionButton(
onPressed: _busy ? null : () => _editStore(null),
tooltip: 'Add supermarket',
child: const Icon(Icons.add),
),
body: stores == null
? const Center(child: CircularProgressIndicator())
: ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
itemCount: stores.length + 1,
separatorBuilder: (_, index) =>
index == 0 ? const SizedBox(height: 4) : const Divider(height: 1),
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Store names and colors appear on products. '
'Add a bonus card photo when you have one.',
style: theme.textTheme.bodyMedium,
),
if (_busy) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(),
],
],
),
);
}
final store = stores[index - 1];
return _SupermarketRow(
store: store,
stores: stores,
busy: _busy,
onEdit: () => _editStore(store),
onDelete: store.id == null ? null : () => _deleteStore(store),
onBonusCard: () => _pickBonusCard(store),
onViewBonusCard: store.bonusImagePath == null
? null
: () => _viewBonusCard(store.bonusImagePath!),
);
},
),
);
}
}
class _SupermarketRow extends StatelessWidget {
const _SupermarketRow({
required this.store,
required this.stores,
required this.busy,
required this.onEdit,
required this.onBonusCard,
this.onDelete,
this.onViewBonusCard,
});
final Supermarket store;
final List<Supermarket> stores;
final bool busy;
final VoidCallback onEdit;
final VoidCallback? onDelete;
final VoidCallback onBonusCard;
final VoidCallback? onViewBonusCard;
@override
Widget build(BuildContext context) {
final path = store.bonusImagePath;
final hasCard = path != null && path.isNotEmpty && File(path).existsSync();
return ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
leading: hasCard
? InkWell(
onTap: busy ? null : onViewBonusCard,
borderRadius: BorderRadius.circular(8),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(path),
width: 44,
height: 28,
fit: BoxFit.cover,
),
),
)
: CircleAvatar(
radius: 16,
backgroundColor: store.color,
child: Text(
store.name.isEmpty ? '?' : store.name.substring(0, 1),
style: TextStyle(
color: store.color.computeLuminance() > 0.55
? Colors.black87
: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
),
title: StoreTag(name: store.name, stores: stores, compact: true),
subtitle: hasCard ? null : const Text('No bonus card'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: hasCard ? 'Replace bonus card' : 'Add bonus card',
onPressed: busy ? null : onBonusCard,
icon: Icon(hasCard ? Icons.photo_camera : Icons.card_membership_outlined),
),
IconButton(
tooltip: 'Edit',
onPressed: busy ? null : onEdit,
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Delete',
onPressed: busy ? null : onDelete,
icon: const Icon(Icons.delete_outline),
),
],
),
);
}
}
+5 -15
View File
@@ -39,9 +39,7 @@ abstract final class AppTheme {
cardTheme: CardThemeData( 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 -1
View File
@@ -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';
+18
View File
@@ -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}';
+269
View File
@@ -0,0 +1,269 @@
/// Case-insensitive product name matching and duplicate grouping.
library;
import '../models/product.dart';
String normalizeProductName(String name) =>
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
String normalizeSupermarket(String? supermarket) =>
supermarket?.trim().toUpperCase() ?? '';
String productConflictKey({
required String name,
required String? supermarket,
}) => '${normalizeSupermarket(supermarket)}|${normalizeProductName(name)}';
String mergeGroupKey(List<Product> products, String? supermarket) {
final ids = products.map((product) => product.id).toList()..sort();
return 'merge:${normalizeSupermarket(supermarket)}:'
'${normalizeProductName(products.first.name)}:${ids.join(',')}';
}
String spellingVariantKey(int productId, String? supermarket) =>
'spelling:$productId:${normalizeSupermarket(supermarket)}';
List<List<Product>> nameConflictGroups(Iterable<Product> products) {
final groups = <String, List<Product>>{};
for (final product in products) {
final key = productConflictKey(
name: product.name,
supermarket: product.supermarket,
);
if (normalizeProductName(product.name).isEmpty) continue;
(groups[key] ??= []).add(product);
}
return [
for (final group in groups.values)
if (group.length > 1) group,
];
}
Set<int> nameConflictIds(Iterable<Product> products) {
return {
for (final group in nameConflictGroups(products))
for (final product in group) product.id,
};
}
/// Separate catalog products with the same name at the same store.
class ProductMergeGroup {
const ProductMergeGroup({
required this.products,
required this.supermarket,
required this.key,
});
final List<Product> products;
final String? supermarket;
final String key;
}
/// One catalog product bought under multiple spellings at one store.
class ProductSpellingVariant {
const ProductSpellingVariant({
required this.product,
required this.supermarket,
required this.variants,
required this.key,
});
final Product product;
final String? supermarket;
final List<String> variants;
final String key;
}
/// Builds merge groups from catalog names and trip lines at the same store.
List<ProductMergeGroup> buildNameConflictGroups({
required Iterable<Product> products,
required Iterable<({int productId, String name, String supermarket})>
shopLines,
}) {
final productById = {for (final product in products) product.id: product};
final idsByKey = <String, Set<int>>{};
final supermarketByKey = <String, String>{};
void add(int productId, String name, String? supermarket) {
final key = productConflictKey(name: name, supermarket: supermarket);
if (normalizeProductName(name).isEmpty) return;
final trimmed = supermarket?.trim();
if (trimmed != null && trimmed.isNotEmpty) {
supermarketByKey.putIfAbsent(key, () => trimmed);
}
(idsByKey[key] ??= {}).add(productId);
}
for (final product in products) {
add(product.id, product.name, product.supermarket);
}
for (final line in shopLines) {
add(line.productId, line.name, line.supermarket);
}
final groups = <ProductMergeGroup>[];
for (final entry in idsByKey.entries) {
if (entry.value.length < 2) continue;
final productsInGroup = [
for (final id in entry.value)
if (productById.containsKey(id)) productById[id]!,
];
if (productsInGroup.length < 2) continue;
productsInGroup.sort(
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
final supermarketLabel = supermarketByKey[entry.key];
groups.add(
ProductMergeGroup(
products: productsInGroup,
supermarket: supermarketLabel,
key: mergeGroupKey(productsInGroup, supermarketLabel),
),
);
}
groups.sort(
(a, b) =>
productConflictKey(
name: a.products.first.name,
supermarket: a.supermarket,
).compareTo(
productConflictKey(
name: b.products.first.name,
supermarket: b.supermarket,
),
),
);
return groups;
}
/// Finds receipt spellings that differ only by capitals at one store.
List<ProductSpellingVariant> findSpellingVariants({
required Iterable<Product> products,
required Iterable<({int productId, String name, String supermarket})>
shopLines,
Iterable<int> mergeProductIds = const [],
Iterable<String> mergeGroupKeys = const [],
}) {
final productById = {for (final product in products) product.id: product};
final excludedProducts = mergeProductIds.toSet();
final excludedGroups = mergeGroupKeys.toSet();
final namesByProductStore = <String, Set<String>>{};
final supermarketByKey = <String, String>{};
void addName(int productId, String name, String supermarket) {
final storeKey = normalizeSupermarket(supermarket);
final bucketKey = '$productId|$storeKey';
supermarketByKey[bucketKey] = supermarket;
(namesByProductStore[bucketKey] ??= {}).add(name);
}
for (final line in shopLines) {
addName(line.productId, line.name, line.supermarket);
}
for (final product in products) {
final supermarket = product.supermarket;
if (supermarket == null || supermarket.trim().isEmpty) continue;
addName(product.id, product.name, supermarket);
}
final issues = <ProductSpellingVariant>[];
for (final entry in namesByProductStore.entries) {
final parts = entry.key.split('|');
final productId = int.parse(parts.first);
if (excludedProducts.contains(productId)) continue;
final product = productById[productId];
if (product == null) continue;
final supermarket = supermarketByKey[entry.key];
final byNormalized = <String, Set<String>>{};
for (final name in entry.value) {
final key = normalizeProductName(name);
if (key.isEmpty) continue;
(byNormalized[key] ??= {}).add(name);
}
for (final names in byNormalized.values) {
if (names.length < 2) continue;
final variants = names.toList()
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
final issue = ProductSpellingVariant(
product: product,
supermarket: supermarket,
variants: variants,
key: spellingVariantKey(productId, supermarket),
);
if (excludedGroups.contains(issue.key)) continue;
issues.add(issue);
}
}
issues.sort(
(a, b) =>
productConflictKey(
name: a.product.name,
supermarket: a.supermarket,
).compareTo(
productConflictKey(name: b.product.name, supermarket: b.supermarket),
),
);
return issues;
}
/// Catalog merges and receipt spelling variants for the Products screen.
class ProductNameIssues {
const ProductNameIssues({
required this.mergeGroups,
required this.spellingVariants,
});
final List<ProductMergeGroup> mergeGroups;
final List<ProductSpellingVariant> spellingVariants;
bool get isEmpty => mergeGroups.isEmpty && spellingVariants.isEmpty;
bool get isNotEmpty => !isEmpty;
int get issueCount => mergeGroups.length + spellingVariants.length;
Set<int> get productIds => {
for (final group in mergeGroups)
for (final product in group.products) product.id,
for (final variant in spellingVariants) variant.product.id,
};
}
ProductNameIssues buildProductNameIssues({
required Iterable<Product> products,
required Iterable<({int productId, String name, String supermarket})>
shopLines,
Set<String> dismissedKeys = const {},
}) {
final mergeGroups = [
for (final group in buildNameConflictGroups(
products: products,
shopLines: shopLines,
))
if (!dismissedKeys.contains(group.key)) group,
];
final mergeIds = {
for (final group in mergeGroups)
for (final product in group.products) product.id,
};
final mergeKeys = {for (final group in mergeGroups) group.key};
final spellingVariants = [
for (final variant in findSpellingVariants(
products: products,
shopLines: shopLines,
mergeProductIds: mergeIds,
mergeGroupKeys: mergeKeys,
))
if (!dismissedKeys.contains(variant.key)) variant,
];
return ProductNameIssues(
mergeGroups: mergeGroups,
spellingVariants: spellingVariants,
);
}
+48
View File
@@ -0,0 +1,48 @@
/// Month-grid helpers for the shop-log heatmap.
library;
DateTime dateOnly(DateTime date) => DateTime(date.year, date.month, date.day);
DateTime monthStart(DateTime date) => DateTime(date.year, date.month);
bool isSameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
bool isSameMonth(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month;
DateTime addMonths(DateTime month, int delta) =>
DateTime(month.year, month.month + delta);
/// Monday-first calendar cells for [month], padded with nulls to full weeks.
List<DateTime?> monthGrid(DateTime month) {
final start = DateTime(month.year, month.month, 1);
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
final leading = start.weekday - DateTime.monday;
final cells = <DateTime?>[
...List<DateTime?>.filled(leading, null),
for (var day = 1; day <= daysInMonth; day++)
DateTime(month.year, month.month, day),
];
while (cells.length % 7 != 0) {
cells.add(null);
}
return cells;
}
/// How many trips landed on each calendar day.
Map<DateTime, int> tripCountsByDay(Iterable<DateTime> shoppedAt) {
final counts = <DateTime, int>{};
for (final date in shoppedAt) {
final key = dateOnly(date);
counts[key] = (counts[key] ?? 0) + 1;
}
return counts;
}
/// GitHub-style 0–4 intensity from a trip count.
int heatmapLevel(int count) {
if (count <= 0) return 0;
if (count >= 4) return 4;
return count;
}
+48
View File
@@ -0,0 +1,48 @@
/// Filters shop-log rows by day, tag, and product search.
library;
import '../models/shop.dart';
import 'fuzzy.dart';
import 'shop_calendar.dart';
List<Shop> filterShops({
required List<Shop> shops,
required Map<int, List<String>> productNames,
DateTime? selectedDay,
String? tag,
String query = '',
}) {
final needle = query.trim();
final tagFilter = tag?.trim();
return [
for (final shop in shops)
if (_matches(
shop: shop,
names: productNames[shop.id] ?? const [],
selectedDay: selectedDay,
tag: tagFilter,
query: needle,
))
shop,
];
}
bool _matches({
required Shop shop,
required List<String> names,
required DateTime? selectedDay,
required String? tag,
required String query,
}) {
if (selectedDay != null && !isSameDay(shop.shoppedAt, selectedDay)) {
return false;
}
if (tag != null && tag.isNotEmpty) {
final wanted = tag.toLowerCase();
if (!shop.tags.any((item) => item.toLowerCase() == wanted)) {
return false;
}
}
if (query.isEmpty) return true;
return names.any((name) => fuzzyScore(query, name) != null);
}
+175
View File
@@ -0,0 +1,175 @@
/// Multi-select sheet for adding catalog products to a shopping list.
library;
import 'package:flutter/material.dart';
import '../models/product.dart';
import 'product_image.dart';
class AddListItemSelection {
const AddListItemSelection({required this.name, this.productId});
final String name;
final int? productId;
}
Future<List<AddListItemSelection>?> showAddListItemsSheet(
BuildContext context, {
required List<Product> products,
}) {
return showModalBottomSheet<List<AddListItemSelection>>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => _AddListItemsSheet(products: products),
);
}
class _AddListItemsSheet extends StatefulWidget {
const _AddListItemsSheet({required this.products});
final List<Product> products;
@override
State<_AddListItemsSheet> createState() => _AddListItemsSheetState();
}
class _AddListItemsSheetState extends State<_AddListItemsSheet> {
final TextEditingController _controller = TextEditingController();
final Set<int> _selectedProductIds = {};
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
List<Product> get _matches {
final query = _controller.text.trim().toLowerCase();
if (query.isEmpty) return widget.products;
return widget.products
.where((product) => product.name.toLowerCase().contains(query))
.toList();
}
int get _addCount {
var count = _selectedProductIds.length;
final typed = _controller.text.trim();
if (typed.isEmpty) return count;
final selectedNames = {
for (final product in widget.products)
if (_selectedProductIds.contains(product.id))
product.name.toLowerCase(),
};
if (!selectedNames.contains(typed.toLowerCase())) count++;
return count;
}
void _toggleProduct(Product product) {
setState(() {
if (_selectedProductIds.contains(product.id)) {
_selectedProductIds.remove(product.id);
} else {
_selectedProductIds.add(product.id);
}
_error = null;
});
}
void _add() {
final selections = <AddListItemSelection>[
for (final product in widget.products)
if (_selectedProductIds.contains(product.id))
AddListItemSelection(name: product.name, productId: product.id),
];
final typed = _controller.text.trim();
if (typed.isNotEmpty &&
!selections.any(
(item) => item.name.toLowerCase() == typed.toLowerCase(),
)) {
selections.add(AddListItemSelection(name: typed));
}
if (selections.isEmpty) {
setState(() => _error = 'Select products or enter an item name.');
return;
}
Navigator.of(context).pop(selections);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
final matches = _matches;
final addCount = _addCount;
return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.72,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Add items', style: theme.textTheme.titleLarge),
const SizedBox(height: 12),
TextField(
controller: _controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Search or type a new item',
),
onChanged: (_) => setState(() => _error = null),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
],
const SizedBox(height: 8),
Text('From products', style: theme.textTheme.labelMedium),
const SizedBox(height: 4),
Expanded(
child: matches.isEmpty
? Center(
child: Text(
widget.products.isEmpty
? 'No products in your catalog yet.'
: 'No products match this search.',
style: theme.textTheme.bodyMedium,
),
)
: ListView.builder(
itemCount: matches.length,
itemBuilder: (context, index) {
final product = matches[index];
final selected = _selectedProductIds.contains(
product.id,
);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Checkbox(
value: selected,
onChanged: (_) => _toggleProduct(product),
),
title: Text(product.name),
trailing: ProductImage(
name: product.name,
path: product.imagePath,
size: 32,
),
onTap: () => _toggleProduct(product),
);
},
),
),
FilledButton(
onPressed: addCount == 0 ? null : _add,
child: Text(addCount == 1 ? 'Add 1 item' : 'Add $addCount items'),
),
],
),
),
);
}
}
+37
View File
@@ -0,0 +1,37 @@
/// Paid amount, with the pre-discount price struck through when there is one.
library;
import 'package:flutter/material.dart';
import '../models/line_item.dart';
import '../utils/money.dart';
class DiscountedPrice extends StatelessWidget {
const DiscountedPrice({super.key, required this.item});
final LineItem item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final paid = Text(
formatEuro(item.price),
style: theme.textTheme.titleMedium,
);
if (!item.hasDiscount) return paid;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
formatEuro(item.originalPrice),
style: theme.textTheme.bodyMedium?.copyWith(
decoration: TextDecoration.lineThrough,
),
),
const SizedBox(width: 8),
paid,
],
);
}
}
+154 -16
View File
@@ -2,6 +2,7 @@
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/line_item.dart'; import '../models/line_item.dart';
import '../utils/money.dart'; import '../utils/money.dart';
@@ -26,46 +27,151 @@ class ItemEditSheet extends StatefulWidget {
class _ItemEditSheetState extends State<ItemEditSheet> { class _ItemEditSheetState extends State<ItemEditSheet> {
late final TextEditingController _nameController; late final TextEditingController _nameController;
late final TextEditingController _priceController; late final TextEditingController _quantityController;
late final TextEditingController _unitPriceController;
late final TextEditingController _totalController;
String? _error; String? _error;
bool _syncing = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final item = widget.item; 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 ?? ''); _nameController = TextEditingController(text: item?.name ?? '');
_priceController = TextEditingController( _quantityController = TextEditingController(text: '$quantity');
text: item == null ? '' : item.price.toStringAsFixed(2), _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 @override
void dispose() { void dispose() {
_quantityController.removeListener(_onQuantityChanged);
_unitPriceController.removeListener(_onUnitPriceChanged);
_totalController.removeListener(_onTotalChanged);
_nameController.dispose(); _nameController.dispose();
_priceController.dispose(); _quantityController.dispose();
_unitPriceController.dispose();
_totalController.dispose();
super.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() { void _save() {
final name = _nameController.text.trim(); final name = _nameController.text.trim();
final price = tryParsePrice(_priceController.text); final quantity = _quantity;
if (name.isEmpty || price == null) { final unit = tryParsePrice(_unitPriceController.text);
setState(() => _error = 'Enter a product name and a price.'); final total = tryParsePrice(_totalController.text);
if (name.isEmpty) {
setState(() => _error = 'Enter a product name.');
return; return;
} }
final rounded = roundMoney(price); if (quantity == null || quantity <= 0) {
final quantity = widget.item?.quantity; 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( Navigator.of(context).pop(
LineItem( LineItem(
id: id:
widget.item?.id ?? widget.item?.id ??
'manual-${DateTime.now().microsecondsSinceEpoch}', 'manual-${DateTime.now().microsecondsSinceEpoch}',
name: name, name: name,
price: rounded, price: roundMoney(resolvedTotal),
quantity: quantity, quantity: hasMulti ? quantity : null,
unitPrice: quantity != null && quantity > 0 unitPrice: hasMulti ? roundMoney(resolvedUnit) : null,
? roundMoney(rounded / quantity) discount: widget.item?.price == roundMoney(resolvedTotal)
: widget.item?.unitPrice, ? widget.item?.discount ?? 0
: 0,
sourceBounds: widget.item?.sourceBounds,
needsManualReview: false,
), ),
); );
} }
@@ -73,6 +179,11 @@ class _ItemEditSheetState extends State<ItemEditSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bottom = MediaQuery.viewInsetsOf(context).bottom; 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( return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
@@ -92,10 +203,37 @@ class _ItemEditSheetState extends State<ItemEditSheet> {
autofocus: widget.item == null, autofocus: widget.item == null,
), ),
const SizedBox(height: 12), 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( TextField(
controller: _priceController, controller: _totalController,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Price'), decoration: InputDecoration(
labelText: 'Total price',
helperText: preview,
),
), ),
if (_error != null) ...[ if (_error != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
+5 -1
View File
@@ -9,6 +9,7 @@ Future<String?> showNameDialog(
String? initial, String? initial,
String label = 'Name', String label = 'Name',
String confirmLabel = 'Save', String confirmLabel = 'Save',
bool allowEmpty = false,
}) { }) {
return showDialog<String>( return showDialog<String>(
context: context, context: context,
@@ -17,6 +18,7 @@ Future<String?> showNameDialog(
initial: initial ?? '', initial: initial ?? '',
label: label, label: label,
confirmLabel: confirmLabel, confirmLabel: confirmLabel,
allowEmpty: allowEmpty,
), ),
); );
} }
@@ -27,12 +29,14 @@ class _NameDialog extends StatefulWidget {
required this.initial, required this.initial,
required this.label, required this.label,
required this.confirmLabel, required this.confirmLabel,
required this.allowEmpty,
}); });
final String title; final String title;
final String initial; final String initial;
final String label; final String label;
final String confirmLabel; final String confirmLabel;
final bool allowEmpty;
@override @override
State<_NameDialog> createState() => _NameDialogState(); State<_NameDialog> createState() => _NameDialogState();
@@ -56,7 +60,7 @@ class _NameDialogState extends State<_NameDialog> {
void _save() { void _save() {
final name = _controller.text.trim(); final name = _controller.text.trim();
if (name.isEmpty) { if (name.isEmpty && !widget.allowEmpty) {
setState(() => _error = 'Enter a name.'); setState(() => _error = 'Enter a name.');
return; return;
} }
+90
View File
@@ -0,0 +1,90 @@
/// Bottom sheet for picking a shopping list to add a product to.
library;
import 'package:flutter/material.dart';
import '../models/shopping_list.dart';
import '../services/product_repository.dart';
import 'name_dialog.dart';
Future<int?> showPickShoppingListSheet(
BuildContext context, {
required ProductRepository repository,
}) async {
final lists = await repository.getShoppingLists();
if (!context.mounted) return null;
if (lists.isEmpty) {
final name = await showNameDialog(
context,
title: 'New shopping list',
label: 'List name',
confirmLabel: 'Create',
);
if (name == null || !context.mounted) return null;
return repository.createShoppingList(name);
}
return showModalBottomSheet<int>(
context: context,
showDragHandle: true,
builder: (context) {
return _PickShoppingListSheet(repository: repository, lists: lists);
},
);
}
class _PickShoppingListSheet extends StatelessWidget {
const _PickShoppingListSheet({required this.repository, required this.lists});
final ProductRepository repository;
final List<ShoppingList> lists;
Future<void> _create(BuildContext context) async {
final name = await showNameDialog(
context,
title: 'New shopping list',
label: 'List name',
confirmLabel: 'Create',
);
if (name == null || !context.mounted) return;
final id = await repository.createShoppingList(name);
if (context.mounted) Navigator.of(context).pop(id);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text('Add to list', style: theme.textTheme.titleLarge),
),
for (final list in lists)
ListTile(
leading: const Icon(Icons.checklist_outlined),
title: Text(list.name),
subtitle: Text(
list.itemCount == 0
? 'Empty'
: list.itemCount == 1
? '1 item'
: '${list.itemCount} items',
),
onTap: () => Navigator.of(context).pop(list.id),
),
ListTile(
leading: const Icon(Icons.add),
title: const Text('Create new list'),
onTap: () => _create(context),
),
],
),
);
}
}
+7 -4
View File
@@ -28,9 +28,7 @@ class PriceChart extends StatelessWidget {
height: 160, 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
+3 -1
View File
@@ -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(
+618
View File
@@ -0,0 +1,618 @@
/// Banner, list markers, and merge sheet for name duplicates.
library;
import 'package:flutter/material.dart';
import '../models/product.dart';
import '../services/product_repository.dart';
import '../utils/money.dart';
import '../utils/product_names.dart';
import 'product_image.dart';
const IconData kProductNameConflictIcon = Icons.merge_type;
class ProductNameConflictBanner extends StatelessWidget {
const ProductNameConflictBanner({
super.key,
required this.issues,
required this.onTap,
});
final ProductNameIssues issues;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final title = _bannerTitle(issues);
final subtitle = _bannerSubtitle(issues);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Material(
color: scheme.errorContainer.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
child: Row(
children: [
Icon(kProductNameConflictIcon, color: scheme.error),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleSmall),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
],
),
),
Icon(Icons.chevron_right, color: scheme.onSurfaceVariant),
],
),
),
),
),
);
}
}
String _bannerTitle(ProductNameIssues issues) {
final count = issues.issueCount;
if (count == 1 && issues.mergeGroups.length == 1) {
return '1 duplicate product';
}
if (count == 1 && issues.spellingVariants.length == 1) {
return '1 product, multiple spellings';
}
return '$count name issues';
}
String _bannerSubtitle(ProductNameIssues issues) {
final examples = <String>[];
for (final group in issues.mergeGroups.take(2)) {
final store = group.supermarket == null ? '' : ' @ ${group.supermarket}';
examples.add(
'${group.products.map((product) => product.name).join(' / ')}$store',
);
}
for (final variant in issues.spellingVariants.take(2)) {
final store = variant.supermarket == null
? ''
: ' @ ${variant.supermarket}';
examples.add('${variant.variants.join(' / ')}$store');
}
return examples.join(', ');
}
class ProductNameConflictMarker extends StatelessWidget {
const ProductNameConflictMarker({
super.key,
this.onTap,
this.compact = false,
});
final VoidCallback? onTap;
final bool compact;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final icon = Icon(
kProductNameConflictIcon,
size: compact ? 14 : 20,
color: scheme.error,
);
if (onTap == null) return icon;
if (compact) {
return Material(
color: scheme.errorContainer,
shape: const CircleBorder(),
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Padding(padding: const EdgeInsets.all(2), child: icon),
),
);
}
return IconButton(
tooltip: 'Resolve name issues',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: onTap,
icon: icon,
);
}
}
Future<bool> showProductNameConflictSheet(
BuildContext context, {
required ProductRepository repository,
required ProductNameIssues issues,
int? focusProductId,
}) {
if (issues.isEmpty) return Future.value(false);
return showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
builder: (context) {
final size = MediaQuery.sizeOf(context);
return SizedBox(
width: size.width,
height: size.height * 0.85,
child: _ProductNameConflictSheet(
repository: repository,
issues: issues,
focusProductId: focusProductId,
),
);
},
).then((resolved) => resolved == true);
}
class _ProductNameConflictSheet extends StatefulWidget {
const _ProductNameConflictSheet({
required this.repository,
required this.issues,
this.focusProductId,
});
final ProductRepository repository;
final ProductNameIssues issues;
final int? focusProductId;
@override
State<_ProductNameConflictSheet> createState() =>
_ProductNameConflictSheetState();
}
class _ProductNameConflictSheetState extends State<_ProductNameConflictSheet> {
late List<ProductMergeGroup> _mergeGroups;
late List<ProductSpellingVariant> _spellingVariants;
late Map<int, int> _keepByMergeGroup;
late Map<int, String> _spellingChoiceByIndex;
final Set<int> _busyMergeGroups = {};
final Set<int> _busySpellingGroups = {};
bool _resolvedAny = false;
@override
void initState() {
super.initState();
_mergeGroups = [for (final group in widget.issues.mergeGroups) group];
_spellingVariants = [
for (final variant in widget.issues.spellingVariants) variant,
];
_rebuildChoices();
}
void _rebuildChoices() {
_keepByMergeGroup = {
for (var i = 0; i < _mergeGroups.length; i++)
i: _defaultKeep(_mergeGroups[i].products),
};
_spellingChoiceByIndex = {
for (var i = 0; i < _spellingVariants.length; i++)
i: _spellingVariants[i].variants.first,
};
}
int _defaultKeep(List<Product> group) {
final sorted = [...group]
..sort((a, b) {
final seen = b.timesSeen.compareTo(a.timesSeen);
if (seen != 0) return seen;
return a.id.compareTo(b.id);
});
return sorted.first.id;
}
bool get _isEmpty => _mergeGroups.isEmpty && _spellingVariants.isEmpty;
int? _focusedMergeIndex() {
final focus = widget.focusProductId;
if (focus == null) return null;
for (var i = 0; i < _mergeGroups.length; i++) {
if (_mergeGroups[i].products.any((product) => product.id == focus)) {
return i;
}
}
return null;
}
int? _focusedSpellingIndex() {
final focus = widget.focusProductId;
if (focus == null) return null;
for (var i = 0; i < _spellingVariants.length; i++) {
if (_spellingVariants[i].product.id == focus) return i;
}
return null;
}
void _maybeClose() {
if (_isEmpty && mounted) {
Navigator.of(context).pop(true);
}
}
Future<void> _mergeGroup(int index) async {
if (_busyMergeGroups.contains(index)) return;
final group = _mergeGroups[index];
final keepId = _keepByMergeGroup[index]!;
setState(() => _busyMergeGroups.add(index));
try {
await widget.repository.mergeProducts(
keepId: keepId,
sourceIds: [for (final product in group.products) product.id],
);
if (!mounted) return;
setState(() {
_resolvedAny = true;
_busyMergeGroups.remove(index);
_mergeGroups.removeAt(index);
_rebuildChoices();
});
_maybeClose();
} catch (_) {
if (!mounted) return;
setState(() => _busyMergeGroups.remove(index));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not merge these products.')),
);
}
}
Future<void> _leaveMergeAsIs(int index) async {
final group = _mergeGroups[index];
await widget.repository.dismissNameIssue(group.key);
if (!mounted) return;
setState(() {
_resolvedAny = true;
_mergeGroups.removeAt(index);
_rebuildChoices();
});
_maybeClose();
}
Future<void> _applySpelling(int index) async {
if (_busySpellingGroups.contains(index)) return;
final variant = _spellingVariants[index];
final chosen = _spellingChoiceByIndex[index]!;
setState(() => _busySpellingGroups.add(index));
try {
await widget.repository.unifyProductSpelling(
productId: variant.product.id,
name: chosen,
supermarket: variant.supermarket,
);
if (!mounted) return;
setState(() {
_resolvedAny = true;
_busySpellingGroups.remove(index);
_spellingVariants.removeAt(index);
_rebuildChoices();
});
_maybeClose();
} catch (_) {
if (!mounted) return;
setState(() => _busySpellingGroups.remove(index));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not update this spelling.')),
);
}
}
Future<void> _leaveSpellingAsIs(int index) async {
final variant = _spellingVariants[index];
await widget.repository.dismissNameIssue(variant.key);
if (!mounted) return;
setState(() {
_resolvedAny = true;
_spellingVariants.removeAt(index);
_rebuildChoices();
});
_maybeClose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final focusMergeIndex = _focusedMergeIndex();
final focusSpellingIndex = _focusedSpellingIndex();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Resolve name issues', style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(
'Same name at the same store. Pick which entry to keep, '
'then merge or leave as separate products.',
style: theme.textTheme.bodyMedium,
),
],
),
),
const SizedBox(height: 12),
Expanded(
child: _isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
_resolvedAny
? 'All name issues resolved.'
: 'No name issues found.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
),
)
: ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
if (_mergeGroups.isNotEmpty) ...[
Text(
'Separate products to merge',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
for (var i = 0; i < _mergeGroups.length; i++) ...[
if (i > 0) const SizedBox(height: 12),
_MergeGroupCard(
group: _mergeGroups[i],
keepId: _keepByMergeGroup[i]!,
busy: _busyMergeGroups.contains(i),
highlighted: focusMergeIndex == i,
onKeepChanged: (id) {
setState(() => _keepByMergeGroup[i] = id);
},
onMerge: () => _mergeGroup(i),
onLeaveAsIs: () => _leaveMergeAsIs(i),
),
],
],
if (_spellingVariants.isNotEmpty) ...[
if (_mergeGroups.isNotEmpty) const SizedBox(height: 20),
Text(
'One product, multiple spellings',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
for (var i = 0; i < _spellingVariants.length; i++) ...[
if (i > 0) const SizedBox(height: 12),
_SpellingVariantCard(
variant: _spellingVariants[i],
chosen: _spellingChoiceByIndex[i]!,
busy: _busySpellingGroups.contains(i),
highlighted: focusSpellingIndex == i,
onChosen: (name) {
setState(() => _spellingChoiceByIndex[i] = name);
},
onApply: () => _applySpelling(i),
onLeaveAsIs: () => _leaveSpellingAsIs(i),
),
],
],
],
),
),
],
);
}
}
class _MergeGroupCard extends StatelessWidget {
const _MergeGroupCard({
required this.group,
required this.keepId,
required this.busy,
required this.highlighted,
required this.onKeepChanged,
required this.onMerge,
required this.onLeaveAsIs,
});
final ProductMergeGroup group;
final int keepId;
final bool busy;
final bool highlighted;
final ValueChanged<int> onKeepChanged;
final VoidCallback onMerge;
final VoidCallback onLeaveAsIs;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final products = group.products;
return Card(
color: highlighted
? theme.colorScheme.tertiaryContainer.withValues(alpha: 0.35)
: null,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
kProductNameConflictIcon,
size: 18,
color: theme.colorScheme.tertiary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
[
normalizeProductName(products.first.name),
if (group.supermarket != null) group.supermarket!,
].join(' · '),
style: theme.textTheme.titleSmall,
),
),
],
),
const SizedBox(height: 8),
for (final product in products)
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: ProductImage(
name: product.name,
path: product.imagePath,
size: 36,
),
title: Text(product.name),
subtitle: Text(
[
formatEuro(product.lastPrice),
product.timesSeen == 1
? '1 purchase'
: '${product.timesSeen} purchases',
].join(' · '),
),
trailing: Icon(
keepId == product.id
? Icons.check_circle
: Icons.circle_outlined,
color: keepId == product.id
? theme.colorScheme.primary
: theme.colorScheme.outline,
),
onTap: busy ? null : () => onKeepChanged(product.id),
),
Wrap(
alignment: WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
TextButton(
onPressed: busy ? null : onLeaveAsIs,
child: const Text('Leave as is'),
),
FilledButton(
onPressed: busy ? null : onMerge,
child: busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Merge'),
),
],
),
],
),
),
);
}
}
class _SpellingVariantCard extends StatelessWidget {
const _SpellingVariantCard({
required this.variant,
required this.chosen,
required this.busy,
required this.highlighted,
required this.onChosen,
required this.onApply,
required this.onLeaveAsIs,
});
final ProductSpellingVariant variant;
final String chosen;
final bool busy;
final bool highlighted;
final ValueChanged<String> onChosen;
final VoidCallback onApply;
final VoidCallback onLeaveAsIs;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final product = variant.product;
return Card(
color: highlighted
? theme.colorScheme.tertiaryContainer.withValues(alpha: 0.35)
: null,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: ProductImage(
name: product.name,
path: product.imagePath,
size: 36,
),
title: Text(product.name),
subtitle: Text(
[
if (variant.supermarket != null) variant.supermarket!,
product.timesSeen == 1
? '1 purchase'
: '${product.timesSeen} purchases',
].join(' · '),
),
),
const SizedBox(height: 4),
Text('Recorded as:', style: theme.textTheme.labelLarge),
const SizedBox(height: 4),
for (final name in variant.variants)
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(name),
trailing: Icon(
chosen == name ? Icons.check_circle : Icons.circle_outlined,
color: chosen == name
? theme.colorScheme.primary
: theme.colorScheme.outline,
),
onTap: busy ? null : () => onChosen(name),
),
Wrap(
alignment: WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
TextButton(
onPressed: busy ? null : onLeaveAsIs,
child: const Text('Leave as is'),
),
FilledButton(
onPressed: busy ? null : onApply,
child: Text(busy ? 'Saving…' : 'Use this spelling'),
),
],
),
],
),
),
);
}
}
+23
View File
@@ -0,0 +1,23 @@
/// Opens the profile screen from an app bar.
library;
import 'package:flutter/material.dart';
import '../profile_screen.dart';
class ProfileButton extends StatelessWidget {
const ProfileButton({super.key});
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: 'Profile',
onPressed: () {
Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => const ProfileScreen()));
},
icon: const Icon(Icons.person_outline),
);
}
}
@@ -0,0 +1,91 @@
/// Overlays a highlight on the receipt photo for a parsed line item.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import '../models/receipt_bounds.dart';
class ReceiptHighlightOverlay extends StatelessWidget {
const ReceiptHighlightOverlay({
super.key,
required this.imagePath,
required this.bounds,
this.maxHeight = 280,
});
final String imagePath;
final ReceiptBounds bounds;
final double maxHeight;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
fit: StackFit.passthrough,
children: [
Image.file(
File(imagePath),
fit: BoxFit.contain,
height: maxHeight,
width: constraints.maxWidth,
),
Positioned.fill(
child: CustomPaint(
painter: _BoundsPainter(
bounds: bounds,
color: scheme.primary.withValues(alpha: 0.35),
stroke: scheme.primary,
),
),
),
],
);
},
),
);
}
}
class _BoundsPainter extends CustomPainter {
_BoundsPainter({
required this.bounds,
required this.color,
required this.stroke,
});
final ReceiptBounds bounds;
final Color color;
final Color stroke;
@override
void paint(Canvas canvas, Size size) {
if (bounds.isEmpty) return;
final rect = Rect.fromLTRB(
bounds.left * size.width,
bounds.top * size.height,
bounds.right * size.width,
bounds.bottom * size.height,
);
canvas.drawRect(rect, Paint()..color = color);
canvas.drawRect(
rect,
Paint()
..color = stroke
..style = PaintingStyle.stroke
..strokeWidth = 2,
);
}
@override
bool shouldRepaint(covariant _BoundsPainter oldDelegate) {
return oldDelegate.bounds != bounds ||
oldDelegate.color != color ||
oldDelegate.stroke != stroke;
}
}
+124 -252
View File
@@ -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,267 +105,140 @@ 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(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), height: maxHeight,
child: SizedBox( child: ListView(
height: MediaQuery.sizeOf(context).height * 0.75, padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.stretch, Text('Settings', style: theme.textTheme.titleLarge),
children: [ const SizedBox(height: 8),
Text('Settings', style: Theme.of(context).textTheme.titleLarge), SwitchListTile(
const SizedBox(height: 8), contentPadding: EdgeInsets.zero,
SwitchListTile( title: const Text('Dark mode'),
contentPadding: EdgeInsets.zero, value: settings.darkMode,
title: const Text('Dark mode'), onChanged: _busy ? null : settings.setDarkMode,
value: settings.darkMode, ),
onChanged: _busy ? null : settings.setDarkMode, 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',
), ),
Text('Backup', style: Theme.of(context).textTheme.titleMedium), trailing: const Icon(Icons.chevron_right),
Text( onTap: _busy
'Save everything to a JSON file, or replace this device from a backup.', ? null
style: Theme.of(context).textTheme.bodyMedium, : () => _openSupermarkets(context, settings),
), ),
const SizedBox(height: 8), const SizedBox(height: 16),
Row( Text('Backup', style: theme.textTheme.titleMedium),
children: [ Text(
Expanded( 'Save everything to a JSON file, or replace this device from a backup.',
child: OutlinedButton.icon( style: theme.textTheme.bodyMedium,
onPressed: _busy ? null : () => _export(settings), ),
icon: const Icon(Icons.file_upload_outlined), const SizedBox(height: 8),
label: const Text('Export'), Row(
), children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _export(settings),
icon: const Icon(Icons.file_upload_outlined),
label: const Text('Export'),
), ),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _import(settings),
icon: const Icon(Icons.file_download_outlined),
label: const Text('Import'),
),
),
],
),
if (_busy) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(),
],
const SizedBox(height: 16),
Text(
'Supermarkets',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'Name and color used as tags on products.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Expanded(
child: ListView(
children: [
for (final store in settings.supermarkets)
_StoreRow(
store: store,
onEdit: _busy
? () {}
: () => _editStore(context, settings, store),
onDelete: _busy || store.id == null
? null
: () => settings.deleteSupermarket(store.id!),
),
],
), ),
), const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _import(settings),
icon: const Icon(Icons.file_download_outlined),
label: const Text('Import'),
),
),
],
),
if (_busy) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
OutlinedButton.icon( const LinearProgressIndicator(),
onPressed: _busy
? null
: () => _editStore(context, settings, null),
icon: const Icon(Icons.add),
label: const Text('Add supermarket'),
),
], ],
), const SizedBox(height: 32),
Text(
'Danger zone',
style: theme.textTheme.titleMedium?.copyWith(
color: scheme.error,
),
),
const SizedBox(height: 4),
Text(
'Permanently delete everything on this device.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: scheme.error,
side: BorderSide(color: scheme.error),
),
onPressed: _busy ? null : () => _wipeAllData(settings),
icon: const Icon(Icons.delete_forever_outlined),
label: const Text('Delete all data'),
),
],
), ),
); );
}, },
); );
} }
Future<void> _editStore(
BuildContext context,
AppSettings settings,
Supermarket? existing,
) async {
final result = await showDialog<Supermarket>(
context: context,
builder: (context) => _StoreEditDialog(store: existing),
);
if (result == null) return;
if (existing == null) {
try {
await settings.addSupermarket(result.name, result.colorValue);
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('That supermarket already exists.')),
);
}
}
} else {
await settings.updateSupermarket(
existing.copyWith(name: result.name, colorValue: result.colorValue),
);
}
}
}
class _StoreRow extends StatelessWidget {
const _StoreRow({
required this.store,
required this.onEdit,
this.onDelete,
});
final Supermarket store;
final VoidCallback onEdit;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final onColor = store.color.computeLuminance() > 0.55
? Colors.black87
: Colors.white;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: store.color,
child: Text(
store.name.isEmpty ? '?' : store.name.substring(0, 1),
style: TextStyle(color: onColor, fontWeight: FontWeight.w700),
),
),
title: Text(store.name),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Delete',
onPressed: onDelete,
icon: const Icon(Icons.delete_outline),
),
],
),
);
}
}
class _StoreEditDialog extends StatefulWidget {
const _StoreEditDialog({this.store});
final Supermarket? store;
@override
State<_StoreEditDialog> createState() => _StoreEditDialogState();
}
class _StoreEditDialogState extends State<_StoreEditDialog> {
late final TextEditingController _name;
late int _color;
@override
void initState() {
super.initState();
_name = TextEditingController(text: widget.store?.name ?? '');
_color = widget.store?.colorValue ?? kStoreColorPalette.first;
}
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.store == null ? 'Add supermarket' : 'Edit supermarket'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _name,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: widget.store == null,
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: Text('Color', style: Theme.of(context).textTheme.titleMedium),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final value in kStoreColorPalette)
GestureDetector(
onTap: () => setState(() => _color = value),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Color(value),
shape: BoxShape.circle,
border: Border.all(
color: _color == value
? Theme.of(context).colorScheme.onSurface
: Colors.transparent,
width: 2,
),
),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = _name.text.trim();
if (name.isEmpty) return;
Navigator.pop(
context,
Supermarket(
id: widget.store?.id,
name: name,
colorValue: _color,
sortOrder: widget.store?.sortOrder ?? 0,
),
);
},
child: const Text('Save'),
),
],
);
}
} }
+216
View File
@@ -0,0 +1,216 @@
/// GitHub-style contribution heatmap for one calendar month of shopping trips.
library;
import 'package:flutter/material.dart';
import '../utils/dates.dart';
import '../utils/money.dart';
import '../utils/shop_calendar.dart';
class ShopHeatmap extends StatelessWidget {
const ShopHeatmap({
super.key,
required this.month,
required this.counts,
required this.monthTotal,
required this.monthTripCount,
required this.canGoForward,
this.selectedDay,
required this.onPreviousMonth,
required this.onNextMonth,
required this.onSelectDay,
});
final DateTime month;
final Map<DateTime, int> counts;
final double monthTotal;
final int monthTripCount;
final bool canGoForward;
final DateTime? selectedDay;
final VoidCallback onPreviousMonth;
final VoidCallback onNextMonth;
final ValueChanged<DateTime?> onSelectDay;
static const _weekdays = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final cells = monthGrid(month);
final today = dateOnly(DateTime.now());
final weeks = cells.length ~/ 7;
return Card(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
IconButton(
tooltip: 'Previous month',
onPressed: onPreviousMonth,
icon: const Icon(Icons.chevron_left),
),
Expanded(
child: Text(
formatMonthYear(month),
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
),
IconButton(
tooltip: 'Next month',
onPressed: canGoForward ? onNextMonth : null,
icon: const Icon(Icons.chevron_right),
),
],
),
Text(
monthTripCount == 0
? 'No trips this month'
: '${monthTripCount == 1 ? '1 trip' : '$monthTripCount trips'} · ${formatEuro(monthTotal)}',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
children: [
for (final label in _weekdays)
Expanded(
child: Text(
label,
textAlign: TextAlign.center,
style: theme.textTheme.labelMedium,
),
),
],
),
const SizedBox(height: 4),
for (var week = 0; week < weeks; week++)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
for (var weekday = 0; weekday < 7; weekday++)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: _HeatCell(
date: cells[week * 7 + weekday],
count: cells[week * 7 + weekday] == null
? 0
: counts[cells[week * 7 + weekday]] ?? 0,
today: today,
selectedDay: selectedDay,
onSelect: onSelectDay,
),
),
),
],
),
),
const SizedBox(height: 12),
Row(
children: [
Text('Less', style: theme.textTheme.labelMedium),
const SizedBox(width: 6),
for (var level = 0; level <= 4; level++)
Padding(
padding: const EdgeInsets.only(right: 3),
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: _fillForLevel(scheme, level),
borderRadius: BorderRadius.circular(3),
),
),
),
Text('More', style: theme.textTheme.labelMedium),
],
),
],
),
),
);
}
}
class _HeatCell extends StatelessWidget {
const _HeatCell({
required this.date,
required this.count,
required this.today,
required this.selectedDay,
required this.onSelect,
});
final DateTime? date;
final int count;
final DateTime today;
final DateTime? selectedDay;
final ValueChanged<DateTime?> onSelect;
@override
Widget build(BuildContext context) {
if (date == null) {
return const SizedBox(height: 36);
}
final scheme = Theme.of(context).colorScheme;
final level = heatmapLevel(count);
final selected = selectedDay != null && isSameDay(date!, selectedDay!);
final isToday = isSameDay(date!, today);
final fill = _fillForLevel(scheme, level);
final textColor = level >= 3
? scheme.onPrimary
: scheme.onSurface.withValues(alpha: count == 0 ? 0.45 : 1);
return Tooltip(
message: count == 0
? formatDate(date!)
: '${formatDate(date!)} · ${count == 1 ? '1 trip' : '$count trips'}',
child: InkWell(
onTap: () => onSelect(selected ? null : date),
borderRadius: BorderRadius.circular(6),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: fill,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: selected
? scheme.primary
: isToday
? scheme.outline
: Colors.transparent,
width: selected ? 2 : 1,
),
),
child: Text(
'${date!.day}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: textColor,
fontWeight: isToday || selected
? FontWeight.w700
: FontWeight.w500,
fontSize: 11,
),
),
),
),
);
}
}
Color _fillForLevel(ColorScheme scheme, int level) {
if (level <= 0) return scheme.surfaceContainerHighest;
final t = (level / 4).clamp(0.28, 1.0);
return Color.lerp(scheme.primary.withValues(alpha: 0.22), scheme.primary, t)!;
}
+119
View File
@@ -0,0 +1,119 @@
/// Dialog for adding or editing a supermarket name and color.
library;
import 'package:flutter/material.dart';
import '../models/supermarket.dart';
Future<Supermarket?> showStoreEditDialog(
BuildContext context, {
Supermarket? store,
}) {
return showDialog<Supermarket>(
context: context,
builder: (context) => _StoreEditDialog(store: store),
);
}
class _StoreEditDialog extends StatefulWidget {
const _StoreEditDialog({this.store});
final Supermarket? store;
@override
State<_StoreEditDialog> createState() => _StoreEditDialogState();
}
class _StoreEditDialogState extends State<_StoreEditDialog> {
late final TextEditingController _name;
late int _color;
@override
void initState() {
super.initState();
_name = TextEditingController(text: widget.store?.name ?? '');
_color = widget.store?.colorValue ?? kStoreColorPalette.first;
}
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
widget.store == null ? 'Add supermarket' : 'Edit supermarket',
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _name,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: widget.store == null,
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: Text(
'Color',
style: Theme.of(context).textTheme.titleMedium,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final value in kStoreColorPalette)
GestureDetector(
onTap: () => setState(() => _color = value),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Color(value),
shape: BoxShape.circle,
border: Border.all(
color: _color == value
? Theme.of(context).colorScheme.onSurface
: Colors.transparent,
width: 2,
),
),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = _name.text.trim();
if (name.isEmpty) return;
Navigator.pop(
context,
Supermarket(
id: widget.store?.id,
name: name,
colorValue: _color,
sortOrder: widget.store?.sortOrder ?? 0,
bonusImagePath: widget.store?.bonusImagePath,
),
);
},
child: const Text('Save'),
),
],
);
}
}
+181
View File
@@ -0,0 +1,181 @@
/// Chip row and optional editor for shopping-trip tags.
library;
import 'package:flutter/material.dart';
class TagChips extends StatelessWidget {
const TagChips({
super.key,
required this.tags,
this.onTap,
this.selected,
this.compact = false,
});
final List<String> tags;
final ValueChanged<String>? onTap;
final String? selected;
final bool compact;
@override
Widget build(BuildContext context) {
if (tags.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: compact ? 4 : 6,
runSpacing: compact ? 4 : 6,
children: [
for (final tag in tags)
_TagChip(
label: tag,
selected:
selected != null &&
selected!.toLowerCase() == tag.toLowerCase(),
compact: compact,
onPressed: onTap == null ? null : () => onTap!(tag),
),
],
);
}
}
class TagEditor extends StatefulWidget {
const TagEditor({
super.key,
required this.tags,
required this.onChanged,
this.suggestions = const [],
this.enabled = true,
});
final List<String> tags;
final List<String> suggestions;
final ValueChanged<List<String>> onChanged;
final bool enabled;
@override
State<TagEditor> createState() => _TagEditorState();
}
class _TagEditorState extends State<TagEditor> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _add(String raw) {
final label = raw.trim().replaceAll(RegExp(r'\s+'), ' ');
if (label.isEmpty) return;
final exists = widget.tags.any(
(tag) => tag.toLowerCase() == label.toLowerCase(),
);
if (exists) {
_controller.clear();
return;
}
widget.onChanged([...widget.tags, label]);
_controller.clear();
}
void _remove(String tag) {
widget.onChanged([
for (final item in widget.tags)
if (item.toLowerCase() != tag.toLowerCase()) item,
]);
}
@override
Widget build(BuildContext context) {
final unused = [
for (final tag in widget.suggestions)
if (!widget.tags.any((item) => item.toLowerCase() == tag.toLowerCase()))
tag,
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.tags.isNotEmpty) ...[
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final tag in widget.tags)
InputChip(
label: Text(tag),
onDeleted: widget.enabled ? () => _remove(tag) : null,
),
],
),
const SizedBox(height: 8),
],
TextField(
controller: _controller,
enabled: widget.enabled,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Add tag',
hintText: 'weekly, bulk, work…',
),
onSubmitted: widget.enabled ? _add : null,
),
if (widget.enabled && unused.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final tag in unused.take(12))
ActionChip(label: Text(tag), onPressed: () => _add(tag)),
],
),
],
],
);
}
}
class _TagChip extends StatelessWidget {
const _TagChip({
required this.label,
required this.selected,
required this.compact,
this.onPressed,
});
final String label;
final bool selected;
final bool compact;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final bg = selected ? scheme.primary : scheme.secondaryContainer;
final fg = selected ? scheme.onPrimary : scheme.onSecondaryContainer;
return Material(
color: bg,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : 8,
vertical: compact ? 2 : 4,
),
child: Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: fg,
fontWeight: FontWeight.w600,
fontSize: compact ? 11 : 12,
),
),
),
),
);
}
}
+32
View File
@@ -256,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:
@@ -464,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:
@@ -637,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:
@@ -653,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:
+2 -1
View File
@@ -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
@@ -24,6 +24,7 @@ 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
+6
View File
@@ -182,5 +182,11 @@
"ART.", "ART.",
"ARTIKELEN" "ARTIKELEN"
] ]
},
"spatial_parsing": {
"_comment": "Bounding-box pipeline settings. row_cluster_height_factor multiplies median element height when grouping elements into rows (only used when ML Kit lines are unavailable — keep below 0.6 so adjacent receipt lines stay separate). price_column_width_fraction is the right-hand slice of receipt content width treated as the price column.",
"row_cluster_height_factor": 0.55,
"price_column_width_fraction": 0.25
} }
} }
+92 -64
View File
@@ -16,30 +16,53 @@ without assuming a single fixed format.
## 1. Input assumptions ## 1. Input assumptions
- Input is raw OCR text, one line per detected text line, in top-to-bottom - Input is structured OCR from ML Kit: **blocks → lines → elements**, each
order as it appeared on the receipt. 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`), - Prices use European format: comma as decimal separator (e.g. `2,89`),
period as thousands separator if present (rare on receipts this size). period as thousands separator if present (rare on receipts this size).
- OCR may introduce noise: misread characters, extra/missing spaces, - OCR may introduce noise: misread characters, extra/missing spaces,
occasional dropped or merged lines. The parser should be tolerant of occasional dropped or merged lines. The parser should be tolerant of
minor spacing irregularities but does not need to do spell-correction. 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) ## 2. Preprocessing (run before classification)
1. Trim leading/trailing whitespace on every line; collapse multiple 1. Extract every text **element** with its bounding box from ML Kit.
internal spaces to one. 2. **Rebuild rows** — do not trust ML Kit's line segmentation:
2. Normalize decimal separators: treat `,` as the decimal point in any - Sort elements by Y-center (top to bottom).
number matching `\d+[,.]\d{2}`. Convert to a standard `.` internally - Cluster into rows where Y-centers fall within one line height of each
for numeric storage, but remember the original format was comma-based other. Threshold = `median_element_height × row_cluster_height_factor`
(useful for locale-aware re-parsing if needed). (default factor **1.2**, configurable in `spatial_parsing`).
3. Drop fully empty lines. - Within each row, sort elements left-to-right by X position.
4. Do NOT drop short lines outright (e.g. a lone `BROCCOLI` or a lone 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. `2 X 0,95`) — both are meaningful in different receipt layouts.
## 3. Core line classification ## 3. Core row classification
Classify every line into one of these categories, in this priority order Classify every **row** (not flat OCR line) into one of these categories.
(check top-down, first match wins): 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 ### 3.1 STORE_HEADER
- The first 1–5 lines of the receipt, before the first recognizable - The first 1–5 lines of the receipt, before the first recognizable
@@ -72,13 +95,13 @@ Classify every line into one of these categories, in this priority order
- This is the "complete" case: name + price on the same line, quantity - This is the "complete" case: name + price on the same line, quantity
of 1 implied unless a QUANTITY_MODIFIER line follows (section 3.4). of 1 implied unless a QUANTITY_MODIFIER line follows (section 3.4).
### 3.4 QUANTITY_MODIFIER (sub-line, belongs to previous or next ITEM) ### 3.4 QUANTITY_MODIFIER (sub-row, belongs to previous or next ITEM)
- A line matching the pattern: `<int> X <price>` optionally followed by
a unit word (e.g. `PER STUK`), e.g.: See **§3.0** — pure `N X price` rows are detected before any other type.
- `2 X 2,79` The rules below describe how they attach to items:
- `2 X 3,99 PER STUK`
- This line does NOT introduce a new product — it modifies the item on - This row does NOT introduce a new product — it modifies the item on
an adjacent line. an adjacent row.
- Two observed variants — the parser must detect which applies: - Two observed variants — the parser must detect which applies:
- **Variant A (Jumbo-style):** product name appears on its own line - **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 with NO price. The next line is `N X unit_price`. The line total
@@ -154,66 +177,71 @@ Classify every line into one of these categories, in this priority order
## 4. Item construction algorithm (pseudocode) ## 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 = [] items = []
pending_item = None # name found, price/qty not yet resolved pending_item = None # name found, price/qty not yet resolved
for line in classified_lines: for row in classified_rows:
if line.type == ITEM: if row.type == QUANTITY_MODIFIER:
if pending_item:
items.append(pending_item) # close out previous item first
pending_item = { name: line.name, unit_price: line.price,
quantity: 1, line_total: line.price,
discount: 0 }
elif line.type == QUANTITY_MODIFIER:
if pending_item and pending_item.unit_price is None:
# Variant A: name-only line was pending, this line completes it
pending_item.quantity = line.qty
pending_item.unit_price = line.price
pending_item.line_total = line.qty * line.price
elif pending_item:
# Variant B: name+price already set, this line refines/
# confirms it (overwrite quantity/unit price, recompute total)
pending_item.quantity = line.qty
pending_item.unit_price = line.price
pending_item.line_total = line.qty * line.price
else:
# Orphan quantity line with no preceding item — flag for
# manual review rather than silently dropping
flag_for_review(line)
elif line.type == DISCOUNT:
if pending_item:
pending_item.discount += line.amount # amount is negative
pending_item.line_total += line.amount
else:
receipt.total_discount += line.amount
elif line.type in (SUBTOTAL, TOTAL):
if pending_item: 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) items.append(pending_item)
pending_item = None pending_item = None
if is_grand_total(line): continue # orphan modifier without pending → ignore
receipt.total = line.amount
break # stop item parsing here
# catch any trailing pending item if receipt ended oddly if row.type == NAME_ONLY:
if pending_item: if pending_item:
items.append(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) ## 5. Validation pass (do this after parsing, before showing results to user)
1. Sum `line_total` across all parsed items. 1. Sum `line_total` across all parsed items.
2. Add `receipt.total_discount` if it wasn't already folded into 2. Add `receipt.total_discount` if it wasn't already folded into
individual item discounts. individual item discounts.
3. Compare the result to `receipt.total` (the grand total line). 3. Compare the result to `receipt.total` (the grand total line).
4. If they match within 0.01 → high confidence, no flags needed. 4. If the receipt prints an item count (e.g. `Aantal Artikel: N`), compare
5. If they don't match → mark the WHOLE receipt as "needs review" and `N` to the number of parsed items.
surface it clearly in the review/edit screen (don't just flag 5. If totals match within 0.01 **and** item count matches (when present)
individual lines — a mismatch means something in the whole parse is **and** no item is flagged `needs_manual_review` → high confidence.
probably off, e.g. a missed line or a misclassified discount). 6. If any check fails → mark the **whole receipt** as "needs review".
## 6. Known store-specific quirks to encode as configurable rules ## 6. Known store-specific quirks to encode as configurable rules
+12 -4
View File
@@ -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', () {
const original = BackupDocument( const original = BackupDocument(
version: 2, 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,9 @@ void main() {
'unit_price': 1.5, 'unit_price': 1.5,
}, },
], ],
shopTags: [
{'id': 1, 'shop_id': 8, 'tag': 'weekly', 'normalized_tag': 'weekly'},
],
shoppingLists: [ shoppingLists: [
{ {
'id': 1, 'id': 1,
@@ -71,13 +75,16 @@ 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.shoppingLists.single['name'], 'Weekly');
expect(parsed.shoppingListItems.single['name'], 'Milk'); 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('accepts a version 1 backup without shopping lists', () { test('accepts legacy receipity backup format', () {
const json = ''' const json = '''
{ {
"format": "receipity-backup", "format": "receipity-backup",
@@ -95,9 +102,10 @@ void main() {
expect(parsed.version, 1); expect(parsed.version, 1);
expect(parsed.shoppingLists, isEmpty); expect(parsed.shoppingLists, isEmpty);
expect(parsed.shoppingListItems, isEmpty); expect(parsed.shoppingListItems, isEmpty);
expect(parsed.shopTags, isEmpty);
}); });
test('rejects a file that is not a Receipity backup', () { test('rejects a file that is not a Groci backup', () {
expect( expect(
() => BackupDocument.parse('{"hello":"world"}'), () => BackupDocument.parse('{"hello":"world"}'),
throwsFormatException, throwsFormatException,
+111
View File
@@ -0,0 +1,111 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:groci/models/line_item.dart';
import 'test_helpers.dart';
void main() {
ensureTestSqlite();
test(
'deleting a product keeps the trip line and drops the catalog row',
() async {
final repository = memoryRepository();
final shopId = await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 12),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
final products = await repository.getAll();
expect(products, hasLength(1));
await repository.deleteProduct(products.single.id);
expect(await repository.getAll(), isEmpty);
final items = await repository.getShopItems(shopId);
expect(items, hasLength(1));
expect(items.single.name, 'Milk');
expect(items.single.productId, isNull);
},
);
test('addShopItem appends a line and updates the trip total', () async {
final repository = memoryRepository();
final shopId = await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 12),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
await repository.addShopItem(
shopId: shopId,
item: LineItem(id: '2', name: 'Bread', price: 2),
);
final shop = (await repository.getShop(shopId))!;
expect(shop.total, 3.5);
final items = await repository.getShopItems(shopId);
expect(items, hasLength(2));
expect(items.map((item) => item.name), ['Milk', 'Bread']);
expect((await repository.getAll()).map((product) => product.name).toSet(), {
'Milk',
'Bread',
});
});
test('getLastTripSupermarkets lists unique stores newest first', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Albert Heijn',
shoppedAt: DateTime(2026, 8, 12),
items: [LineItem(id: '2', name: 'Bread', price: 2)],
);
expect(await repository.getLastTripSupermarkets(), [
'Albert Heijn',
'Jumbo',
]);
});
test('wipeAllData clears products, trips, lists, and settings', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 12),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
await repository.createShoppingList('Weekly');
await repository.setDarkMode(true);
await repository.wipeAllData();
expect(await repository.getAll(), isEmpty);
expect(await repository.getShops(), isEmpty);
expect(await repository.getShoppingLists(), isEmpty);
expect(await repository.getDarkMode(), isFalse);
expect((await repository.getSupermarkets()).length, greaterThan(0));
});
test('deleting all products leaves shopping trips in place', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 12),
items: [
LineItem(id: '1', name: 'Milk', price: 1.5),
LineItem(id: '2', name: 'Bread', price: 2),
],
);
await repository.deleteAllProducts();
expect(await repository.getAll(), isEmpty);
expect(await repository.getShops(), hasLength(1));
final overview = await repository.getOverview();
expect(overview.productCount, 0);
expect(overview.tripCount, 1);
});
}
+5 -2
View File
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; import 'package: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,
);
}); });
} }
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:groci/models/line_item.dart';
import 'package:groci/widgets/item_edit_sheet.dart';
void main() {
testWidgets('edit sheet syncs quantity unit and total', (tester) async {
LineItem? saved;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) {
return TextButton(
onPressed: () async {
saved = await showItemEditSheet(
context,
item: LineItem(
id: '1',
name: 'Milk',
price: 3.98,
quantity: 2,
unitPrice: 1.99,
),
);
},
child: const Text('Open'),
);
},
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('Edit item'), findsOneWidget);
expect(find.text('Qty'), findsOneWidget);
expect(find.text('Unit price'), findsOneWidget);
expect(find.text('Total price'), findsOneWidget);
expect(find.text('2 × € 1.99'), findsOneWidget);
await tester.enterText(find.widgetWithText(TextField, 'Qty'), '3');
await tester.pump();
expect(find.text('3 × € 1.99'), findsOneWidget);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(saved, isNotNull);
expect(saved!.name, 'Milk');
expect(saved!.quantity, 3);
expect(saved!.unitPrice, 1.99);
expect(saved!.price, closeTo(5.97, 0.001));
});
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:groci/models/line_item.dart';
import 'package:groci/models/product.dart';
import 'package:groci/utils/product_names.dart';
import 'test_helpers.dart';
Product _product(int id, String name, {String? supermarket}) => Product(
id: id,
name: name,
lastPrice: 1,
timesSeen: 1,
updatedAt: DateTime(2026, 1, 1),
supermarket: supermarket,
);
void main() {
ensureTestSqlite();
group('nameConflictGroups', () {
test('groups products that differ only by case at the same store', () {
final groups = nameConflictGroups([
_product(1, 'Broccoli', supermarket: 'Jumbo'),
_product(2, 'BROCCOLI', supermarket: 'Jumbo'),
_product(3, 'Milk', supermarket: 'Jumbo'),
]);
expect(groups, hasLength(1));
expect(groups.single.map((product) => product.id), [1, 2]);
});
test('ignores same name at different stores', () {
final groups = nameConflictGroups([
_product(1, 'Broccoli', supermarket: 'Jumbo'),
_product(2, 'BROCCOLI', supermarket: 'Lidl'),
]);
expect(groups, isEmpty);
});
});
group('buildNameConflictGroups', () {
test('finds conflicts from trip lines linked to different products', () {
final groups = buildNameConflictGroups(
products: [
_product(1, 'Broccoli', supermarket: 'Jumbo'),
_product(2, 'BROCCOLI', supermarket: 'Jumbo'),
],
shopLines: const [
(productId: 1, name: 'Broccoli', supermarket: 'Jumbo'),
(productId: 2, name: 'BROCCOLI', supermarket: 'Jumbo'),
],
);
expect(groups, hasLength(1));
expect(groups.single.products.map((product) => product.id), [1, 2]);
expect(groups.single.supermarket, 'Jumbo');
});
});
group('findSpellingVariants', () {
test('finds multiple receipt spellings for one product at one store', () {
final variants = findSpellingVariants(
products: [_product(1, 'Broccoli', supermarket: 'Jumbo')],
shopLines: const [
(productId: 1, name: 'Broccoli', supermarket: 'Jumbo'),
(productId: 1, name: 'BROCCOLI', supermarket: 'Jumbo'),
],
);
expect(variants, hasLength(1));
expect(variants.single.product.id, 1);
expect(variants.single.supermarket, 'Jumbo');
expect(variants.single.variants, ['Broccoli', 'BROCCOLI']);
});
test('ignores different spellings at different stores', () {
final variants = findSpellingVariants(
products: [_product(1, 'Broccoli', supermarket: 'Jumbo')],
shopLines: const [
(productId: 1, name: 'Broccoli', supermarket: 'Jumbo'),
(productId: 1, name: 'BROCCOLI', supermarket: 'Lidl'),
],
);
expect(variants, isEmpty);
});
});
test('same name at different supermarkets creates separate products', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Lidl',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'Milk', price: 1.2)],
);
final products = await repository.getAll();
expect(products, hasLength(2));
expect(products.map((p) => p.supermarket).toSet(), {'Jumbo', 'Lidl'});
expect(await repository.getProductNameIssues(), isEmpty);
});
test('getNameConflictGroups finds duplicates in the database', () async {
final repository = memoryRepository();
await seedCaseConflictCatalog(repository);
final groups = await repository.getNameConflictGroups();
expect(groups, hasLength(1));
expect(groups.single.map((product) => product.name).toSet(), {
'Broccoli',
'BROCCOLI',
});
});
test('getProductNameIssues finds receipt spelling variants', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)],
);
final issues = await repository.getProductNameIssues();
expect(issues.mergeGroups, isEmpty);
expect(issues.spellingVariants, hasLength(1));
expect(issues.spellingVariants.single.supermarket, 'Jumbo');
expect(issues.spellingVariants.single.variants, ['Broccoli', 'BROCCOLI']);
});
test('dismissNameIssue hides a spelling variant', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)],
);
final issue =
(await repository.getProductNameIssues()).spellingVariants.single;
await repository.dismissNameIssue(issue.key);
expect(await repository.getProductNameIssues(), isEmpty);
});
test('mergeProducts combines purchases onto the kept product', () async {
final repository = memoryRepository();
await seedCaseConflictCatalog(repository);
await repository.mergeProducts(keepId: 1, sourceIds: [2]);
final after = await repository.getAll();
expect(after, hasLength(1));
expect(after.single.name, 'Broccoli');
expect(after.single.timesSeen, 2);
expect(await repository.getPurchases(1), hasLength(2));
});
test('unifyProductSpelling updates catalog and receipt lines', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)],
);
final product = (await repository.getAll()).single;
await repository.unifyProductSpelling(
productId: product.id,
name: 'BROCCOLI',
supermarket: 'Jumbo',
);
final updated = (await repository.getProduct(product.id))!;
expect(updated.name, 'BROCCOLI');
expect((await repository.getProductNameIssues()).isEmpty, isTrue);
});
}
+85 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/services/receipt_parser.dart'; import 'package:groci/models/ocr_element.dart';
import 'package:groci/services/receipt_parser.dart';
void main() { 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 +16,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
@@ -56,6 +86,7 @@ Totaal korting: -2,00
expect(result.items.map((item) => item.name), ['AARDBEIEN']); expect(result.items.map((item) => item.name), ['AARDBEIEN']);
expect(result.items.single.price, 5.98); expect(result.items.single.price, 5.98);
expect(result.items.single.discount, -2.00); expect(result.items.single.discount, -2.00);
expect(result.items.single.originalPrice, 7.98);
expect(result.validationPassed, isTrue); expect(result.validationPassed, isTrue);
}); });
@@ -122,6 +153,59 @@ Bedr.Incl 2,09
expect(items.last.price, 6.20); expect(items.last.price, 6.20);
expect(items, hasLength(26)); expect(items, hasLength(26));
}); });
test('scan path uses column parser for Jumbo name-then-price OCR dump', () {
const ocr = '''
BIO VASTKOKEND AARD
2 X 1,99
AARDBEIEN
2 X 3,99
ACTIE AARDBEIEN
BROCCOLII
PREI
CITROENEN
KOMKOMMER
2 X 0,95
ACTIE KOMKOMMER
BOL KNOFLOOK
BROODSTICKS OLIJF
4 X 1.25
3,98
7,98
-2,00
1,24
0,69
2,11
1,90
-0,21
1,79
5,00
''';
final result = ReceiptParser.parseReceiptFromOcr(
OcrDocument(
elements: const [],
lines: const [],
imageWidth: 800,
imageHeight: 1200,
flattenedText: ocr,
),
);
expect(result.items, hasLength(8));
expect(result.items[0].name, 'BIO VASTKOKEND AARD');
expect(result.items[0].quantity, 2);
expect(result.items[0].price, 3.98);
expect(result.items[1].name, 'AARDBEIEN');
expect(result.items[1].price, 5.98);
expect(result.items[1].discount, -2.00);
expect(result.items[5].name, 'KOMKOMMER');
expect(result.items[5].price, 1.69);
expect(result.items.last.name, 'BROODSTICKS OLIJF');
expect(result.items.last.quantity, 4);
expect(result.items.last.price, 5.00);
expect(result.items.every((item) => !item.needsManualReview), isTrue);
});
} }
const _jumboColumnOcr = ''' const _jumboColumnOcr = '''
+195
View File
@@ -0,0 +1,195 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:groci/models/ocr_element.dart';
import 'package:groci/services/receipt_parse_config.dart';
import 'package:groci/services/receipt_parser.dart';
import 'package:groci/services/receipt_row_builder.dart';
import 'package:groci/services/receipt_spatial_parser.dart';
/// Synthetic two-column receipt layout (800×1200 image).
const _imageWidth = 800.0;
const _imageHeight = 1200.0;
const _nameLeft = 40.0;
const _priceLeft = 620.0;
const _rowHeight = 24.0;
const _rowGap = 8.0;
OcrElement _word(String text, double row, {bool price = false}) {
final y = row * (_rowHeight + _rowGap) + 100;
final left = price ? _priceLeft : _nameLeft;
final width = text.length * 10.0 + 20;
return OcrElement(
text: text,
left: left,
top: y,
right: left + width,
bottom: y + _rowHeight,
);
}
OcrLine _line(List<OcrElement> elements) {
var left = double.infinity;
var top = double.infinity;
var right = 0.0;
var bottom = 0.0;
for (final e in elements) {
if (e.left < left) left = e.left;
if (e.top < top) top = e.top;
if (e.right > right) right = e.right;
if (e.bottom > bottom) bottom = e.bottom;
}
return OcrLine(
text: elements.map((e) => e.text).join(' '),
elements: elements,
left: left,
top: top,
right: right,
bottom: bottom,
);
}
OcrDocument _document(
List<List<OcrElement>> rows, {
String? flattened,
}) {
final elements = [for (final row in rows) ...row];
final lines = [for (final row in rows) _line(row)];
return OcrDocument(
elements: elements,
lines: lines,
imageWidth: _imageWidth,
imageHeight: _imageHeight,
flattenedText: flattened ?? lines.map((l) => l.text).join('\n'),
);
}
void main() {
setUpAll(() async {
await ReceiptParser.loadConfig();
});
group('ReceiptRowBuilder', () {
test('clusters name and price on the same row by Y position', () {
final rows = ReceiptRowBuilder(receiptParseConfig).buildRows(
_document([
[_word('MILK', 0), _word('1,50', 0, price: true)],
]),
);
expect(rows, hasLength(1));
expect(rows.single.kind.name, 'item');
expect(rows.single.nameText, 'MILK');
expect(rows.single.price, 1.50);
});
test('classifies pure quantity row before item logic', () {
final rows = ReceiptRowBuilder(receiptParseConfig).buildRows(
_document([
[_word('2', 1), _word('X', 1), _word('2,99', 1, price: true)],
], flattened: '2 X 2,99'),
);
expect(rows, hasLength(1));
expect(rows.single.kind.name, 'quantityModifier');
expect(rows.single.quantity, 2);
expect(rows.single.unitPrice, 2.99);
});
test('treats row without price-column token as name-only', () {
final rows = ReceiptRowBuilder(receiptParseConfig).buildRows(
_document([
[_word('BROCCOLI', 0)],
]),
);
expect(rows.single.kind.name, 'nameOnly');
expect(rows.single.nameText, 'BROCCOLI');
});
test('does not merge adjacent ML Kit lines into one row', () {
final rows = ReceiptRowBuilder(receiptParseConfig).buildRows(
_document([
[_word('BIO', 0), _word('VASTKOKEND', 0), _word('AARD', 0)],
[_word('2', 1), _word('X', 1), _word('1,99', 1, price: true)],
]),
);
expect(rows, hasLength(2));
expect(rows[0].nameText, contains('BIO'));
expect(rows[1].kind.name, 'quantityModifier');
});
});
group('ReceiptSpatialParser lookahead', () {
test('merges Jumbo-style name row with following quantity modifier', () {
final result = ReceiptSpatialParser.parse(
_document([
[_word('AARDBEIEN', 0)],
[_word('2', 1), _word('X', 1), _word('3,99', 1, price: true)],
[_word('Totaal', 2), _word('7,98', 2, price: true)],
]),
);
expect(result.items, hasLength(1));
expect(result.items.single.name, 'AARDBEIEN');
expect(result.items.single.quantity, 2);
expect(result.items.single.unitPrice, 3.99);
expect(result.items.single.price, 7.98);
expect(result.items.single.sourceBounds, isNotNull);
});
test('overrides inline price when quantity modifier follows (Kruidvat)', () {
final result = ReceiptSpatialParser.parse(
_document([
[_word('ZEEPTABLET', 0), _word('NEUTRAL', 0), _word('7,98', 0, price: true)],
[_word('2', 1), _word('X', 1), _word('3,99', 1, price: true)],
]),
);
expect(result.items, hasLength(1));
expect(result.items.single.quantity, 2);
expect(result.items.single.unitPrice, 3.99);
expect(result.items.single.price, 7.98);
});
test('flags name-only row without following modifier for manual review', () {
final result = ReceiptSpatialParser.parse(
_document([
[_word('MILK', 0)],
[_word('BREAD', 1), _word('2,00', 1, price: true)],
]),
);
expect(result.items, hasLength(2));
expect(result.items[0].name, 'MILK');
expect(result.items[0].needsManualReview, isTrue);
expect(result.items[1].name, 'BREAD');
expect(result.items[1].price, 2.00);
});
test('parses two side-by-side items without cross-assigning prices', () {
final result = ReceiptSpatialParser.parse(
_document([
[_word('MILK', 0), _word('1,50', 0, price: true)],
[_word('BREAD', 1), _word('2,00', 1, price: true)],
[_word('Totaal', 2), _word('3,50', 2, price: true)],
]),
);
expect(result.items.map((i) => i.name), ['MILK', 'BREAD']);
expect(result.items.map((i) => i.price), [1.50, 2.00]);
expect(result.validationPassed, isTrue);
});
test('fails validation when printed item count does not match', () {
final result = ReceiptSpatialParser.parse(
_document([
[_word('MILK', 0), _word('1,50', 0, price: true)],
[_word('Aantal Artikel: 3', 1)],
]),
);
expect(result.expectedItemCount, 3);
expect(result.validationPassed, isFalse);
});
});
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:groci/models/shop.dart';
import 'package:groci/utils/shop_calendar.dart';
import 'package:groci/utils/shop_log_filter.dart';
Shop _shop({
required int id,
required DateTime at,
String supermarket = 'Jumbo',
String? name,
List<String> tags = const [],
}) {
return Shop(
id: id,
supermarket: supermarket,
name: name,
shoppedAt: at,
total: 10,
itemCount: 1,
tags: tags,
);
}
void main() {
group('monthGrid', () {
test('pads August 2026 from Saturday with Monday-first weeks', () {
final cells = monthGrid(DateTime(2026, 8));
expect(cells.length % 7, 0);
expect(cells[0], isNull);
expect(cells[1], isNull);
expect(cells[2], isNull);
expect(cells[3], isNull);
expect(cells[4], isNull);
expect(cells[5], DateTime(2026, 8, 1));
expect(cells[35], DateTime(2026, 8, 31));
expect(cells.last, isNull);
});
});
test('tripCountsByDay groups multiple trips on the same day', () {
final counts = tripCountsByDay([
DateTime(2026, 8, 12, 9),
DateTime(2026, 8, 12, 18),
DateTime(2026, 8, 13),
]);
expect(counts[DateTime(2026, 8, 12)], 2);
expect(counts[DateTime(2026, 8, 13)], 1);
expect(heatmapLevel(0), 0);
expect(heatmapLevel(1), 1);
expect(heatmapLevel(4), 4);
expect(heatmapLevel(9), 4);
});
group('filterShops', () {
final shops = [
_shop(id: 1, at: DateTime(2026, 8, 12), tags: const ['weekly']),
_shop(
id: 2,
at: DateTime(2026, 8, 13),
name: 'Work lunch',
tags: const ['work'],
),
];
const names = {
1: ['Milk', 'Bread'],
2: ['Coffee'],
};
test('filters by product name', () {
final found = filterShops(
shops: shops,
productNames: names,
query: 'milk',
);
expect(found.map((shop) => shop.id), [1]);
});
test('filters by selected day', () {
final found = filterShops(
shops: shops,
productNames: names,
selectedDay: DateTime(2026, 8, 13),
);
expect(found.single.id, 2);
});
test('filters by tag', () {
final found = filterShops(
shops: shops,
productNames: names,
tag: 'weekly',
);
expect(found.single.id, 1);
});
});
}
+30 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; import 'package: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',
]);
});
} }
+108
View File
@@ -0,0 +1,108 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:groci/services/product_repository.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
var _dbSeq = 0;
var _sqliteReady = false;
void ensureTestSqlite() {
if (_sqliteReady) return;
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
_sqliteReady = true;
}
ProductRepository memoryRepository() {
ensureTestSqlite();
final dir = Directory(p.join('.dart_tool', 'groci_test_db'))
..createSync(recursive: true);
final name =
'db_${DateTime.now().microsecondsSinceEpoch}_${_dbSeq++}_${Random().nextInt(1 << 32)}.sqlite';
return ProductRepository(databasePath: p.join(dir.path, name));
}
Future<void> pumpUntilFound(
WidgetTester tester,
Finder finder, {
int attempts = 40,
}) async {
for (var i = 0; i < attempts; i++) {
await tester.pump();
if (finder.evaluate().isNotEmpty) return;
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 20)),
);
}
throw TestFailure('Timed out waiting for $finder');
}
const _seedTs = '2026-08-01T00:00:00.000';
Future<void> seedCaseConflictCatalog(ProductRepository repository) async {
await repository.replaceAllData(
darkMode: false,
supermarkets: [
{'id': 1, 'name': 'Jumbo', 'color': 0xFFEEC21B, 'sort_order': 0},
],
products: [
{
'id': 1,
'name': 'Broccoli',
'normalized_name': 'BROCCOLI_A',
'last_price': 1.5,
'times_seen': 1,
'favourite': 0,
'supermarket': 'Jumbo',
'created_at': _seedTs,
'updated_at': _seedTs,
},
{
'id': 2,
'name': 'BROCCOLI',
'normalized_name': 'BROCCOLI_B',
'last_price': 1.8,
'times_seen': 1,
'favourite': 0,
'supermarket': 'Jumbo',
'created_at': _seedTs,
'updated_at': _seedTs,
},
],
shops: [
{
'id': 1,
'supermarket': 'Jumbo',
'shopped_at': '2026-08-01T00:00:00.000',
'total': 1.5,
'created_at': _seedTs,
},
{
'id': 2,
'supermarket': 'Jumbo',
'shopped_at': '2026-08-02T00:00:00.000',
'total': 1.8,
'created_at': _seedTs,
},
],
shopItems: [
{
'id': 1,
'shop_id': 1,
'product_id': 1,
'name': 'Broccoli',
'price': 1.5,
},
{
'id': 2,
'shop_id': 2,
'product_id': 2,
'name': 'BROCCOLI',
'price': 1.8,
},
],
);
}
+278 -17
View File
@@ -1,17 +1,38 @@
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('Enter manually'), findsOneWidget);
@@ -20,22 +41,149 @@ void main() {
find.textContaining('Scan a receipt or enter a trip by hand'), find.textContaining('Scan a receipt or enter a trip by hand'),
findsOneWidget, findsOneWidget,
); );
expect(find.text('Products'), findsOneWidget); });
expect(find.text('Lists'), findsOneWidget);
expect(find.text('Add'), findsOneWidget); testWidgets('empty shop log hides edit and disables search', (tester) async {
expect(find.text('Shop log'), findsOneWidget); 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 { testWidgets('enter manually opens a blank trip', (tester) async {
await tester.pumpWidget(ReceipityApp()); 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 tester.tap(find.text('Enter manually'));
await tester.pumpAndSettle(); await pumpUntilFound(tester, find.text('New trip'));
expect(find.text('New trip'), findsOneWidget);
expect(find.text('Add item'), findsOneWidget); expect(find.text('Add item'), findsOneWidget);
}); });
testWidgets('lists tab shows shopping lists', (tester) async { testWidgets('lists tab shows shopping lists', (tester) async {
await tester.pumpWidget(ReceipityApp()); await tester.pumpWidget(GrociApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Lists')); await tester.tap(find.text('Lists'));
await tester.pump(); await tester.pump();
expect(find.text('Shopping lists'), findsOneWidget); expect(find.text('Shopping lists'), findsOneWidget);
@@ -50,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(),
), ),
), ),
); );
@@ -60,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();
@@ -75,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);
});
} }