7 Commits
Author SHA1 Message Date
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
58 changed files with 6626 additions and 1205 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
+1 -1
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).
+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),
],
),
),
),
);
}
}
+134 -23
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,
foregroundColor: scheme.onPrimary,
child: const Icon(Icons.add, size: 24),
),
bottomNavigationBar: BottomAppBar(
shape: const CircularNotchedRectangle(),
notchMargin: 6,
elevation: 2,
height: 52,
padding: EdgeInsets.zero,
color: scheme.surfaceContainer,
child: Row(
children: [
Expanded(
child: Row(
children: [
Expanded(
child: _NavButton(
icon: Icons.home_outlined,
selectedIcon: Icons.home,
label: 'Home',
selected: _index == _homeIndex,
onTap: () => _select(_homeIndex),
),
),
Expanded(
child: _NavButton(
icon: Icons.inventory_2_outlined,
selectedIcon: Icons.inventory_2,
label: 'Products', label: 'Products',
selected: _index == _productsIndex,
onTap: () => _select(_productsIndex),
), ),
NavigationDestination( ),
icon: Icon(Icons.checklist_outlined), ],
selectedIcon: Icon(Icons.checklist), ),
),
const SizedBox(width: _fabGap),
Expanded(
child: Row(
children: [
Expanded(
child: _NavButton(
icon: Icons.checklist_outlined,
selectedIcon: Icons.checklist,
label: 'Lists', label: 'Lists',
selected: _index == _listsIndex,
onTap: () => _select(_listsIndex),
), ),
NavigationDestination(
icon: Icon(Icons.add),
selectedIcon: Icon(Icons.add_circle),
label: 'Add',
), ),
NavigationDestination( Expanded(
icon: Icon(Icons.receipt_long_outlined), child: _NavButton(
selectedIcon: Icon(Icons.receipt_long), icon: Icons.receipt_long_outlined,
selectedIcon: Icons.receipt_long,
label: 'Shop log', label: 'Shop log',
selected: _index == _shopLogIndex,
onTap: () => _select(_shopLogIndex),
),
),
],
),
),
],
),
),
);
}
}
class _NavButton extends StatelessWidget {
const _NavButton({
required this.icon,
required this.selectedIcon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final IconData selectedIcon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final color = selected ? scheme.primary : scheme.onSurfaceVariant;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(selected ? selectedIcon : icon, color: color, size: 20),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: color,
height: 1.1,
fontSize: 10,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
), ),
], ],
), ),
+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;
}
+8
View File
@@ -22,6 +22,14 @@ 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;
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,
+1 -1
View File
@@ -72,4 +72,4 @@ class Product {
} }
} }
enum ProductSort { name, preferred } enum ProductSort { name, preferred, favourite }
+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
+69 -16
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,10 +286,16 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? final stores =
kDefaultSupermarkets; SettingsScope.maybeOf(context)?.supermarkets ?? kDefaultSupermarkets;
return Scaffold( return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
_leave();
},
child: Scaffold(
appBar: AppBar(title: const Text('Product')), appBar: AppBar(title: const Text('Product')),
body: FutureBuilder( body: FutureBuilder(
future: _future, future: _future,
@@ -317,13 +359,15 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
Expanded( Expanded(
child: TextField( child: TextField(
controller: _nameController, controller: _nameController,
textCapitalization: TextCapitalization.sentences, textCapitalization:
TextCapitalization.sentences,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Name', labelText: 'Name',
isDense: true, isDense: true,
), ),
onSubmitted: (_) => _saveName(), onSubmitted: (_) => _saveName(),
onEditingComplete: _saveName, onEditingComplete: _saveName,
onTapOutside: (_) => _saveName(),
), ),
), ),
IconButton( IconButton(
@@ -351,7 +395,8 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
child: product.supermarket == null child: product.supermarket == null
? Text( ? Text(
'Add supermarket', 'Add supermarket',
style: theme.textTheme.labelMedium?.copyWith( style: theme.textTheme.labelMedium
?.copyWith(
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
) )
@@ -381,6 +426,15 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
onPressed: () => _pickCategory(product), onPressed: () => _pickCategory(product),
), ),
), ),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
onPressed: () => _addToList(product),
icon: const Icon(Icons.playlist_add),
label: const Text('Add to list'),
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'Price and purchase history', 'Price and purchase history',
@@ -405,7 +459,8 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
[ [
formatDate(purchase.boughtAt), formatDate(purchase.boughtAt),
purchase.supermarket, purchase.supermarket,
if (purchase.quantity != null) '${purchase.quantity}×', if (purchase.quantity != null)
'${purchase.quantity}×',
].join(' · '), ].join(' · '),
), ),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
@@ -431,6 +486,7 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
); );
}, },
), ),
),
); );
} }
} }
@@ -477,10 +533,7 @@ class _CategoryNameDialogState extends State<_CategoryNameDialog> {
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(onPressed: _save, child: const Text('Save')),
onPressed: _save,
child: const Text('Save'),
),
], ],
); );
} }
+246 -56
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(() {
_products = products;
_nameIssues = nameIssues;
_error = null;
if (products.isEmpty) {
_editing = false;
if (_searching) {
_searching = false;
_search.clear();
}
}
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
} }
void _reload() {
setState(() => _future = _load());
} }
List<Product> _filter(List<Product> products) { List<Product> _filter(List<Product> products) {
@@ -82,13 +110,98 @@ class _ProductsScreenState extends State<ProductsScreen> {
), ),
), ),
); );
if (mounted) _reload(); if (mounted) _load();
}
Future<void> _removeProduct(Product product) async {
final confirmed = await confirmAction(
context,
title: 'Delete product?',
message:
'"${product.name}" will be removed from your catalog. Trip history keeps the purchase line.',
);
if (!confirmed || !mounted) return;
setState(() {
_products = [
for (final item in _products ?? const <Product>[])
if (item.id != product.id) item,
];
if (_products!.isEmpty) {
_editing = false;
_searching = false;
_search.clear();
}
});
try {
await widget.repository.deleteProduct(product.id);
} catch (_) {
if (mounted) _load();
}
}
Future<void> _removeAllProducts() async {
final confirmed = await confirmAction(
context,
title: 'Delete all products?',
message: 'This removes every product from your catalog. Shopping trips stay in the shop log.',
);
if (!confirmed || !mounted) return;
setState(() {
_products = const [];
_editing = false;
_searching = false;
_search.clear();
});
try {
await widget.repository.deleteAllProducts();
} catch (_) {
if (mounted) _load();
}
}
Set<int> get _issueProductIds => _nameIssues.productIds;
Future<void> _resolveNameIssues({int? focusProductId}) async {
final issues = await widget.repository.getProductNameIssues();
if (!mounted || issues.isEmpty) return;
final resolved = await showProductNameConflictSheet(
context,
repository: widget.repository,
issues: issues,
focusProductId: focusProductId,
);
if (resolved && mounted) _load();
}
Future<void> _toggleFavourite(Product product) async {
final next = !product.favourite;
setState(() {
_products = [
for (final item in _products ?? const <Product>[])
if (item.id == product.id) item.copyWith(favourite: next) else item,
];
if (_sort == ProductSort.favourite && !next) {
_products = [
for (final item in _products!)
if (item.id != product.id) item,
];
if (_products!.isEmpty) _editing = false;
}
});
try {
await widget.repository.setFavourite(product.id, next);
} catch (_) {
if (mounted) _load();
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const []; final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
final products = _products == null
? const <Product>[]
: _filter(_products!);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -106,68 +219,92 @@ class _ProductsScreenState extends State<ProductsScreen> {
) )
: const Text('Products'), : const Text('Products'),
actions: [ actions: [
if (!_editing)
IconButton( IconButton(
tooltip: _searching ? 'Close search' : 'Search', tooltip: _searching ? 'Close search' : 'Search',
onPressed: () { onPressed: _hasProducts
? () {
setState(() { setState(() {
_searching = !_searching; _searching = !_searching;
if (!_searching) _search.clear(); if (!_searching) _search.clear();
}); });
}, }
: null,
icon: Icon(_searching ? Icons.close : Icons.search), icon: Icon(_searching ? Icons.close : Icons.search),
), ),
const SettingsButton(), if (_hasProducts && _nameIssues.isNotEmpty && !_editing)
IconButton(
tooltip: 'Resolve name issues',
onPressed: _resolveNameIssues,
icon: const Icon(kProductNameConflictIcon),
),
if (_hasProducts)
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() {
_editing = !_editing;
if (_editing) {
_searching = false;
_search.clear();
}
}),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
if (_editing)
IconButton(
tooltip: 'Delete all products',
onPressed: _removeAllProducts,
icon: const Icon(Icons.delete_outline),
),
if (!_editing) ...const [ProfileButton(), SettingsButton()],
], ],
), ),
body: Column( body: Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
child: Row(
children: [
Expanded(
child: SegmentedButton<ProductSort>( child: SegmentedButton<ProductSort>(
segments: const [ segments: const [
ButtonSegment(
value: ProductSort.preferred,
label: Text('Preferred'),
),
ButtonSegment( ButtonSegment(
value: ProductSort.name, value: ProductSort.name,
label: Text('A–Z'), label: Text('A–Z'),
), ),
ButtonSegment(
value: ProductSort.preferred,
label: Text('Frequent'),
),
ButtonSegment(
value: ProductSort.favourite,
icon: Icon(Icons.star),
label: Text('Star'),
),
], ],
selected: {_sort}, selected: {_sort},
onSelectionChanged: (selected) { onSelectionChanged: (selected) {
setState(() { setState(() {
_sort = selected.first; _sort = selected.first;
_future = _load();
}); });
_load();
}, },
), ),
), ),
const SizedBox(width: 8), if (_nameIssues.isNotEmpty && !_editing)
IconButton.filledTonal( ProductNameConflictBanner(
tooltip: 'Favourites', issues: _nameIssues,
isSelected: _favouritesOnly, onTap: _resolveNameIssues,
onPressed: () {
setState(() {
_favouritesOnly = !_favouritesOnly;
_future = _load();
});
},
icon: Icon(
_favouritesOnly ? Icons.star : Icons.star_outline,
),
), ),
Expanded(child: _buildList(theme, stores, products)),
], ],
), ),
), );
Expanded( }
child: FutureBuilder<List<Product>>(
future: _future, Widget _buildList(
builder: (context, snapshot) { ThemeData theme,
if (snapshot.hasError) { List<Supermarket> stores,
List<Product> products,
) {
if (_error != null && _products == null) {
return Center( return Center(
child: Text( child: Text(
'Could not load products.', 'Could not load products.',
@@ -175,15 +312,13 @@ class _ProductsScreenState extends State<ProductsScreen> {
), ),
); );
} }
if (!snapshot.hasData) { if (_products == null) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (_products!.isEmpty) {
final products = _filter(snapshot.data!);
if (snapshot.data!.isEmpty) {
return Center( return Center(
child: Text( child: Text(
_favouritesOnly _sort == ProductSort.favourite
? 'Star products to keep them as favourites.' ? 'Star products to keep them as favourites.'
: 'Confirmed receipt items will show up here.', : 'Confirmed receipt items will show up here.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
@@ -207,6 +342,7 @@ class _ProductsScreenState extends State<ProductsScreen> {
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final product = products[index]; final product = products[index];
final hasIssue = _issueProductIds.contains(product.id);
return ListTile( return ListTile(
dense: true, dense: true,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
@@ -214,11 +350,59 @@ class _ProductsScreenState extends State<ProductsScreen> {
horizontal: 8, horizontal: 8,
vertical: 0, vertical: 0,
), ),
leading: ProductImage( minLeadingWidth: _editing ? 72 : 36,
leading: _editing
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Delete',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
onPressed: () => _removeProduct(product),
icon: const Icon(Icons.delete_outline),
),
IconButton(
tooltip: product.favourite
? 'Remove favourite'
: 'Favourite',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
onPressed: () => _toggleFavourite(product),
icon: Icon(
product.favourite ? Icons.star : Icons.star_outline,
),
),
],
)
: Stack(
clipBehavior: Clip.none,
children: [
ProductImage(
name: product.name, name: product.name,
path: product.imagePath, path: product.imagePath,
size: 36, size: 36,
), ),
if (hasIssue && !_editing)
Positioned(
right: -2,
top: -2,
child: ProductNameConflictMarker(
compact: true,
onTap: () =>
_resolveNameIssues(focusProductId: product.id),
),
),
],
),
title: Text( title: Text(
product.name, product.name,
maxLines: 1, maxLines: 1,
@@ -237,19 +421,25 @@ class _ProductsScreenState extends State<ProductsScreen> {
), ),
), ),
), ),
trailing: Text( trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (hasIssue && !_editing)
Padding(
padding: const EdgeInsets.only(right: 4),
child: ProductNameConflictMarker(
onTap: () => _resolveNameIssues(focusProductId: product.id),
),
),
Text(
formatEuro(product.lastPrice), formatEuro(product.lastPrice),
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium,
), ),
onTap: () => _openProduct(product),
);
},
);
},
),
),
], ],
), ),
onTap: _editing ? null : () => _openProduct(product),
);
},
); );
} }
} }
+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,
),
],
),
);
}
}
+107 -15
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,10 @@ 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/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({
@@ -42,6 +46,9 @@ 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 = [];
@override @override
void initState() { void initState() {
@@ -57,6 +64,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 {
@@ -96,6 +115,24 @@ class _ReviewScreenState extends State<ReviewScreen> {
setState(() => _shoppedAt = picked); setState(() => _shoppedAt = picked);
} }
Future<void> _openReceipt() async {
final path = widget.imagePath;
if (path == null || path.isEmpty) return;
final file = File(path);
if (!file.existsSync()) return;
await showDialog<void>(
context: context,
builder: (context) {
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: InteractiveViewer(
child: Image.file(file, fit: BoxFit.contain),
),
);
},
);
}
Future<void> _confirm() async { Future<void> _confirm() async {
final supermarket = _resolvedSupermarket; final supermarket = _resolvedSupermarket;
if (supermarket == null) { if (supermarket == null) {
@@ -117,6 +154,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 +180,10 @@ class _ReviewScreenState extends State<ReviewScreen> {
if (_supermarket != null && !names.contains(_supermarket)) { if (_supermarket != null && !names.contains(_supermarket)) {
names.add(_supermarket!); names.add(_supermarket!);
} }
final receiptPath = widget.imagePath;
final hasReceipt = receiptPath != null &&
receiptPath.isNotEmpty &&
File(receiptPath).existsSync();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -214,16 +257,49 @@ class _ReviewScreenState extends State<ReviewScreen> {
), ),
), ),
Expanded( Expanded(
child: _items.isEmpty child: CustomScrollView(
? Center( slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _tripName,
enabled: !_saving,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Trip name',
hintText: 'Optional, e.g. Weekly shop',
),
),
const SizedBox(height: 12),
TagEditor(
tags: _tags,
suggestions: _tagSuggestions,
enabled: !_saving,
onChanged: (tags) => setState(() => _tags = tags),
),
],
),
),
),
if (_items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text( child: Text(
'No line items yet.\nAdd products for this trip.', 'No line items yet.\nAdd products for this trip.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge, style: theme.textTheme.bodyLarge,
), ),
),
) )
: ListView.separated( else
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverList.separated(
itemCount: _items.length, itemCount: _items.length,
separatorBuilder: (_, _) => const SizedBox(height: 4), separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -251,16 +327,37 @@ class _ReviewScreenState extends State<ReviewScreen> {
child: ListTile( child: ListTile(
title: Text(item.name), title: Text(item.name),
subtitle: _itemSubtitle(item), subtitle: _itemSubtitle(item),
trailing: Text( trailing: DiscountedPrice(item: item),
formatEuro(item.price),
style: theme.textTheme.titleMedium,
),
onTap: _saving ? null : () => _edit(existing: item), onTap: _saving ? null : () => _edit(existing: item),
), ),
); );
}, },
), ),
), ),
],
),
),
if (hasReceipt)
ExpansionTile(
title: const Text('See receipt'),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: GestureDetector(
onTap: _openReceipt,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(receiptPath),
fit: BoxFit.contain,
height: 220,
width: double.infinity,
),
),
),
),
],
),
if (widget.rawText.isNotEmpty) if (widget.rawText.isNotEmpty)
ExpansionTile( ExpansionTile(
title: const Text('Raw OCR text'), title: const Text('Raw OCR text'),
@@ -302,12 +399,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
} }
Widget? _itemSubtitle(LineItem item) { Widget? _itemSubtitle(LineItem item) {
final parts = [ if (item.quantity == null) return null;
if (item.quantity != null) return Text('${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}');
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
if (item.discount != 0) 'Discount ${formatEuro(item.discount.abs())}',
];
if (parts.isEmpty) return null;
return Text(parts.join(' · '));
} }
} }
+5 -2
View File
@@ -16,6 +16,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 +54,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 +69,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;
@@ -182,7 +185,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
+230 -22
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
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final token = ++_loadToken;
try {
final shop = await widget.repository.getShop(widget.shopId); final shop = await widget.repository.getShop(widget.shopId);
if (shop == null) { if (shop == null) throw StateError('Shop not found');
throw StateError('Shop not found');
}
final items = await widget.repository.getShopItems(widget.shopId); final items = await widget.repository.getShopItems(widget.shopId);
return (shop: shop, items: items); if (!mounted || token != _loadToken) return;
setState(() {
_shop = shop;
_items = items;
_error = null;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
} }
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;
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); await widget.repository.deleteShopItem(item.id);
if (mounted) _reload(); 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), const SizedBox(height: 4),
Text(shop.supermarket, style: theme.textTheme.titleMedium),
],
const SizedBox(height: 4),
if (_editing)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(formatDate(shop.shoppedAt)),
subtitle: const Text('Trip date'),
trailing: const Icon(Icons.calendar_today_outlined),
onTap: () => _editDate(shop),
)
else
Text( Text(
formatDate(shop.shoppedAt), formatDate(shop.shoppedAt),
style: theme.textTheme.bodyLarge, style: theme.textTheme.bodyLarge,
), ),
if (_editing) ...[
const SizedBox(height: 16),
TagEditor(
tags: shop.tags,
suggestions: _tagSuggestions,
onChanged: (tags) => _setTags(shop, tags),
),
] else if (shop.tags.isNotEmpty) ...[
const SizedBox(height: 12),
TagChips(tags: shop.tags),
],
if (hasReceipt) ...[ if (hasReceipt) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
GestureDetector( GestureDetector(
@@ -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(
+339 -79
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,31 +98,157 @@ 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;
setState(() {
_shops = [
for (final item in _shops ?? const <Shop>[])
if (item.id != shop.id) item,
];
_productNames = Map.of(_productNames)..remove(shop.id);
if (_shops!.isEmpty) {
_editing = false;
_searching = false;
_search.clear();
}
});
try {
await widget.repository.deleteShop(shop.id); await widget.repository.deleteShop(shop.id);
if (mounted) _reload(); } 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(
tooltip: _searching ? 'Close search' : 'Search products',
onPressed: _hasTrips
? () {
setState(() {
_searching = !_searching;
if (!_searching) _search.clear();
});
}
: null,
icon: Icon(_searching ? Icons.close : Icons.search),
),
if (_hasTrips)
IconButton( IconButton(
tooltip: _editing ? 'Done' : 'Edit', tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing), onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined), 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,
shops: _shops,
productNames: _productNames,
error: _error,
month: _month,
selectedDay: _selectedDay,
tagFilter: _tagFilter,
query: _search.text,
editing: _editing,
onPreviousMonth: () => _goMonth(-1),
onNextMonth: () => _goMonth(1),
onSelectDay: _selectDay,
onClearTag: () => setState(() => _tagFilter = null),
onTagTap: (tag) {
setState(() {
_tagFilter = _tagFilter?.toLowerCase() == tag.toLowerCase()
? null
: tag;
});
},
onOpen: _openShop,
onRemove: _removeShop,
),
);
}
}
class _ShopLogBody extends StatelessWidget {
const _ShopLogBody({
required this.theme,
required this.stores,
required this.nowMonth,
required this.shops,
required this.productNames,
required this.error,
required this.month,
required this.selectedDay,
required this.tagFilter,
required this.query,
required this.editing,
required this.onPreviousMonth,
required this.onNextMonth,
required this.onSelectDay,
required this.onClearTag,
required this.onTagTap,
required this.onOpen,
required this.onRemove,
});
final ThemeData theme;
final List<Supermarket> stores;
final DateTime nowMonth;
final List<Shop>? shops;
final Map<int, List<String>> productNames;
final Object? error;
final DateTime month;
final DateTime? selectedDay;
final String? tagFilter;
final String query;
final bool editing;
final VoidCallback onPreviousMonth;
final VoidCallback onNextMonth;
final ValueChanged<DateTime?> onSelectDay;
final VoidCallback onClearTag;
final ValueChanged<String> onTagTap;
final ValueChanged<Shop> onOpen;
final ValueChanged<Shop> onRemove;
@override
Widget build(BuildContext context) {
if (error != null && shops == null) {
return Center( return Center(
child: Text( child: Text(
'Could not load the shop log.', 'Could not load the shop log.',
@@ -87,94 +256,185 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
), ),
); );
} }
if (!snapshot.hasData) { if (shops == null) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final shops = snapshot.data!; final allShops = shops!;
if (shops.isEmpty) { final counts = tripCountsByDay([
return Center( for (final shop in allShops)
if (isSameMonth(shop.shoppedAt, month)) shop.shoppedAt,
]);
final monthShops = [
for (final shop in allShops)
if (isSameMonth(shop.shoppedAt, month)) shop,
];
final monthTotal = monthShops.fold<double>(
0,
(sum, shop) => sum + shop.total,
);
final visible = filterShops(
shops: allShops,
productNames: productNames,
selectedDay: selectedDay,
tag: tagFilter,
query: query,
);
return ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
ShopHeatmap(
month: month,
counts: counts,
monthTotal: monthTotal,
monthTripCount: monthShops.length,
canGoForward: month.isBefore(nowMonth),
selectedDay: selectedDay,
onPreviousMonth: onPreviousMonth,
onNextMonth: onNextMonth,
onSelectDay: onSelectDay,
),
if (selectedDay != null || tagFilter != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (selectedDay != null)
InputChip(
label: Text(formatDate(selectedDay!)),
onDeleted: () => onSelectDay(null),
),
if (tagFilter != null)
InputChip(label: Text(tagFilter!), onDeleted: onClearTag),
],
),
),
if (allShops.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text( child: Text(
'Scan a receipt or enter a trip to log it.', 'Scan a receipt or enter a trip to log it.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge, 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( else if (visible.isEmpty)
formatEuro(shop.total), Padding(
style: theme.textTheme.titleMedium, 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,
), ),
onTap: _editing ? null : () => _openShop(shop), )
); 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 _ReceiptThumb extends StatelessWidget { class _ShopLogTile extends StatelessWidget {
const _ReceiptThumb({this.path}); const _ShopLogTile({
required this.shop,
required this.stores,
required this.editing,
required this.onOpen,
required this.onRemove,
required this.onTagTap,
this.selectedTag,
});
final String? path; final Shop shop;
final List<Supermarket> stores;
final bool editing;
final VoidCallback onOpen;
final VoidCallback onRemove;
final ValueChanged<String> onTagTap;
final String? selectedTag;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final file = path == null ? null : File(path!); final theme = Theme.of(context);
final hasFile = file != null && file.existsSync(); final count = shop.itemCount;
final scheme = Theme.of(context).colorScheme; final hasName = shop.name != null && shop.name!.isNotEmpty;
final meta = [
formatDate(shop.shoppedAt),
count == 1 ? '1 product' : '$count products',
].join(' · ');
return ClipRRect( return Material(
color: Colors.transparent,
child: InkWell(
onTap: editing ? null : onOpen,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: SizedBox( child: Padding(
width: 48, padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
height: 48, child: Row(
child: hasFile crossAxisAlignment: CrossAxisAlignment.start,
? Image.file( children: [
file, if (editing)
fit: BoxFit.cover, IconButton(
cacheWidth: 144, tooltip: 'Remove',
errorBuilder: (_, _, _) => ColoredBox( onPressed: onRemove,
color: scheme.secondaryContainer, icon: const Icon(Icons.delete_outline),
child: Icon( ),
Icons.receipt_long_outlined, Expanded(
color: scheme.onSecondaryContainer, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasName)
Text(shop.name!, style: theme.textTheme.titleMedium)
else
StoreTag(name: shop.supermarket, stores: stores),
const SizedBox(height: 4),
if (hasName) ...[
StoreTag(
name: shop.supermarket,
stores: stores,
compact: true,
),
const SizedBox(height: 4),
],
Text(meta, style: theme.textTheme.bodyMedium),
if (shop.tags.isNotEmpty) ...[
const SizedBox(height: 8),
TagChips(
tags: shop.tags,
compact: true,
selected: selectedTag,
onTap: onTagTap,
),
],
],
), ),
), ),
) const SizedBox(width: 8),
: ColoredBox( Padding(
color: scheme.secondaryContainer, padding: const EdgeInsets.only(top: 8, right: 8),
child: Icon( child: Text(
Icons.receipt_long_outlined, formatEuro(shop.total),
color: scheme.onSecondaryContainer, style: theme.textTheme.titleMedium,
),
),
],
), ),
), ),
), ),
+192 -127
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
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final token = ++_loadToken;
try {
final list = await widget.repository.getShoppingList(widget.listId); final list = await widget.repository.getShoppingList(widget.listId);
if (list == null) { if (list == null) throw StateError('List not found');
throw StateError('List not found');
}
final items = await widget.repository.getShoppingListItems(widget.listId); final items = await widget.repository.getShoppingListItems(widget.listId);
return (list: list, items: items); if (!mounted || token != _loadToken) return;
setState(() {
_list = list;
_items = items;
_error = null;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
} }
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: result.name, name: selections[i].name,
productId: result.productId, checked: false,
sortOrder: (_items?.length ?? 0) + i,
productId: selections[i].productId,
),
];
setState(() {
_items = [...?_items, ...pending];
if (_list != null) {
_list = ShoppingList(
id: _list!.id,
name: _list!.name,
itemCount: _list!.itemCount + pending.length,
checkedCount: _list!.checkedCount,
createdAt: _list!.createdAt,
updatedAt: DateTime.now(),
); );
if (mounted) _reload(); }
});
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;
setState(() {
_items = [
for (final entry in _items ?? const <ShoppingListItem>[])
if (entry.id != item.id) entry,
];
if (_list != null) {
_list = ShoppingList(
id: _list!.id,
name: _list!.name,
itemCount: _list!.itemCount - 1,
checkedCount: item.checked
? _list!.checkedCount - 1
: _list!.checkedCount,
createdAt: _list!.createdAt,
updatedAt: DateTime.now(),
);
}
});
try {
await widget.repository.deleteShoppingListItem(item.id); await widget.repository.deleteShoppingListItem(item.id);
if (mounted) _reload(); } catch (_) {
if (mounted) _load();
}
} }
Future<void> _toggle(ShoppingListItem item) async { Future<void> _toggle(ShoppingListItem item) async {
final next = !item.checked;
setState(() {
_items = [
for (final entry in _items ?? const <ShoppingListItem>[])
if (entry.id == item.id)
ShoppingListItem(
id: entry.id,
listId: entry.listId,
name: entry.name,
checked: next,
sortOrder: entry.sortOrder,
productId: entry.productId,
)
else
entry,
];
if (_list != null) {
_list = ShoppingList(
id: _list!.id,
name: _list!.name,
itemCount: _list!.itemCount,
checkedCount: _list!.checkedCount + (next ? 1 : -1),
createdAt: _list!.createdAt,
updatedAt: _list!.updatedAt,
);
}
});
try {
await widget.repository.updateShoppingListItem( await widget.repository.updateShoppingListItem(
id: item.id, id: item.id,
checked: !item.checked, checked: next,
); );
if (mounted) _reload(); } 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,11 +270,7 @@ 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(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Scaffold( return Scaffold(
appBar: AppBar(), appBar: AppBar(),
body: Center( body: Center(
@@ -140,20 +281,25 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
), ),
); );
} }
if (!snapshot.hasData) { if (_list == null || _items == null) {
return Scaffold( return Scaffold(
appBar: AppBar(), appBar: AppBar(),
body: const Center(child: CircularProgressIndicator()), body: const Center(child: CircularProgressIndicator()),
); );
} }
final list = snapshot.data!.list; final list = _list!;
final items = snapshot.data!.items; final items = _items!;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(list.name), title: Text('${list.name} (${formatDate(list.createdAt)})'),
actions: [ actions: [
if (list.checkedCount > 0 && !_editing)
TextButton(
onPressed: _shopAgain,
child: const Text('Shop again'),
),
IconButton( IconButton(
tooltip: 'Rename', tooltip: 'Rename',
onPressed: () => _rename(list), onPressed: () => _rename(list),
@@ -186,7 +332,9 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
return ListTile( return ListTile(
leading: Checkbox( leading: Checkbox(
value: item.checked, value: item.checked,
onChanged: _editing ? null : (_) => _toggle(item), onChanged: _editing || item.id < 0
? null
: (_) => _toggle(item),
), ),
title: Text( title: Text(
item.name, item.name,
@@ -203,12 +351,16 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
children: [ children: [
IconButton( IconButton(
tooltip: 'Edit', tooltip: 'Edit',
onPressed: () => _editItem(item), onPressed: item.id < 0
? null
: () => _editItem(item),
icon: const Icon(Icons.edit_outlined), icon: const Icon(Icons.edit_outlined),
), ),
IconButton( IconButton(
tooltip: 'Remove', tooltip: 'Remove',
onPressed: () => _removeItem(item), onPressed: item.id < 0
? null
: () => _removeItem(item),
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
), ),
], ],
@@ -220,97 +372,10 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
onPressed: () => _openProduct(item.productId!), onPressed: () => _openProduct(item.productId!),
icon: const Icon(Icons.chevron_right), icon: const Icon(Icons.chevron_right),
), ),
onTap: _editing ? null : () => _toggle(item), onTap: _editing || item.id < 0 ? 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')),
],
),
);
}
}
+55 -29
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;
setState(() {
_lists = [
for (final item in _lists ?? const <ShoppingList>[])
if (item.id != list.id) item,
];
if (_lists!.isEmpty) _editing = false;
});
try {
await widget.repository.deleteShoppingList(list.id); await widget.repository.deleteShoppingList(list.id);
if (mounted) _reload(); } 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: [
if (_hasLists)
IconButton( IconButton(
tooltip: _editing ? 'Done' : 'Edit', tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing), onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined), icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
), ),
const ProfileButton(),
const SettingsButton(), const SettingsButton(),
], ],
), ),
@@ -88,42 +125,33 @@ 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(
return ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88), padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
itemCount: lists.length, itemCount: _lists!.length,
separatorBuilder: (_, _) => const SizedBox(height: 4), separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final list = lists[index]; final list = _lists![index];
final remaining = list.itemCount - list.checkedCount; final remaining = list.itemCount - list.checkedCount;
return ListTile( return ListTile(
leading: CircleAvatar(child: Text('${list.itemCount}')), leading: CircleAvatar(child: Text('${list.itemCount}')),
title: Text(list.name), title: Text('${list.name} (${formatDate(list.createdAt)})'),
subtitle: Text( subtitle: Text(
list.itemCount == 0 list.itemCount == 0
? 'Empty' ? 'Empty'
@@ -143,8 +171,6 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
onTap: _editing ? null : () => _openList(list.id), 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,
],
);
}
}
+152 -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,149 @@ 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,
), ),
); );
} }
@@ -73,6 +177,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 +201,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),
);
}
}
+86 -214
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,22 +105,63 @@ class _SettingsSheetState extends State<_SettingsSheet> {
} }
} }
Future<void> _wipeAllData(AppSettings settings) async {
final confirmed = await confirmAction(
context,
title: 'Delete all data?',
message:
'This permanently removes all products, shopping trips, lists, '
'photos, bonus cards, and settings on this device. '
'Export a backup first if you want to keep anything.',
confirmLabel: 'Delete everything',
);
if (!confirmed || !mounted) return;
setState(() => _busy = true);
try {
await settings.wipeAllData();
if (!mounted) return;
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All data deleted.')),
);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not delete all data.')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _openSupermarkets(BuildContext context, AppSettings settings) {
Navigator.of(context)
..pop()
..push(
MaterialPageRoute(
builder: (_) => SupermarketsScreen(repository: settings.repository),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = SettingsScope.of(context); final settings = SettingsScope.of(context);
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final bottom = MediaQuery.viewInsetsOf(context).bottom; final bottom = MediaQuery.viewInsetsOf(context).bottom;
final maxHeight = MediaQuery.sizeOf(context).height * 0.85;
return ListenableBuilder( return ListenableBuilder(
listenable: settings, listenable: settings,
builder: (context, _) { builder: (context, _) {
return Padding( return SizedBox(
height: maxHeight,
child: ListView(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.75,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text('Settings', style: Theme.of(context).textTheme.titleLarge), Text('Settings', style: theme.textTheme.titleLarge),
const SizedBox(height: 8), const SizedBox(height: 8),
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
@@ -129,10 +169,25 @@ class _SettingsSheetState extends State<_SettingsSheet> {
value: settings.darkMode, value: settings.darkMode,
onChanged: _busy ? null : settings.setDarkMode, onChanged: _busy ? null : settings.setDarkMode,
), ),
Text('Backup', style: Theme.of(context).textTheme.titleMedium), ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.store_outlined),
title: const Text('Supermarkets'),
subtitle: Text(
settings.supermarkets.isEmpty
? 'Names, colors, and bonus cards'
: '${settings.supermarkets.length} stores',
),
trailing: const Icon(Icons.chevron_right),
onTap: _busy
? null
: () => _openSupermarkets(context, settings),
),
const SizedBox(height: 16),
Text('Backup', style: theme.textTheme.titleMedium),
Text( Text(
'Save everything to a JSON file, or replace this device from a backup.', 'Save everything to a JSON file, or replace this device from a backup.',
style: Theme.of(context).textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
@@ -158,215 +213,32 @@ class _SettingsSheetState extends State<_SettingsSheet> {
const SizedBox(height: 8), const SizedBox(height: 8),
const LinearProgressIndicator(), const LinearProgressIndicator(),
], ],
const SizedBox(height: 16), const SizedBox(height: 32),
Text( Text(
'Supermarkets', 'Danger zone',
style: Theme.of(context).textTheme.titleMedium, style: theme.textTheme.titleMedium?.copyWith(
color: scheme.error,
), ),
),
const SizedBox(height: 4),
Text( Text(
'Name and color used as tags on products.', 'Permanently delete everything on this device.',
style: Theme.of(context).textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
), ),
const SizedBox(height: 8), const SizedBox(height: 12),
Expanded(
child: ListView(
children: [
for (final store in settings.supermarkets)
_StoreRow(
store: store,
onEdit: _busy
? () {}
: () => _editStore(context, settings, store),
onDelete: _busy || store.id == null
? null
: () => settings.deleteSupermarket(store.id!),
),
],
),
),
const SizedBox(height: 8),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: _busy style: OutlinedButton.styleFrom(
? null foregroundColor: scheme.error,
: () => _editStore(context, settings, null), side: BorderSide(color: scheme.error),
icon: const Icon(Icons.add), ),
label: const Text('Add supermarket'), onPressed: _busy ? null : () => _wipeAllData(settings),
icon: const Icon(Icons.delete_forever_outlined),
label: const Text('Delete all data'),
), ),
], ],
), ),
),
); );
}, },
); );
} }
Future<void> _editStore(
BuildContext context,
AppSettings settings,
Supermarket? existing,
) async {
final result = await showDialog<Supermarket>(
context: context,
builder: (context) => _StoreEditDialog(store: existing),
);
if (result == null) return;
if (existing == null) {
try {
await settings.addSupermarket(result.name, result.colorValue);
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('That supermarket already exists.')),
);
}
}
} else {
await settings.updateSupermarket(
existing.copyWith(name: result.name, colorValue: result.colorValue),
);
}
}
}
class _StoreRow extends StatelessWidget {
const _StoreRow({
required this.store,
required this.onEdit,
this.onDelete,
});
final Supermarket store;
final VoidCallback onEdit;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final onColor = store.color.computeLuminance() > 0.55
? Colors.black87
: Colors.white;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: store.color,
child: Text(
store.name.isEmpty ? '?' : store.name.substring(0, 1),
style: TextStyle(color: onColor, fontWeight: FontWeight.w700),
),
),
title: Text(store.name),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Delete',
onPressed: onDelete,
icon: const Icon(Icons.delete_outline),
),
],
),
);
}
}
class _StoreEditDialog extends StatefulWidget {
const _StoreEditDialog({this.store});
final Supermarket? store;
@override
State<_StoreEditDialog> createState() => _StoreEditDialogState();
}
class _StoreEditDialogState extends State<_StoreEditDialog> {
late final TextEditingController _name;
late int _color;
@override
void initState() {
super.initState();
_name = TextEditingController(text: widget.store?.name ?? '');
_color = widget.store?.colorValue ?? kStoreColorPalette.first;
}
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.store == null ? 'Add supermarket' : 'Edit supermarket'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _name,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: widget.store == null,
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: Text('Color', style: Theme.of(context).textTheme.titleMedium),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final value in kStoreColorPalette)
GestureDetector(
onTap: () => setState(() => _color = value),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Color(value),
shape: BoxShape.circle,
border: Border.all(
color: _color == value
? Theme.of(context).colorScheme.onSurface
: Colors.transparent,
width: 2,
),
),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = _name.text.trim();
if (name.isEmpty) return;
Navigator.pop(
context,
Supermarket(
id: widget.store?.id,
name: name,
colorValue: _color,
sortOrder: widget.store?.sortOrder ?? 0,
),
);
},
child: const Text('Save'),
),
],
);
}
} }
+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
+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);
});
}
+2 -1
View File
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/services/receipt_parser.dart'; import 'package:groci/services/receipt_parser.dart';
void main() { void main() {
test('parses name and price at the end of a line', () { test('parses name and price at the end of a line', () {
@@ -56,6 +56,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);
}); });
+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);
});
} }