fixes before build

This commit is contained in:
gitea
2026-08-31 16:51:31 +02:00
parent 7692809219
commit 756f9db356
34 changed files with 3542 additions and 837 deletions
+172 -2
View File
@@ -1,10 +1,14 @@
/// Dashboard of catalog, trips, lists, and this month's spend. /// Dashboard of catalog, trips, lists, and this month's spend.
library; library;
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'models/app_overview.dart'; import 'models/app_overview.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';
@@ -36,8 +40,10 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
AppOverview? _overview; AppOverview? _overview;
List<Supermarket> _stores = const [];
Object? _error; Object? _error;
int _loadToken = 0; int _loadToken = 0;
final ImagePicker _picker = ImagePicker();
@override @override
void initState() { void initState() {
@@ -56,9 +62,11 @@ class _HomeScreenState extends State<HomeScreen> {
final token = ++_loadToken; final token = ++_loadToken;
try { try {
final overview = await widget.repository.getOverview(); final overview = await widget.repository.getOverview();
final stores = await widget.repository.getSupermarkets();
if (!mounted || token != _loadToken) return; if (!mounted || token != _loadToken) return;
setState(() { setState(() {
_overview = overview; _overview = overview;
_stores = stores;
_error = null; _error = null;
}); });
} catch (error) { } catch (error) {
@@ -77,6 +85,60 @@ class _HomeScreenState extends State<HomeScreen> {
if (mounted) _load(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -97,11 +159,13 @@ class _HomeScreenState extends State<HomeScreen> {
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _HomeBody( : _HomeBody(
overview: _overview!, overview: _overview!,
stores: _stores,
onOpenProducts: widget.onOpenProducts, onOpenProducts: widget.onOpenProducts,
onOpenLists: widget.onOpenLists, onOpenLists: widget.onOpenLists,
onOpenShopLog: widget.onOpenShopLog, onOpenShopLog: widget.onOpenShopLog,
onOpenAdd: widget.onOpenAdd, onOpenAdd: widget.onOpenAdd,
onOpenShop: _openShop, onOpenShop: _openShop,
onOpenBonusCard: _openBonusCard,
), ),
); );
} }
@@ -110,24 +174,29 @@ class _HomeScreenState extends State<HomeScreen> {
class _HomeBody extends StatelessWidget { class _HomeBody extends StatelessWidget {
const _HomeBody({ const _HomeBody({
required this.overview, required this.overview,
required this.stores,
required this.onOpenProducts, required this.onOpenProducts,
required this.onOpenLists, required this.onOpenLists,
required this.onOpenShopLog, required this.onOpenShopLog,
required this.onOpenAdd, required this.onOpenAdd,
required this.onOpenShop, required this.onOpenShop,
required this.onOpenBonusCard,
}); });
final AppOverview overview; final AppOverview overview;
final List<Supermarket> stores;
final VoidCallback onOpenProducts; final VoidCallback onOpenProducts;
final VoidCallback onOpenLists; final VoidCallback onOpenLists;
final VoidCallback onOpenShopLog; final VoidCallback onOpenShopLog;
final VoidCallback onOpenAdd; final VoidCallback onOpenAdd;
final ValueChanged<Shop> onOpenShop; final ValueChanged<Shop> onOpenShop;
final ValueChanged<Supermarket> onOpenBonusCard;
@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 settingsStores =
SettingsScope.maybeOf(context)?.supermarkets ?? stores;
final empty = final empty =
overview.productCount == 0 && overview.productCount == 0 &&
overview.tripCount == 0 && overview.tripCount == 0 &&
@@ -205,6 +274,27 @@ class _HomeBody extends StatelessWidget {
onTap: onOpenLists, 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(
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) ...[ if (overview.recentShops.isNotEmpty) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
Text('Recent trips', style: theme.textTheme.titleMedium), Text('Recent trips', style: theme.textTheme.titleMedium),
@@ -215,7 +305,7 @@ class _HomeBody extends StatelessWidget {
child: ListTile( child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 8), contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: shop.name == null || shop.name!.isEmpty title: shop.name == null || shop.name!.isEmpty
? StoreTag(name: shop.supermarket, stores: stores) ? StoreTag(name: shop.supermarket, stores: settingsStores)
: Text(shop.name!), : Text(shop.name!),
subtitle: Text( subtitle: Text(
[ [
@@ -233,6 +323,86 @@ class _HomeBody extends StatelessWidget {
} }
} }
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 { class _StatCard extends StatelessWidget {
const _StatCard({ const _StatCard({
required this.icon, required this.icon,
+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, start }
+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),
); );
} }
+8
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,12 +35,17 @@ 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),
); );
} }
} }
+231 -178
View File
@@ -13,6 +13,7 @@ import 'utils/categories.dart';
import 'utils/dates.dart'; import 'utils/dates.dart';
import 'utils/money.dart'; import 'utils/money.dart';
import 'utils/supermarkets.dart'; import 'utils/supermarkets.dart';
import 'widgets/pick_shopping_list_sheet.dart';
import 'widgets/price_chart.dart'; import 'widgets/price_chart.dart';
import 'widgets/product_image.dart'; import 'widgets/product_image.dart';
import 'widgets/store_tag.dart'; import 'widgets/store_tag.dart';
@@ -37,6 +38,7 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
late final TextEditingController _notesController; late final TextEditingController _notesController;
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
bool _ready = false; bool _ready = false;
bool _leaving = false;
@override @override
void initState() { void initState() {
@@ -48,7 +50,7 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
@override @override
void dispose() { void dispose() {
_saveNotes(); _saveEdits();
_nameController.dispose(); _nameController.dispose();
_notesController.dispose(); _notesController.dispose();
super.dispose(); super.dispose();
@@ -69,7 +71,18 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
} }
void _reload() { void _reload() {
setState(() => _future = _load()); setState(() {
_future = _load();
});
}
Future<void> _saveEdits() {
final name = _nameController.text.trim();
return widget.repository.updateProduct(
id: widget.productId,
name: name.isEmpty ? null : name,
notes: _notesController.text.trim(),
);
} }
Future<void> _saveName() async { Future<void> _saveName() async {
@@ -86,6 +99,15 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
); );
} }
Future<void> _leave() async {
if (_leaving) return;
_leaving = true;
try {
await _saveEdits();
} catch (_) {}
if (mounted) Navigator.of(context).pop();
}
Future<void> _toggleFavourite(Product product) async { Future<void> _toggleFavourite(Product product) async {
await widget.repository.setFavourite(product.id, !product.favourite); await widget.repository.setFavourite(product.id, !product.favourite);
if (mounted) _reload(); if (mounted) _reload();
@@ -169,6 +191,22 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
if (mounted) _reload(); if (mounted) _reload();
} }
Future<void> _addToList(Product product) async {
final listId = await showPickShoppingListSheet(
context,
repository: widget.repository,
);
if (listId == null || !mounted) return;
await widget.repository.addShoppingListItem(
listId: listId,
name: product.name,
productId: product.id,
);
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Added to shopping list')));
}
Future<void> _pickCategory(Product product) async { Future<void> _pickCategory(Product product) async {
final used = await widget.repository.getUsedCategories(); final used = await widget.repository.getUsedCategories();
if (!mounted) return; if (!mounted) return;
@@ -238,10 +276,8 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
Future<void> _openShop(int shopId) async { Future<void> _openShop(int shopId) async {
await Navigator.of(context).push<void>( await Navigator.of(context).push<void>(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => ShopDetailScreen( builder: (_) =>
repository: widget.repository, ShopDetailScreen(repository: widget.repository, shopId: shopId),
shopId: shopId,
),
), ),
); );
if (mounted) _reload(); if (mounted) _reload();
@@ -250,186 +286,206 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? final stores =
kDefaultSupermarkets; SettingsScope.maybeOf(context)?.supermarkets ?? kDefaultSupermarkets;
return Scaffold( return PopScope(
appBar: AppBar(title: const Text('Product')), canPop: false,
body: FutureBuilder( onPopInvokedWithResult: (didPop, _) {
future: _future, if (didPop) return;
builder: (context, snapshot) { _leave();
if (snapshot.hasError) { },
return Center( child: Scaffold(
child: Text( appBar: AppBar(title: const Text('Product')),
'Could not load this product.', body: FutureBuilder(
style: theme.textTheme.bodyLarge, future: _future,
), builder: (context, snapshot) {
); if (snapshot.hasError) {
} return Center(
if (!snapshot.hasData) { child: Text(
return const Center(child: CircularProgressIndicator()); 'Could not load this product.',
} style: theme.textTheme.bodyLarge,
),
);
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final product = snapshot.data!.product; final product = snapshot.data!.product;
final purchases = snapshot.data!.purchases; final purchases = snapshot.data!.purchases;
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
GestureDetector( GestureDetector(
onTap: _chooseImageSource, onTap: _chooseImageSource,
child: Stack( child: Stack(
children: [ children: [
ProductImage( ProductImage(
name: product.name, name: product.name,
path: product.imagePath, path: product.imagePath,
size: 88, size: 88,
),
Positioned(
right: 0,
bottom: 0,
child: Material(
color: theme.colorScheme.primary,
shape: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.camera_alt,
size: 14,
color: theme.colorScheme.onPrimary,
),
),
), ),
), Positioned(
], right: 0,
), bottom: 0,
), child: Material(
const SizedBox(width: 12), color: theme.colorScheme.primary,
Expanded( shape: const CircleBorder(),
child: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.stretch, padding: const EdgeInsets.all(4),
children: [ child: Icon(
Row( Icons.camera_alt,
children: [ size: 14,
Expanded( color: theme.colorScheme.onPrimary,
child: TextField(
controller: _nameController,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Name',
isDense: true,
), ),
onSubmitted: (_) => _saveName(),
onEditingComplete: _saveName,
), ),
), ),
IconButton(
tooltip: product.favourite
? 'Remove favourite'
: 'Add favourite',
onPressed: () => _toggleFavourite(product),
icon: Icon(
product.favourite
? Icons.star
: Icons.star_outline,
color: product.favourite
? theme.colorScheme.primary
: null,
),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: InkWell(
onTap: _pickStore,
borderRadius: BorderRadius.circular(8),
child: product.supermarket == null
? Text(
'Add supermarket',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
),
)
: StoreTag(
name: product.supermarket,
stores: stores,
),
), ),
), ],
], ),
), ),
), const SizedBox(width: 12),
], Expanded(
), child: Column(
const SizedBox(height: 24), crossAxisAlignment: CrossAxisAlignment.stretch,
Text( children: [
formatEuro(product.lastPrice), Row(
style: theme.textTheme.headlineMedium, children: [
), Expanded(
Text('Price per unit', style: theme.textTheme.bodyMedium), child: TextField(
const SizedBox(height: 12), controller: _nameController,
Align( textCapitalization:
alignment: Alignment.centerLeft, TextCapitalization.sentences,
child: ActionChip( decoration: const InputDecoration(
avatar: const Icon(Icons.category_outlined, size: 18), labelText: 'Name',
label: Text(product.category ?? 'Add category'), isDense: true,
onPressed: () => _pickCategory(product), ),
onSubmitted: (_) => _saveName(),
onEditingComplete: _saveName,
onTapOutside: (_) => _saveName(),
),
),
IconButton(
tooltip: product.favourite
? 'Remove favourite'
: 'Add favourite',
onPressed: () => _toggleFavourite(product),
icon: Icon(
product.favourite
? Icons.star
: Icons.star_outline,
color: product.favourite
? theme.colorScheme.primary
: null,
),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: InkWell(
onTap: _pickStore,
borderRadius: BorderRadius.circular(8),
child: product.supermarket == null
? Text(
'Add supermarket',
style: theme.textTheme.labelMedium
?.copyWith(
color: theme.colorScheme.primary,
),
)
: StoreTag(
name: product.supermarket,
stores: stores,
),
),
),
],
),
),
],
), ),
), const SizedBox(height: 24),
const SizedBox(height: 24),
Text(
'Price and purchase history',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
if (purchases.isEmpty)
Text( Text(
'Scan a receipt to start a history.', formatEuro(product.lastPrice),
style: theme.textTheme.bodyMedium, style: theme.textTheme.headlineMedium,
)
else ...[
PriceChart(purchases: purchases),
const SizedBox(height: 8),
...purchases.map((purchase) {
return ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: const Icon(Icons.shopping_bag_outlined),
title: Text(formatEuro(purchase.unitOrLinePrice)),
subtitle: Text(
[
formatDate(purchase.boughtAt),
purchase.supermarket,
if (purchase.quantity != null) '${purchase.quantity}×',
].join(' · '),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _openShop(purchase.shopId),
);
}),
],
const SizedBox(height: 24),
Text('Notes', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
TextField(
controller: _notesController,
minLines: 3,
maxLines: 6,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
hintText: 'Add a note about this product',
), ),
onEditingComplete: _saveNotes, Text('Price per unit', style: theme.textTheme.bodyMedium),
onTapOutside: (_) => _saveNotes(), const SizedBox(height: 12),
), Align(
], alignment: Alignment.centerLeft,
); child: ActionChip(
}, avatar: const Icon(Icons.category_outlined, size: 18),
label: Text(product.category ?? 'Add category'),
onPressed: () => _pickCategory(product),
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
onPressed: () => _addToList(product),
icon: const Icon(Icons.playlist_add),
label: const Text('Add to list'),
),
),
const SizedBox(height: 24),
Text(
'Price and purchase history',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
if (purchases.isEmpty)
Text(
'Scan a receipt to start a history.',
style: theme.textTheme.bodyMedium,
)
else ...[
PriceChart(purchases: purchases),
const SizedBox(height: 8),
...purchases.map((purchase) {
return ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: const Icon(Icons.shopping_bag_outlined),
title: Text(formatEuro(purchase.unitOrLinePrice)),
subtitle: Text(
[
formatDate(purchase.boughtAt),
purchase.supermarket,
if (purchase.quantity != null)
'${purchase.quantity}×',
].join(' · '),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _openShop(purchase.shopId),
);
}),
],
const SizedBox(height: 24),
Text('Notes', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
TextField(
controller: _notesController,
minLines: 3,
maxLines: 6,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
hintText: 'Add a note about this product',
),
onEditingComplete: _saveNotes,
onTapOutside: (_) => _saveNotes(),
),
],
);
},
),
), ),
); );
} }
@@ -477,10 +533,7 @@ class _CategoryNameDialogState extends State<_CategoryNameDialog> {
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(onPressed: _save, child: const Text('Save')),
onPressed: _save,
child: const Text('Save'),
),
], ],
); );
} }
+95 -66
View File
@@ -10,8 +10,10 @@ 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/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/profile_button.dart';
import 'widgets/settings_button.dart'; import 'widgets/settings_button.dart';
import 'widgets/store_tag.dart'; import 'widgets/store_tag.dart';
@@ -27,11 +29,14 @@ 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; bool _editing = false;
final TextEditingController _search = TextEditingController(); final TextEditingController _search = TextEditingController();
List<Product>? _products; List<Product>? _products;
ProductNameIssues _nameIssues = const ProductNameIssues(
mergeGroups: [],
spellingVariants: [],
);
Object? _error; Object? _error;
int _loadToken = 0; int _loadToken = 0;
@@ -55,13 +60,12 @@ class _ProductsScreenState extends State<ProductsScreen> {
Future<void> _load() async { Future<void> _load() async {
final token = ++_loadToken; final token = ++_loadToken;
try { try {
final products = await widget.repository.getAll( final products = await widget.repository.getAll(sort: _sort);
sort: _sort, final nameIssues = await widget.repository.getProductNameIssues();
favouritesOnly: _favouritesOnly,
);
if (!mounted || token != _loadToken) return; if (!mounted || token != _loadToken) return;
setState(() { setState(() {
_products = products; _products = products;
_nameIssues = nameIssues;
_error = null; _error = null;
if (products.isEmpty) { if (products.isEmpty) {
_editing = false; _editing = false;
@@ -139,8 +143,7 @@ class _ProductsScreenState extends State<ProductsScreen> {
final confirmed = await confirmAction( final confirmed = await confirmAction(
context, context,
title: 'Delete all products?', title: 'Delete all products?',
message: message: 'This removes every product from your catalog. Shopping trips stay in the shop log.',
'This removes every product from your catalog. Shopping trips stay in the shop log.',
); );
if (!confirmed || !mounted) return; if (!confirmed || !mounted) return;
setState(() { setState(() {
@@ -156,28 +159,31 @@ class _ProductsScreenState extends State<ProductsScreen> {
} }
} }
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 { Future<void> _toggleFavourite(Product product) async {
final next = !product.favourite; final next = !product.favourite;
setState(() { setState(() {
_products = [ _products = [
for (final item in _products ?? const <Product>[]) for (final item in _products ?? const <Product>[])
if (item.id == product.id) if (item.id == product.id) item.copyWith(favourite: next) else item,
item.copyWith(favourite: next)
else
item,
]; ];
if (_favouritesOnly && !next) {
_products = [
for (final item in _products!)
if (item.id != product.id) item,
];
if (_products!.isEmpty) {
_editing = false;
}
}
}); });
try { try {
await widget.repository.setFavourite(product.id, next); await widget.repository.setFavourite(product.id, next);
if (_sort == ProductSort.start && mounted) _load();
} catch (_) { } catch (_) {
if (mounted) _load(); if (mounted) _load();
} }
@@ -187,7 +193,9 @@ class _ProductsScreenState extends State<ProductsScreen> {
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!); final products = _products == null
? const <Product>[]
: _filter(_products!);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -218,6 +226,12 @@ class _ProductsScreenState extends State<ProductsScreen> {
: null, : null,
icon: Icon(_searching ? Icons.close : Icons.search), icon: Icon(_searching ? Icons.close : Icons.search),
), ),
if (_hasProducts && _nameIssues.isNotEmpty && !_editing)
IconButton(
tooltip: 'Resolve name issues',
onPressed: _resolveNameIssues,
icon: const Icon(kProductNameConflictIcon),
),
if (_hasProducts) if (_hasProducts)
IconButton( IconButton(
tooltip: _editing ? 'Done' : 'Edit', tooltip: _editing ? 'Done' : 'Edit',
@@ -243,46 +257,35 @@ class _ProductsScreenState extends State<ProductsScreen> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
child: Row( child: SegmentedButton<ProductSort>(
children: [ segments: const [
Expanded( ButtonSegment(
child: SegmentedButton<ProductSort>( value: ProductSort.name,
segments: const [ label: Text('A–Z'),
ButtonSegment(
value: ProductSort.preferred,
label: Text('Preferred'),
),
ButtonSegment(
value: ProductSort.name,
label: Text('A–Z'),
),
],
selected: {_sort},
onSelectionChanged: (selected) {
setState(() {
_sort = selected.first;
});
_load();
},
),
), ),
const SizedBox(width: 8), ButtonSegment(
IconButton.filledTonal( value: ProductSort.preferred,
tooltip: 'Favourites', label: Text('Preferred'),
isSelected: _favouritesOnly, ),
onPressed: () { ButtonSegment(
setState(() { value: ProductSort.start,
_favouritesOnly = !_favouritesOnly; label: Text('Start'),
});
_load();
},
icon: Icon(
_favouritesOnly ? Icons.star : Icons.star_outline,
),
), ),
], ],
selected: {_sort},
onSelectionChanged: (selected) {
setState(() {
_sort = selected.first;
});
_load();
},
), ),
), ),
if (_nameIssues.isNotEmpty && !_editing)
ProductNameConflictBanner(
issues: _nameIssues,
onTap: _resolveNameIssues,
),
Expanded(child: _buildList(theme, stores, products)), Expanded(child: _buildList(theme, stores, products)),
], ],
), ),
@@ -308,9 +311,7 @@ class _ProductsScreenState extends State<ProductsScreen> {
if (_products!.isEmpty) { if (_products!.isEmpty) {
return Center( return Center(
child: Text( child: Text(
_favouritesOnly 'Confirmed receipt items will show up here.',
? 'Star products to keep them as favourites.'
: 'Confirmed receipt items will show up here.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge, style: theme.textTheme.bodyLarge,
), ),
@@ -332,6 +333,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,
@@ -372,10 +374,25 @@ class _ProductsScreenState extends State<ProductsScreen> {
), ),
], ],
) )
: ProductImage( : Stack(
name: product.name, clipBehavior: Clip.none,
path: product.imagePath, children: [
size: 36, ProductImage(
name: product.name,
path: product.imagePath,
size: 36,
),
if (hasIssue && !_editing)
Positioned(
right: -2,
top: -2,
child: ProductNameConflictMarker(
compact: true,
onTap: () =>
_resolveNameIssues(focusProductId: product.id),
),
),
],
), ),
title: Text( title: Text(
product.name, product.name,
@@ -395,9 +412,21 @@ class _ProductsScreenState extends State<ProductsScreen> {
), ),
), ),
), ),
trailing: Text( trailing: Row(
formatEuro(product.lastPrice), mainAxisSize: MainAxisSize.min,
style: theme.textTheme.titleMedium, children: [
if (hasIssue && !_editing)
Padding(
padding: const EdgeInsets.only(right: 4),
child: ProductNameConflictMarker(
onTap: () => _resolveNameIssues(focusProductId: product.id),
),
),
Text(
formatEuro(product.lastPrice),
style: theme.textTheme.titleMedium,
),
],
), ),
onTap: _editing ? null : () => _openProduct(product), onTap: _editing ? null : () => _openProduct(product),
); );
+4 -11
View File
@@ -9,6 +9,7 @@ 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'; import 'widgets/tag_editor.dart';
@@ -302,10 +303,7 @@ 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),
), ),
); );
@@ -356,12 +354,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(' · '));
} }
} }
+3 -1
View File
@@ -84,6 +84,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;
} }
} }
+1
View File
@@ -126,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);
+458 -33
View File
@@ -1,6 +1,7 @@
/// Local SQLite store of products and shopping trips. /// Local SQLite store of products and shopping trips.
library; library;
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -16,6 +17,7 @@ import '../models/shop.dart';
import '../models/shopping_list.dart'; import '../models/shopping_list.dart';
import '../models/supermarket.dart'; import '../models/supermarket.dart';
import '../utils/money.dart'; import '../utils/money.dart';
import '../utils/product_names.dart';
import '../utils/supermarkets.dart'; import '../utils/supermarkets.dart';
class ProductRepository extends ChangeNotifier { class ProductRepository extends ChangeNotifier {
@@ -35,7 +37,7 @@ class ProductRepository extends ChangeNotifier {
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db'); p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
return openDatabase( return openDatabase(
path, path,
version: 7, version: 9,
onConfigure: (db) async { onConfigure: (db) async {
await db.execute('PRAGMA foreign_keys = ON'); await db.execute('PRAGMA foreign_keys = ON');
}, },
@@ -72,6 +74,14 @@ class ProductRepository extends ChangeNotifier {
if (oldVersion < 7) { if (oldVersion < 7) {
await _upgradeShopItemsProductFk(db); await _upgradeShopItemsProductFk(db);
} }
if (oldVersion < 8) {
await db.execute(
'ALTER TABLE supermarkets ADD COLUMN bonus_image_path TEXT',
);
}
if (oldVersion < 9) {
await _upgradeProductNormalizedNamesV9(db);
}
}, },
); );
} }
@@ -107,7 +117,8 @@ class ProductRepository extends ChangeNotifier {
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
color INTEGER NOT NULL, color INTEGER NOT NULL,
sort_order INTEGER NOT NULL sort_order INTEGER NOT NULL,
bonus_image_path TEXT
) )
'''); ''');
} }
@@ -222,8 +233,28 @@ class ProductRepository extends ChangeNotifier {
'''); ''');
} }
static String normalizeName(String name) => static String normalizeName(String name, {String? supermarket}) =>
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' '); productConflictKey(name: name, supermarket: supermarket);
Future<void> _upgradeProductNormalizedNamesV9(DatabaseExecutor db) async {
final rows = await db.query('products', columns: ['id', 'name', 'supermarket']);
final used = <String>{};
for (final row in rows) {
final id = row['id']! as int;
final name = row['name']! as String;
final supermarket = row['supermarket'] as String?;
var key = normalizeName(name, supermarket: supermarket);
if (!used.add(key)) {
key = '$key#$id';
}
await db.update(
'products',
{'normalized_name': key},
where: 'id = ?',
whereArgs: [id],
);
}
}
Future<String> _persistImage(String sourcePath, String relativePath) async { Future<String> _persistImage(String sourcePath, String relativePath) async {
final dir = await getApplicationDocumentsDirectory(); final dir = await getApplicationDocumentsDirectory();
@@ -308,7 +339,7 @@ class ProductRepository extends ChangeNotifier {
required String supermarket, required String supermarket,
required String now, required String now,
}) async { }) async {
final key = normalizeName(item.name); final key = normalizeName(item.name, supermarket: supermarket);
final existing = await txn.query( final existing = await txn.query(
'products', 'products',
where: 'normalized_name = ?', where: 'normalized_name = ?',
@@ -359,12 +390,12 @@ class ProductRepository extends ChangeNotifier {
Future<List<Product>> getAll({ Future<List<Product>> getAll({
ProductSort sort = ProductSort.name, ProductSort sort = ProductSort.name,
bool favouritesOnly = false,
}) async { }) async {
final db = await _database; final db = await _database;
final orderBy = switch (sort) { final orderBy = switch (sort) {
ProductSort.name => 'name COLLATE NOCASE ASC', ProductSort.name => 'name COLLATE NOCASE ASC',
ProductSort.preferred => 'times_seen DESC, name COLLATE NOCASE ASC', ProductSort.preferred => 'times_seen DESC, name COLLATE NOCASE ASC',
ProductSort.start => 'favourite DESC, name COLLATE NOCASE ASC',
}; };
final rows = await db.rawQuery(''' final rows = await db.rawQuery('''
SELECT SELECT
@@ -378,12 +409,132 @@ class ProductRepository extends ChangeNotifier {
LIMIT 1 LIMIT 1
) AS display_price ) AS display_price
FROM products FROM products
${favouritesOnly ? 'WHERE favourite = 1' : ''}
ORDER BY $orderBy ORDER BY $orderBy
'''); ''');
return rows.map(Product.fromMap).toList(); return rows.map(Product.fromMap).toList();
} }
Future<List<({int productId, String name, String supermarket})>>
_shopLineNames() async {
final db = await _database;
final rows = await db.rawQuery('''
SELECT shop_items.product_id, shop_items.name, shops.supermarket
FROM shop_items
JOIN shops ON shops.id = shop_items.shop_id
WHERE shop_items.product_id IS NOT NULL
''');
return [
for (final row in rows)
if (_readProductId(row['product_id']) case final int id
when row['name'] is String && row['supermarket'] is String)
(
productId: id,
name: row['name']! as String,
supermarket: row['supermarket']! as String,
),
];
}
int? _readProductId(Object? value) {
if (value is int) return value;
if (value is num) return value.toInt();
return null;
}
Future<ProductNameIssues> getProductNameIssues() async {
final products = await getAll();
return buildProductNameIssues(
products: products,
shopLines: await _shopLineNames(),
dismissedKeys: await getDismissedNameIssues(),
);
}
Future<Set<String>> getDismissedNameIssues() async {
final db = await _database;
final rows = await db.query(
'app_settings',
where: 'key = ?',
whereArgs: ['dismissed_name_issues'],
limit: 1,
);
if (rows.isEmpty) return {};
final raw = rows.first['value'] as String?;
if (raw == null || raw.isEmpty) return {};
try {
final decoded = jsonDecode(raw) as List<dynamic>;
return decoded.map((entry) => entry as String).toSet();
} catch (_) {
return {};
}
}
Future<void> dismissNameIssue(String key) async {
final dismissed = await getDismissedNameIssues()
..add(key);
final db = await _database;
await db.insert('app_settings', {
'key': 'dismissed_name_issues',
'value': jsonEncode(dismissed.toList()..sort()),
}, conflictAlgorithm: ConflictAlgorithm.replace);
notifyListeners();
}
Future<List<List<Product>>> getNameConflictGroups() async {
return [
for (final group in (await getProductNameIssues()).mergeGroups)
group.products,
];
}
Future<List<ProductSpellingVariant>> getProductSpellingVariants() async {
return (await getProductNameIssues()).spellingVariants;
}
/// Picks one spelling for a catalog product at a store.
Future<void> unifyProductSpelling({
required int productId,
required String name,
String? supermarket,
}) async {
final trimmed = name.trim();
final key = normalizeName(trimmed, supermarket: supermarket);
final db = await _database;
final now = DateTime.now().toIso8601String();
final storeKey = supermarket == null
? null
: normalizeSupermarket(supermarket);
await db.transaction((txn) async {
await txn.update(
'products',
{'name': trimmed, 'normalized_name': key, 'updated_at': now},
where: 'id = ?',
whereArgs: [productId],
);
final rows = await txn.rawQuery('''
SELECT shop_items.id, shop_items.name
FROM shop_items
JOIN shops ON shops.id = shop_items.shop_id
WHERE shop_items.product_id = ?
${storeKey == null ? '' : 'AND upper(trim(shops.supermarket)) = ?'}
''', storeKey == null ? [productId] : [productId, storeKey]);
for (final row in rows) {
final lineName = row['name'] as String?;
final lineId = row['id'];
if (lineName == null || lineId == null) continue;
if (normalizeName(lineName, supermarket: supermarket) != key) continue;
await txn.update(
'shop_items',
{'name': trimmed},
where: 'id = ?',
whereArgs: [lineId],
);
}
});
notifyListeners();
}
Future<AppOverview> getOverview() async { Future<AppOverview> getOverview() async {
final db = await _database; final db = await _database;
final productCount = final productCount =
@@ -399,7 +550,9 @@ class ProductRepository extends ChangeNotifier {
) ?? ) ??
0; 0;
final tripCount = final tripCount =
Sqflite.firstIntValue(await db.rawQuery('SELECT COUNT(*) FROM shops')) ?? Sqflite.firstIntValue(
await db.rawQuery('SELECT COUNT(*) FROM shops'),
) ??
0; 0;
final listCount = final listCount =
Sqflite.firstIntValue( Sqflite.firstIntValue(
@@ -480,13 +633,28 @@ class ProductRepository extends ChangeNotifier {
bool clearCategory = false, bool clearCategory = false,
}) async { }) async {
final db = await _database; final db = await _database;
final existing = await db.query(
'products',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (existing.isEmpty) return;
final row = existing.first;
final nextName = name?.trim() ?? row['name']! as String;
final nextSupermarket = clearSupermarket
? null
: (supermarket ?? row['supermarket'] as String?);
final values = <String, Object?>{ final values = <String, Object?>{
'updated_at': DateTime.now().toIso8601String(), 'updated_at': DateTime.now().toIso8601String(),
'normalized_name': normalizeName(
nextName,
supermarket: nextSupermarket,
),
}; };
if (name != null) { if (name != null) values['name'] = nextName;
values['name'] = name.trim();
values['normalized_name'] = normalizeName(name);
}
if (favourite != null) values['favourite'] = favourite ? 1 : 0; if (favourite != null) values['favourite'] = favourite ? 1 : 0;
if (clearSupermarket) { if (clearSupermarket) {
values['supermarket'] = null; values['supermarket'] = null;
@@ -565,6 +733,145 @@ class ProductRepository extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Moves trip lines and list items onto [keepId], then deletes the rest.
Future<void> mergeProducts({
required int keepId,
required List<int> sourceIds,
}) async {
final mergeIds = [
for (final id in sourceIds)
if (id != keepId) id,
];
if (mergeIds.isEmpty) return;
final db = await _database;
final keepRows = await db.query(
'products',
where: 'id = ?',
whereArgs: [keepId],
limit: 1,
);
if (keepRows.isEmpty) return;
final keep = Product.fromMap(keepRows.first);
final losers = <Product>[];
for (final id in mergeIds) {
final rows = await db.query(
'products',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isNotEmpty) losers.add(Product.fromMap(rows.first));
}
if (losers.isEmpty) return;
var imagePath = keep.imagePath;
final unusedImages = <String?>[];
for (final loser in losers) {
final path = loser.imagePath;
if ((imagePath == null || imagePath.isEmpty) &&
path != null &&
path.isNotEmpty) {
imagePath = path;
} else {
unusedImages.add(path);
}
}
var notes = keep.notes?.trim();
for (final loser in losers) {
final extra = loser.notes?.trim();
if (extra == null || extra.isEmpty) continue;
if (notes == null || notes.isEmpty) {
notes = extra;
} else if (notes != extra) {
notes = '$notes\n$extra';
}
}
final favourite = keep.favourite || losers.any((item) => item.favourite);
final supermarket = (keep.supermarket == null || keep.supermarket!.isEmpty)
? losers
.map((item) => item.supermarket)
.firstWhere(
(value) => value != null && value.isNotEmpty,
orElse: () => keep.supermarket,
)
: keep.supermarket;
final category = (keep.category == null || keep.category!.isEmpty)
? losers
.map((item) => item.category)
.firstWhere(
(value) => value != null && value.isNotEmpty,
orElse: () => keep.category,
)
: keep.category;
await db.transaction((txn) async {
for (final id in mergeIds) {
await txn.update(
'shop_items',
{'product_id': keepId},
where: 'product_id = ?',
whereArgs: [id],
);
await txn.update(
'shopping_list_items',
{'product_id': keepId},
where: 'product_id = ?',
whereArgs: [id],
);
await txn.delete('products', where: 'id = ?', whereArgs: [id]);
}
final seen = Sqflite.firstIntValue(
await txn.rawQuery(
'SELECT COUNT(*) FROM shop_items WHERE product_id = ?',
[keepId],
),
);
final priceRows = await txn.rawQuery(
'''
SELECT COALESCE(shop_items.unit_price, shop_items.price) AS price
FROM shop_items
JOIN shops ON shops.id = shop_items.shop_id
WHERE shop_items.product_id = ?
ORDER BY shops.shopped_at DESC, shop_items.id DESC
LIMIT 1
''',
[keepId],
);
final lastPrice = priceRows.isEmpty
? keep.lastPrice
: (priceRows.first['price'] as num).toDouble();
await txn.update(
'products',
{
'normalized_name': normalizeName(
keep.name,
supermarket: keep.supermarket,
),
'times_seen': seen ?? keep.timesSeen,
'last_price': lastPrice,
'favourite': favourite ? 1 : 0,
'image_path': imagePath,
'supermarket': supermarket,
'notes': (notes == null || notes.isEmpty) ? null : notes,
'category': category,
'updated_at': DateTime.now().toIso8601String(),
},
where: 'id = ?',
whereArgs: [keepId],
);
});
for (final path in unusedImages) {
await _tryDeleteFile(path);
}
notifyListeners();
}
Future<void> _tryDeleteFile(String? path) async { Future<void> _tryDeleteFile(String? path) async {
if (path == null || path.isEmpty) return; if (path == null || path.isEmpty) return;
try { try {
@@ -691,17 +998,20 @@ class ProductRepository extends ChangeNotifier {
required int id, required int id,
String? name, String? name,
bool clearName = false, bool clearName = false,
DateTime? shoppedAt,
List<String>? tags, List<String>? tags,
}) async { }) async {
final db = await _database; final db = await _database;
await db.transaction((txn) async { await db.transaction((txn) async {
final values = <String, Object?>{};
if (clearName || name != null) { if (clearName || name != null) {
await txn.update( values['name'] = clearName ? null : _optionalText(name);
'shops', }
{'name': clearName ? null : _optionalText(name)}, if (shoppedAt != null) {
where: 'id = ?', values['shopped_at'] = shoppedAt.toIso8601String();
whereArgs: [id], }
); if (values.isNotEmpty) {
await txn.update('shops', values, where: 'id = ?', whereArgs: [id]);
} }
if (tags != null) { if (tags != null) {
await _replaceShopTags(txn, shopId: id, tags: tags); await _replaceShopTags(txn, shopId: id, tags: tags);
@@ -760,6 +1070,51 @@ class ProductRepository extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Adds a line to an existing trip and upserts the product catalog entry.
Future<ShopItem> addShopItem({
required int shopId,
required LineItem item,
}) async {
final db = await _database;
final shopRows = await db.query(
'shops',
where: 'id = ?',
whereArgs: [shopId],
limit: 1,
);
if (shopRows.isEmpty) throw StateError('Shop not found');
final supermarket = shopRows.first['supermarket']! as String;
final now = DateTime.now().toIso8601String();
final itemId = await db.transaction((txn) async {
final productId = await _upsertProduct(
txn,
item: item,
supermarket: supermarket,
now: now,
);
return txn.insert('shop_items', {
'shop_id': shopId,
'product_id': productId,
'name': item.name.trim(),
'price': item.price,
'quantity': item.quantity,
'unit_price': item.unitPrice,
});
});
await _recalculateShopTotal(shopId);
notifyListeners();
final rows = await db.query(
'shop_items',
where: 'id = ?',
whereArgs: [itemId],
limit: 1,
);
return ShopItem.fromMap(rows.first);
}
/// Removes one trip line. Catalog products are left unchanged. /// Removes one trip line. Catalog products are left unchanged.
Future<void> deleteShopItem(int id) async { Future<void> deleteShopItem(int id) async {
final db = await _database; final db = await _database;
@@ -825,6 +1180,7 @@ class ProductRepository extends ChangeNotifier {
SELECT SELECT
shopping_lists.id, shopping_lists.id,
shopping_lists.name, shopping_lists.name,
shopping_lists.created_at,
shopping_lists.updated_at, shopping_lists.updated_at,
COUNT(shopping_list_items.id) AS item_count, COUNT(shopping_list_items.id) AS item_count,
COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count
@@ -844,6 +1200,7 @@ class ProductRepository extends ChangeNotifier {
SELECT SELECT
shopping_lists.id, shopping_lists.id,
shopping_lists.name, shopping_lists.name,
shopping_lists.created_at,
shopping_lists.updated_at, shopping_lists.updated_at,
COUNT(shopping_list_items.id) AS item_count, COUNT(shopping_list_items.id) AS item_count,
COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count
@@ -882,6 +1239,18 @@ class ProductRepository extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> resetShoppingListChecks(int listId) async {
final db = await _database;
await db.update(
'shopping_list_items',
{'checked': 0},
where: 'list_id = ? AND checked = 1',
whereArgs: [listId],
);
await _touchShoppingList(listId);
notifyListeners();
}
Future<void> deleteShoppingList(int id) async { Future<void> deleteShoppingList(int id) async {
final db = await _database; final db = await _database;
await db.transaction((txn) async { await db.transaction((txn) async {
@@ -911,23 +1280,72 @@ class ProductRepository extends ChangeNotifier {
required String name, required String name,
int? productId, int? productId,
}) async { }) async {
final db = await _database; final added = await addShoppingListItems(
final max = Sqflite.firstIntValue( listId: listId,
await db.rawQuery( items: [(name: name, productId: productId)],
'SELECT MAX(sort_order) FROM shopping_list_items WHERE list_id = ?',
[listId],
),
); );
final id = await db.insert('shopping_list_items', { return added.single.id;
'list_id': listId, }
'product_id': productId,
'name': name.trim(), Future<List<ShoppingListItem>> addShoppingListItems({
'checked': 0, required int listId,
'sort_order': (max ?? -1) + 1, required List<({String name, int? productId})> items,
}) async {
if (items.isEmpty) return const [];
final db = await _database;
final added = <ShoppingListItem>[];
await db.transaction((txn) async {
var max = Sqflite.firstIntValue(
await txn.rawQuery(
'SELECT MAX(sort_order) FROM shopping_list_items WHERE list_id = ?',
[listId],
),
);
var sortOrder = (max ?? -1) + 1;
for (final item in items) {
final name = item.name.trim();
if (name.isEmpty) continue;
final id = await txn.insert('shopping_list_items', {
'list_id': listId,
'product_id': item.productId,
'name': name,
'checked': 0,
'sort_order': sortOrder++,
});
added.add(
ShoppingListItem(
id: id,
listId: listId,
name: name,
checked: false,
sortOrder: sortOrder - 1,
productId: item.productId,
),
);
}
await txn.update(
'shopping_lists',
{'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [listId],
);
}); });
await _touchShoppingList(listId);
notifyListeners(); notifyListeners();
return id; return added;
}
Future<String?> setSupermarketBonusCard(int id, String sourcePath) async {
final stored = await _persistImage(sourcePath, 'bonus_cards/store_$id.jpg');
final db = await _database;
await db.update(
'supermarkets',
{'bonus_image_path': stored},
where: 'id = ?',
whereArgs: [id],
);
notifyListeners();
return stored;
} }
Future<void> updateShoppingListItem({ Future<void> updateShoppingListItem({
@@ -1057,6 +1475,7 @@ class ProductRepository extends ChangeNotifier {
'name': store.name.trim(), 'name': store.name.trim(),
'color': store.colorValue, 'color': store.colorValue,
'sort_order': store.sortOrder, 'sort_order': store.sortOrder,
'bonus_image_path': store.bonusImagePath,
}, },
where: 'id = ?', where: 'id = ?',
whereArgs: [id], whereArgs: [id],
@@ -1166,7 +1585,13 @@ class ProductRepository extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
static const _supermarketColumns = {'id', 'name', 'color', 'sort_order'}; static const _supermarketColumns = {
'id',
'name',
'color',
'sort_order',
'bonus_image_path',
};
static const _productColumns = { static const _productColumns = {
'id', 'id',
'name', 'name',
+170 -32
View File
@@ -31,15 +31,18 @@ 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 = []; List<String> _tagSuggestions = [];
int _loadToken = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_future = _load(); widget.repository.addListener(_load);
widget.repository.addListener(_reload); _load();
widget.repository widget.repository
.getUsedShopTags() .getUsedShopTags()
.then((tags) { .then((tags) {
@@ -50,22 +53,26 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
@override @override
void dispose() { void dispose() {
widget.repository.removeListener(_reload); widget.repository.removeListener(_load);
super.dispose(); super.dispose();
} }
Future<({Shop shop, List<ShopItem> items})> _load() async { Future<void> _load() async {
final shop = await widget.repository.getShop(widget.shopId); final token = ++_loadToken;
if (shop == null) { try {
throw StateError('Shop not found'); final shop = await widget.repository.getShop(widget.shopId);
if (shop == null) throw StateError('Shop not found');
final items = await widget.repository.getShopItems(widget.shopId);
if (!mounted || token != _loadToken) return;
setState(() {
_shop = shop;
_items = items;
_error = null;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
} }
final items = await widget.repository.getShopItems(widget.shopId);
return (shop: shop, items: items);
}
void _reload() {
if (!mounted) return;
setState(() => _future = _load());
} }
Future<void> _openReceipt(File file) async { Future<void> _openReceipt(File file) async {
@@ -93,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,
@@ -112,7 +169,7 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
quantity: result.quantity, quantity: result.quantity,
unitPrice: result.unitPrice, unitPrice: result.unitPrice,
); );
if (mounted) _reload(); if (mounted) _load();
} }
Future<void> _removeItem(ShopItem item) async { Future<void> _removeItem(ShopItem item) async {
@@ -124,8 +181,59 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
confirmLabel: 'Remove', confirmLabel: 'Remove',
); );
if (!confirmed || !mounted) return; if (!confirmed || !mounted) return;
await widget.repository.deleteShopItem(item.id);
if (mounted) _reload(); final isLastItem = (_items?.length ?? 0) == 1;
final shop = _shop;
setState(() {
_items = [
for (final entry in _items ?? const <ShopItem>[])
if (entry.id != item.id) entry,
];
if (shop != null) {
_shop = Shop(
id: shop.id,
supermarket: shop.supermarket,
shoppedAt: shop.shoppedAt,
total: roundMoney(shop.total - item.price),
itemCount: shop.itemCount - 1,
name: shop.name,
receiptImagePath: shop.receiptImagePath,
tags: shop.tags,
);
}
});
try {
await widget.repository.deleteShopItem(item.id);
if (!mounted || !isLastItem || shop == null) return;
await _confirmRemoveEmptyTrip(shop);
} catch (_) {
if (mounted) _load();
}
}
Future<void> _confirmRemoveEmptyTrip(Shop shop) async {
final removeTrip = await confirmAction(
context,
title: 'Remove trip?',
message:
'This trip has no products left. Remove it from your shop log? '
'Products from this trip stay in your catalog.',
confirmLabel: 'Remove trip',
);
if (!mounted) return;
if (!removeTrip) return;
try {
await widget.repository.deleteShop(shop.id);
if (mounted) Navigator.of(context).pop();
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not remove this trip.')),
);
_load();
}
} }
Future<void> _editName(Shop shop) async { Future<void> _editName(Shop shop) async {
@@ -142,12 +250,27 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
name: name, name: name,
clearName: name.isEmpty, clearName: name.isEmpty,
); );
if (mounted) _reload(); 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 { Future<void> _setTags(Shop shop, List<String> tags) async {
await widget.repository.updateShop(id: shop.id, tags: tags); await widget.repository.updateShop(id: shop.id, tags: tags);
if (mounted) _reload(); if (mounted) _load();
} }
Widget? _itemSubtitle(ShopItem item) { Widget? _itemSubtitle(ShopItem item) {
@@ -175,10 +298,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.',
@@ -186,18 +315,18 @@ 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: [
if (_editing) if (_editing)
ListTile( ListTile(
@@ -218,10 +347,19 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
Text(shop.supermarket, style: theme.textTheme.titleMedium), Text(shop.supermarket, style: theme.textTheme.titleMedium),
], ],
const SizedBox(height: 4), const SizedBox(height: 4),
Text( if (_editing)
formatDate(shop.shoppedAt), ListTile(
style: theme.textTheme.bodyLarge, contentPadding: EdgeInsets.zero,
), title: Text(formatDate(shop.shoppedAt)),
subtitle: const Text('Trip date'),
trailing: const Icon(Icons.calendar_today_outlined),
onTap: () => _editDate(shop),
)
else
Text(
formatDate(shop.shoppedAt),
style: theme.textTheme.bodyLarge,
),
if (_editing) ...[ if (_editing) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
TagEditor( TagEditor(
+6 -7
View File
@@ -95,12 +95,14 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
final confirmed = await confirmAction( final confirmed = await confirmAction(
context, context,
title: 'Remove shopping trip?', title: 'Remove shopping trip?',
message: message: 'This trip will be removed from the shop log. Products from this trip stay in your catalog.',
'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(() { setState(() {
_shops = [for (final item in _shops ?? const <Shop>[]) if (item.id != shop.id) item]; _shops = [
for (final item in _shops ?? const <Shop>[])
if (item.id != shop.id) item,
];
_productNames = Map.of(_productNames)..remove(shop.id); _productNames = Map.of(_productNames)..remove(shop.id);
if (_shops!.isEmpty) { if (_shops!.isEmpty) {
_editing = false; _editing = false;
@@ -306,10 +308,7 @@ class _ShopLogBody extends StatelessWidget {
onDeleted: () => onSelectDay(null), onDeleted: () => onSelectDay(null),
), ),
if (tagFilter != null) if (tagFilter != null)
InputChip( InputChip(label: Text(tagFilter!), onDeleted: onClearTag),
label: Text(tagFilter!),
onDeleted: onClearTag,
),
], ],
), ),
), ),
+278 -221
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,34 +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);
widget.repository.addListener(_reload); _load();
} }
@override @override
void dispose() { void dispose() {
widget.repository.removeListener(_reload); widget.repository.removeListener(_load);
super.dispose(); super.dispose();
} }
Future<({ShoppingList list, List<ShoppingListItem> items})> _load() async { Future<void> _load() async {
final list = await widget.repository.getShoppingList(widget.listId); final token = ++_loadToken;
if (list == null) { try {
throw StateError('List not found'); final list = await widget.repository.getShoppingList(widget.listId);
if (list == null) throw StateError('List not found');
final items = await widget.repository.getShoppingListItems(widget.listId);
if (!mounted || token != _loadToken) return;
setState(() {
_list = list;
_items = items;
_error = null;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
} }
final items = await widget.repository.getShoppingListItems(widget.listId);
return (list: list, items: items);
}
void _reload() {
if (!mounted) return;
setState(() => _future = _load());
} }
Future<void> _rename(ShoppingList list) async { Future<void> _rename(ShoppingList list) async {
@@ -65,25 +73,70 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
); );
if (name == null || !mounted) return; if (name == null || !mounted) return;
await widget.repository.renameShoppingList(widget.listId, name); await widget.repository.renameShoppingList(widget.listId, name);
if (mounted) _reload(); if (mounted) {
setState(
() => _list = ShoppingList(
id: list.id,
name: name,
itemCount: list.itemCount,
checkedCount: list.checkedCount,
createdAt: list.createdAt,
updatedAt: DateTime.now(),
),
);
_load();
}
} }
Future<void> _openAddSheet() async { Future<void> _openAddSheet() async {
final products = await widget.repository.getAll(); final products = await widget.repository.getAll();
if (!mounted) return; if (!mounted) return;
final result = await showModalBottomSheet<({String name, int? productId})>( final selections = await showAddListItemsSheet(context, products: products);
context: context, if (selections == null || selections.isEmpty || !mounted) return;
isScrollControlled: true,
showDragHandle: true, final pending = [
builder: (context) => _AddListItemSheet(products: products), for (var i = 0; i < selections.length; i++)
); ShoppingListItem(
if (result == null || !mounted) return; id: -(i + 1),
await widget.repository.addShoppingListItem( listId: widget.listId,
listId: widget.listId, name: selections[i].name,
name: result.name, checked: false,
productId: result.productId, sortOrder: (_items?.length ?? 0) + i,
); productId: selections[i].productId,
if (mounted) _reload(); ),
];
setState(() {
_items = [...?_items, ...pending];
if (_list != null) {
_list = ShoppingList(
id: _list!.id,
name: _list!.name,
itemCount: _list!.itemCount + pending.length,
checkedCount: _list!.checkedCount,
createdAt: _list!.createdAt,
updatedAt: DateTime.now(),
);
}
});
try {
final added = await widget.repository.addShoppingListItems(
listId: widget.listId,
items: [
for (final selection in selections)
(name: selection.name, productId: selection.productId),
],
);
if (!mounted) return;
setState(() {
final withoutPending = _items
?.where((item) => item.id >= 0)
.toList(growable: false);
_items = [...?withoutPending, ...added];
});
} catch (_) {
if (mounted) _load();
}
} }
Future<void> _editItem(ShoppingListItem item) async { Future<void> _editItem(ShoppingListItem item) async {
@@ -95,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 {
@@ -106,16 +159,100 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
confirmLabel: 'Remove', confirmLabel: 'Remove',
); );
if (!confirmed || !mounted) return; if (!confirmed || !mounted) return;
await widget.repository.deleteShoppingListItem(item.id); setState(() {
if (mounted) _reload(); _items = [
for (final entry in _items ?? const <ShoppingListItem>[])
if (entry.id != item.id) entry,
];
if (_list != null) {
_list = ShoppingList(
id: _list!.id,
name: _list!.name,
itemCount: _list!.itemCount - 1,
checkedCount: item.checked
? _list!.checkedCount - 1
: _list!.checkedCount,
createdAt: _list!.createdAt,
updatedAt: DateTime.now(),
);
}
});
try {
await widget.repository.deleteShoppingListItem(item.id);
} catch (_) {
if (mounted) _load();
}
} }
Future<void> _toggle(ShoppingListItem item) async { Future<void> _toggle(ShoppingListItem item) async {
await widget.repository.updateShoppingListItem( final next = !item.checked;
id: item.id, setState(() {
checked: !item.checked, _items = [
); for (final entry in _items ?? const <ShoppingListItem>[])
if (mounted) _reload(); if (entry.id == item.id)
ShoppingListItem(
id: entry.id,
listId: entry.listId,
name: entry.name,
checked: next,
sortOrder: entry.sortOrder,
productId: entry.productId,
)
else
entry,
];
if (_list != null) {
_list = ShoppingList(
id: _list!.id,
name: _list!.name,
itemCount: _list!.itemCount,
checkedCount: _list!.checkedCount + (next ? 1 : -1),
createdAt: _list!.createdAt,
updatedAt: _list!.updatedAt,
);
}
});
try {
await widget.repository.updateShoppingListItem(
id: item.id,
checked: next,
);
} catch (_) {
if (mounted) _load();
}
}
Future<void> _shopAgain() async {
final list = _list;
if (list == null || list.checkedCount == 0) return;
setState(() {
_items = [
for (final item in _items ?? const <ShoppingListItem>[])
ShoppingListItem(
id: item.id,
listId: item.listId,
name: item.name,
checked: false,
sortOrder: item.sortOrder,
productId: item.productId,
),
];
_list = ShoppingList(
id: list.id,
name: list.name,
itemCount: list.itemCount,
checkedCount: 0,
createdAt: list.createdAt,
updatedAt: DateTime.now(),
);
});
try {
await widget.repository.resetShoppingListChecks(widget.listId);
} catch (_) {
if (mounted) _load();
}
} }
Future<void> _openProduct(int productId) async { Future<void> _openProduct(int productId) async {
@@ -133,192 +270,112 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( if (_error != null && _list == null) {
body: FutureBuilder( return Scaffold(
future: _future, appBar: AppBar(),
builder: (context, snapshot) { body: Center(
if (snapshot.hasError) { child: Text(
return Scaffold( 'Could not load this list.',
appBar: AppBar(), style: theme.textTheme.bodyLarge,
body: Center( ),
child: Text( ),
'Could not load this list.', );
style: theme.textTheme.bodyLarge, }
), if (_list == null || _items == null) {
), return Scaffold(
); appBar: AppBar(),
} body: const Center(child: CircularProgressIndicator()),
if (!snapshot.hasData) { );
return Scaffold( }
appBar: AppBar(),
body: const Center(child: CircularProgressIndicator()), final list = _list!;
); final items = _items!;
}
return Scaffold(
final list = snapshot.data!.list; appBar: AppBar(
final items = snapshot.data!.items; title: Text('${list.name} (${formatDate(list.createdAt)})'),
actions: [
return Scaffold( if (list.checkedCount > 0 && !_editing)
appBar: AppBar( TextButton(
title: Text(list.name), onPressed: _shopAgain,
actions: [ child: const Text('Shop again'),
IconButton( ),
tooltip: 'Rename', IconButton(
onPressed: () => _rename(list), tooltip: 'Rename',
icon: const Icon(Icons.drive_file_rename_outline), onPressed: () => _rename(list),
), icon: const Icon(Icons.drive_file_rename_outline),
IconButton( ),
tooltip: _editing ? 'Done' : 'Edit', IconButton(
onPressed: () => setState(() => _editing = !_editing), tooltip: _editing ? 'Done' : 'Edit',
icon: Icon(_editing ? Icons.check : Icons.edit_outlined), onPressed: () => setState(() => _editing = !_editing),
), icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _openAddSheet,
tooltip: 'Add item',
child: const Icon(Icons.add),
),
body: items.isEmpty
? Center(
child: Text(
'Add things to buy.',
style: theme.textTheme.bodyLarge,
),
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
leading: Checkbox(
value: item.checked,
onChanged: _editing ? null : (_) => _toggle(item),
),
title: Text(
item.name,
style: item.checked
? theme.textTheme.bodyLarge?.copyWith(
decoration: TextDecoration.lineThrough,
color: theme.colorScheme.onSurfaceVariant,
)
: null,
),
trailing: _editing
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
onPressed: () => _editItem(item),
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Remove',
onPressed: () => _removeItem(item),
icon: const Icon(Icons.delete_outline),
),
],
)
: item.productId == null
? null
: IconButton(
tooltip: 'Product',
onPressed: () => _openProduct(item.productId!),
icon: const Icon(Icons.chevron_right),
),
onTap: _editing ? null : () => _toggle(item),
);
},
),
);
},
),
);
}
}
class _AddListItemSheet extends StatefulWidget {
const _AddListItemSheet({required this.products});
final List<Product> products;
@override
State<_AddListItemSheet> createState() => _AddListItemSheetState();
}
class _AddListItemSheetState extends State<_AddListItemSheet> {
final TextEditingController _controller = TextEditingController();
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
List<Product> get _matches {
final query = _controller.text.trim().toLowerCase();
if (query.isEmpty) {
return widget.products.take(8).toList();
}
return widget.products
.where((product) => product.name.toLowerCase().contains(query))
.take(8)
.toList();
}
void _add({String? name, int? productId}) {
final value = (name ?? _controller.text).trim();
if (value.isEmpty) {
setState(() => _error = 'Enter an item name.');
return;
}
Navigator.of(context).pop((name: value, productId: productId));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
final matches = _matches;
return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Add item', style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
TextField(
controller: _controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(labelText: 'Item name'),
onChanged: (_) => setState(() => _error = null),
onSubmitted: (_) => _add(),
), ),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
],
if (matches.isNotEmpty) ...[
const SizedBox(height: 12),
Text('From products', style: theme.textTheme.labelMedium),
const SizedBox(height: 4),
for (final product in matches)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(product.name),
onTap: () => _add(name: product.name, productId: product.id),
),
],
const SizedBox(height: 8),
FilledButton(onPressed: () => _add(), child: const Text('Add')),
], ],
), ),
floatingActionButton: FloatingActionButton(
onPressed: _openAddSheet,
tooltip: 'Add item',
child: const Icon(Icons.add),
),
body: items.isEmpty
? Center(
child: Text(
'Add things to buy.',
style: theme.textTheme.bodyLarge,
),
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
leading: Checkbox(
value: item.checked,
onChanged: _editing || item.id < 0
? null
: (_) => _toggle(item),
),
title: Text(
item.name,
style: item.checked
? theme.textTheme.bodyLarge?.copyWith(
decoration: TextDecoration.lineThrough,
color: theme.colorScheme.onSurfaceVariant,
)
: null,
),
trailing: _editing
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
onPressed: item.id < 0
? null
: () => _editItem(item),
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Remove',
onPressed: item.id < 0
? null
: () => _removeItem(item),
icon: const Icon(Icons.delete_outline),
),
],
)
: item.productId == null
? null
: IconButton(
tooltip: 'Product',
onPressed: () => _openProduct(item.productId!),
icon: const Icon(Icons.chevron_right),
),
onTap: _editing || item.id < 0 ? null : () => _toggle(item),
);
},
),
); );
} }
} }
+6 -2
View File
@@ -6,6 +6,7 @@ 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/profile_button.dart';
@@ -88,7 +89,10 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
); );
if (!confirmed || !mounted) return; if (!confirmed || !mounted) return;
setState(() { setState(() {
_lists = [for (final item in _lists ?? const <ShoppingList>[]) if (item.id != list.id) item]; _lists = [
for (final item in _lists ?? const <ShoppingList>[])
if (item.id != list.id) item,
];
if (_lists!.isEmpty) _editing = false; if (_lists!.isEmpty) _editing = false;
}); });
try { try {
@@ -147,7 +151,7 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
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'
+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),
+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,
);
}
+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,
],
);
}
}
+3
View File
@@ -66,6 +66,9 @@ class _ItemEditSheetState extends State<ItemEditSheet> {
unitPrice: quantity != null && quantity > 0 unitPrice: quantity != null && quantity > 0
? roundMoney(rounded / quantity) ? roundMoney(rounded / quantity)
: widget.item?.unitPrice, : widget.item?.unitPrice,
discount: widget.item?.price == rounded
? widget.item?.discount ?? 0
: 0,
), ),
); );
} }
+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'),
),
],
),
],
),
),
);
}
}
+3 -3
View File
@@ -13,9 +13,9 @@ class ProfileButton extends StatelessWidget {
return IconButton( return IconButton(
tooltip: 'Profile', tooltip: 'Profile',
onPressed: () { onPressed: () {
Navigator.of(context).push<void>( Navigator.of(
MaterialPageRoute(builder: (_) => const ProfileScreen()), context,
); ).push<void>(MaterialPageRoute(builder: (_) => const ProfileScreen()));
}, },
icon: const Icon(Icons.person_outline), icon: const Icon(Icons.person_outline),
); );
+67 -250
View File
@@ -3,9 +3,9 @@ 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';
class SettingsButton extends StatelessWidget { class SettingsButton extends StatelessWidget {
const SettingsButton({super.key}); const SettingsButton({super.key});
@@ -47,9 +47,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 +91,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,6 +104,16 @@ class _SettingsSheetState extends State<_SettingsSheet> {
} }
} }
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);
@@ -116,257 +124,66 @@ class _SettingsSheetState extends State<_SettingsSheet> {
builder: (context, _) { builder: (context, _) {
return Padding( return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: SizedBox( child: Column(
height: MediaQuery.sizeOf(context).height * 0.75, mainAxisSize: MainAxisSize.min,
child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ Text('Settings', style: Theme.of(context).textTheme.titleLarge),
Text('Settings', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8),
const SizedBox(height: 8), SwitchListTile(
SwitchListTile( contentPadding: EdgeInsets.zero,
contentPadding: EdgeInsets.zero, title: const Text('Dark mode'),
title: const Text('Dark mode'), value: settings.darkMode,
value: settings.darkMode, onChanged: _busy ? null : settings.setDarkMode,
onChanged: _busy ? null : settings.setDarkMode, ),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.store_outlined),
title: const Text('Supermarkets'),
subtitle: Text(
settings.supermarkets.isEmpty
? 'Names, colors, and bonus cards'
: '${settings.supermarkets.length} stores',
), ),
Text('Backup', style: Theme.of(context).textTheme.titleMedium), trailing: const Icon(Icons.chevron_right),
Text( onTap: _busy
'Save everything to a JSON file, or replace this device from a backup.', ? null
style: Theme.of(context).textTheme.bodyMedium, : () => _openSupermarkets(context, settings),
), ),
const SizedBox(height: 8), const SizedBox(height: 16),
Row( Text('Backup', style: Theme.of(context).textTheme.titleMedium),
children: [ Text(
Expanded( 'Save everything to a JSON file, or replace this device from a backup.',
child: OutlinedButton.icon( style: Theme.of(context).textTheme.bodyMedium,
onPressed: _busy ? null : () => _export(settings), ),
icon: const Icon(Icons.file_upload_outlined), const SizedBox(height: 8),
label: const Text('Export'), Row(
), children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _export(settings),
icon: const Icon(Icons.file_upload_outlined),
label: const Text('Export'),
), ),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _import(settings),
icon: const Icon(Icons.file_download_outlined),
label: const Text('Import'),
),
),
],
),
if (_busy) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(),
],
const SizedBox(height: 16),
Text(
'Supermarkets',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'Name and color used as tags on products.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Expanded(
child: ListView(
children: [
for (final store in settings.supermarkets)
_StoreRow(
store: store,
onEdit: _busy
? () {}
: () => _editStore(context, settings, store),
onDelete: _busy || store.id == null
? null
: () => settings.deleteSupermarket(store.id!),
),
],
), ),
), const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _import(settings),
icon: const Icon(Icons.file_download_outlined),
label: const Text('Import'),
),
),
],
),
if (_busy) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
OutlinedButton.icon( const LinearProgressIndicator(),
onPressed: _busy
? null
: () => _editStore(context, settings, null),
icon: const Icon(Icons.add),
label: const Text('Add supermarket'),
),
], ],
), ],
), ),
); );
}, },
); );
} }
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'),
),
],
);
}
} }
+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'),
),
],
);
}
}
+35 -9
View File
@@ -6,8 +6,29 @@ import 'test_helpers.dart';
void main() { void main() {
ensureTestSqlite(); ensureTestSqlite();
test('deleting a product keeps the trip line and drops the catalog row', test(
() async { '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 repository = memoryRepository();
final shopId = await repository.saveShop( final shopId = await repository.saveShop(
supermarket: 'Jumbo', supermarket: 'Jumbo',
@@ -15,15 +36,20 @@ void main() {
items: [LineItem(id: '1', name: 'Milk', price: 1.5)], items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
); );
final products = await repository.getAll(); await repository.addShopItem(
expect(products, hasLength(1)); shopId: shopId,
await repository.deleteProduct(products.single.id); item: LineItem(id: '2', name: 'Bread', price: 2),
);
expect(await repository.getAll(), isEmpty); final shop = (await repository.getShop(shopId))!;
expect(shop.total, 3.5);
final items = await repository.getShopItems(shopId); final items = await repository.getShopItems(shopId);
expect(items, hasLength(1)); expect(items, hasLength(2));
expect(items.single.name, 'Milk'); expect(items.map((item) => item.name), ['Milk', 'Bread']);
expect(items.single.productId, isNull); expect((await repository.getAll()).map((product) => product.name).toSet(), {
'Milk',
'Bread',
});
}); });
test('deleting all products leaves shopping trips in place', () async { test('deleting all products leaves shopping trips in place', () async {
+4 -1
View File
@@ -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,
);
}); });
} }
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/models/line_item.dart';
import 'package:receipity/models/product.dart';
import 'package:receipity/utils/product_names.dart';
import 'test_helpers.dart';
Product _product(int id, String name, {String? supermarket}) => Product(
id: id,
name: name,
lastPrice: 1,
timesSeen: 1,
updatedAt: DateTime(2026, 1, 1),
supermarket: supermarket,
);
void main() {
ensureTestSqlite();
group('nameConflictGroups', () {
test('groups products that differ only by case at the same store', () {
final groups = nameConflictGroups([
_product(1, 'Broccoli', supermarket: 'Jumbo'),
_product(2, 'BROCCOLI', supermarket: 'Jumbo'),
_product(3, 'Milk', supermarket: 'Jumbo'),
]);
expect(groups, hasLength(1));
expect(groups.single.map((product) => product.id), [1, 2]);
});
test('ignores same name at different stores', () {
final groups = nameConflictGroups([
_product(1, 'Broccoli', supermarket: 'Jumbo'),
_product(2, 'BROCCOLI', supermarket: 'Lidl'),
]);
expect(groups, isEmpty);
});
});
group('buildNameConflictGroups', () {
test('finds conflicts from trip lines linked to different products', () {
final groups = buildNameConflictGroups(
products: [
_product(1, 'Broccoli', supermarket: 'Jumbo'),
_product(2, 'BROCCOLI', supermarket: 'Jumbo'),
],
shopLines: const [
(productId: 1, name: 'Broccoli', supermarket: 'Jumbo'),
(productId: 2, name: 'BROCCOLI', supermarket: 'Jumbo'),
],
);
expect(groups, hasLength(1));
expect(groups.single.products.map((product) => product.id), [1, 2]);
expect(groups.single.supermarket, 'Jumbo');
});
});
group('findSpellingVariants', () {
test('finds multiple receipt spellings for one product at one store', () {
final variants = findSpellingVariants(
products: [_product(1, 'Broccoli', supermarket: 'Jumbo')],
shopLines: const [
(productId: 1, name: 'Broccoli', supermarket: 'Jumbo'),
(productId: 1, name: 'BROCCOLI', supermarket: 'Jumbo'),
],
);
expect(variants, hasLength(1));
expect(variants.single.product.id, 1);
expect(variants.single.supermarket, 'Jumbo');
expect(variants.single.variants, ['Broccoli', 'BROCCOLI']);
});
test('ignores different spellings at different stores', () {
final variants = findSpellingVariants(
products: [_product(1, 'Broccoli', supermarket: 'Jumbo')],
shopLines: const [
(productId: 1, name: 'Broccoli', supermarket: 'Jumbo'),
(productId: 1, name: 'BROCCOLI', supermarket: 'Lidl'),
],
);
expect(variants, isEmpty);
});
});
test('same name at different supermarkets creates separate products', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Lidl',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'Milk', price: 1.2)],
);
final products = await repository.getAll();
expect(products, hasLength(2));
expect(products.map((p) => p.supermarket).toSet(), {'Jumbo', 'Lidl'});
expect(await repository.getProductNameIssues(), isEmpty);
});
test('getNameConflictGroups finds duplicates in the database', () async {
final repository = memoryRepository();
await seedCaseConflictCatalog(repository);
final groups = await repository.getNameConflictGroups();
expect(groups, hasLength(1));
expect(groups.single.map((product) => product.name).toSet(), {
'Broccoli',
'BROCCOLI',
});
});
test('getProductNameIssues finds receipt spelling variants', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)],
);
final issues = await repository.getProductNameIssues();
expect(issues.mergeGroups, isEmpty);
expect(issues.spellingVariants, hasLength(1));
expect(issues.spellingVariants.single.supermarket, 'Jumbo');
expect(issues.spellingVariants.single.variants, ['Broccoli', 'BROCCOLI']);
});
test('dismissNameIssue hides a spelling variant', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)],
);
final issue =
(await repository.getProductNameIssues()).spellingVariants.single;
await repository.dismissNameIssue(issue.key);
expect(await repository.getProductNameIssues(), isEmpty);
});
test('mergeProducts combines purchases onto the kept product', () async {
final repository = memoryRepository();
await seedCaseConflictCatalog(repository);
await repository.mergeProducts(keepId: 1, sourceIds: [2]);
final after = await repository.getAll();
expect(after, hasLength(1));
expect(after.single.name, 'Broccoli');
expect(after.single.timesSeen, 2);
expect(await repository.getPurchases(1), hasLength(2));
});
test('unifyProductSpelling updates catalog and receipt lines', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)],
);
final product = (await repository.getAll()).single;
await repository.unifyProductSpelling(
productId: product.id,
name: 'BROCCOLI',
supermarket: 'Jumbo',
);
final updated = (await repository.getProduct(product.id))!;
expect(updated.name, 'BROCCOLI');
expect((await repository.getProductNameIssues()).isEmpty, isTrue);
});
}
+1
View File
@@ -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);
}); });
+67
View File
@@ -39,3 +39,70 @@ Future<void> pumpUntilFound(
} }
throw TestFailure('Timed out waiting for $finder'); 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,
},
],
);
}
+111
View File
@@ -232,4 +232,115 @@ void main() {
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(ReceipityApp(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(ReceipityApp(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(ReceipityApp(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(ReceipityApp(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);
});
} }