fixes before build
This commit is contained in:
+172
-2
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -72,4 +72,4 @@ class Product {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ProductSort { name, preferred }
|
enum ProductSort { name, preferred, start }
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'utils/categories.dart';
|
|||||||
import 'utils/dates.dart';
|
import 'utils/dates.dart';
|
||||||
import 'utils/money.dart';
|
import 'utils/money.dart';
|
||||||
import 'utils/supermarkets.dart';
|
import 'utils/supermarkets.dart';
|
||||||
|
import 'widgets/pick_shopping_list_sheet.dart';
|
||||||
import 'widgets/price_chart.dart';
|
import 'widgets/price_chart.dart';
|
||||||
import 'widgets/product_image.dart';
|
import 'widgets/product_image.dart';
|
||||||
import 'widgets/store_tag.dart';
|
import 'widgets/store_tag.dart';
|
||||||
@@ -37,6 +38,7 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
late final TextEditingController _notesController;
|
late final TextEditingController _notesController;
|
||||||
final ImagePicker _picker = ImagePicker();
|
final ImagePicker _picker = ImagePicker();
|
||||||
bool _ready = false;
|
bool _ready = false;
|
||||||
|
bool _leaving = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -48,7 +50,7 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_saveNotes();
|
_saveEdits();
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_notesController.dispose();
|
_notesController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -69,7 +71,18 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _reload() {
|
void _reload() {
|
||||||
setState(() => _future = _load());
|
setState(() {
|
||||||
|
_future = _load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveEdits() {
|
||||||
|
final name = _nameController.text.trim();
|
||||||
|
return widget.repository.updateProduct(
|
||||||
|
id: widget.productId,
|
||||||
|
name: name.isEmpty ? null : name,
|
||||||
|
notes: _notesController.text.trim(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveName() async {
|
Future<void> _saveName() async {
|
||||||
@@ -86,6 +99,15 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _leave() async {
|
||||||
|
if (_leaving) return;
|
||||||
|
_leaving = true;
|
||||||
|
try {
|
||||||
|
await _saveEdits();
|
||||||
|
} catch (_) {}
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _toggleFavourite(Product product) async {
|
Future<void> _toggleFavourite(Product product) async {
|
||||||
await widget.repository.setFavourite(product.id, !product.favourite);
|
await widget.repository.setFavourite(product.id, !product.favourite);
|
||||||
if (mounted) _reload();
|
if (mounted) _reload();
|
||||||
@@ -169,6 +191,22 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
if (mounted) _reload();
|
if (mounted) _reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _addToList(Product product) async {
|
||||||
|
final listId = await showPickShoppingListSheet(
|
||||||
|
context,
|
||||||
|
repository: widget.repository,
|
||||||
|
);
|
||||||
|
if (listId == null || !mounted) return;
|
||||||
|
await widget.repository.addShoppingListItem(
|
||||||
|
listId: listId,
|
||||||
|
name: product.name,
|
||||||
|
productId: product.id,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(const SnackBar(content: Text('Added to shopping list')));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _pickCategory(Product product) async {
|
Future<void> _pickCategory(Product product) async {
|
||||||
final used = await widget.repository.getUsedCategories();
|
final used = await widget.repository.getUsedCategories();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -238,10 +276,8 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
Future<void> _openShop(int shopId) async {
|
Future<void> _openShop(int shopId) async {
|
||||||
await Navigator.of(context).push<void>(
|
await Navigator.of(context).push<void>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => ShopDetailScreen(
|
builder: (_) =>
|
||||||
repository: widget.repository,
|
ShopDetailScreen(repository: widget.repository, shopId: shopId),
|
||||||
shopId: shopId,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (mounted) _reload();
|
if (mounted) _reload();
|
||||||
@@ -250,10 +286,16 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final stores = SettingsScope.maybeOf(context)?.supermarkets ??
|
final stores =
|
||||||
kDefaultSupermarkets;
|
SettingsScope.maybeOf(context)?.supermarkets ?? kDefaultSupermarkets;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
|
canPop: false,
|
||||||
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
if (didPop) return;
|
||||||
|
_leave();
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
appBar: AppBar(title: const Text('Product')),
|
appBar: AppBar(title: const Text('Product')),
|
||||||
body: FutureBuilder(
|
body: FutureBuilder(
|
||||||
future: _future,
|
future: _future,
|
||||||
@@ -317,13 +359,15 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
textCapitalization: TextCapitalization.sentences,
|
textCapitalization:
|
||||||
|
TextCapitalization.sentences,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Name',
|
labelText: 'Name',
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _saveName(),
|
onSubmitted: (_) => _saveName(),
|
||||||
onEditingComplete: _saveName,
|
onEditingComplete: _saveName,
|
||||||
|
onTapOutside: (_) => _saveName(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -351,7 +395,8 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
child: product.supermarket == null
|
child: product.supermarket == null
|
||||||
? Text(
|
? Text(
|
||||||
'Add supermarket',
|
'Add supermarket',
|
||||||
style: theme.textTheme.labelMedium?.copyWith(
|
style: theme.textTheme.labelMedium
|
||||||
|
?.copyWith(
|
||||||
color: theme.colorScheme.primary,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -381,6 +426,15 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
onPressed: () => _pickCategory(product),
|
onPressed: () => _pickCategory(product),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: FilledButton.tonalIcon(
|
||||||
|
onPressed: () => _addToList(product),
|
||||||
|
icon: const Icon(Icons.playlist_add),
|
||||||
|
label: const Text('Add to list'),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'Price and purchase history',
|
'Price and purchase history',
|
||||||
@@ -405,7 +459,8 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
[
|
[
|
||||||
formatDate(purchase.boughtAt),
|
formatDate(purchase.boughtAt),
|
||||||
purchase.supermarket,
|
purchase.supermarket,
|
||||||
if (purchase.quantity != null) '${purchase.quantity}×',
|
if (purchase.quantity != null)
|
||||||
|
'${purchase.quantity}×',
|
||||||
].join(' · '),
|
].join(' · '),
|
||||||
),
|
),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
@@ -431,6 +486,7 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -477,10 +533,7 @@ class _CategoryNameDialogState extends State<_CategoryNameDialog> {
|
|||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||||
onPressed: _save,
|
|
||||||
child: const Text('Save'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-47
@@ -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,18 +257,19 @@ 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(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: SegmentedButton<ProductSort>(
|
child: SegmentedButton<ProductSort>(
|
||||||
segments: const [
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: ProductSort.name,
|
||||||
|
label: Text('A–Z'),
|
||||||
|
),
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: ProductSort.preferred,
|
value: ProductSort.preferred,
|
||||||
label: Text('Preferred'),
|
label: Text('Preferred'),
|
||||||
),
|
),
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: ProductSort.name,
|
value: ProductSort.start,
|
||||||
label: Text('A–Z'),
|
label: Text('Start'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
selected: {_sort},
|
selected: {_sort},
|
||||||
@@ -266,22 +281,10 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
if (_nameIssues.isNotEmpty && !_editing)
|
||||||
IconButton.filledTonal(
|
ProductNameConflictBanner(
|
||||||
tooltip: 'Favourites',
|
issues: _nameIssues,
|
||||||
isSelected: _favouritesOnly,
|
onTap: _resolveNameIssues,
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_favouritesOnly = !_favouritesOnly;
|
|
||||||
});
|
|
||||||
_load();
|
|
||||||
},
|
|
||||||
icon: Icon(
|
|
||||||
_favouritesOnly ? Icons.star : Icons.star_outline,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
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,11 +374,26 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: ProductImage(
|
: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
ProductImage(
|
||||||
name: product.name,
|
name: product.name,
|
||||||
path: product.imagePath,
|
path: product.imagePath,
|
||||||
size: 36,
|
size: 36,
|
||||||
),
|
),
|
||||||
|
if (hasIssue && !_editing)
|
||||||
|
Positioned(
|
||||||
|
right: -2,
|
||||||
|
top: -2,
|
||||||
|
child: ProductNameConflictMarker(
|
||||||
|
compact: true,
|
||||||
|
onTap: () =>
|
||||||
|
_resolveNameIssues(focusProductId: product.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
product.name,
|
product.name,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -395,10 +412,22 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: Text(
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (hasIssue && !_editing)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 4),
|
||||||
|
child: ProductNameConflictMarker(
|
||||||
|
onTap: () => _resolveNameIssues(focusProductId: product.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
formatEuro(product.lastPrice),
|
formatEuro(product.lastPrice),
|
||||||
style: theme.textTheme.titleMedium,
|
style: theme.textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
onTap: _editing ? null : () => _openProduct(product),
|
onTap: _editing ? null : () => _openProduct(product),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-11
@@ -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(' · '));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 added = await addShoppingListItems(
|
||||||
|
listId: listId,
|
||||||
|
items: [(name: name, productId: productId)],
|
||||||
|
);
|
||||||
|
return added.single.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<ShoppingListItem>> addShoppingListItems({
|
||||||
|
required int listId,
|
||||||
|
required List<({String name, int? productId})> items,
|
||||||
|
}) async {
|
||||||
|
if (items.isEmpty) return const [];
|
||||||
|
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final max = Sqflite.firstIntValue(
|
final added = <ShoppingListItem>[];
|
||||||
await db.rawQuery(
|
await db.transaction((txn) async {
|
||||||
|
var max = Sqflite.firstIntValue(
|
||||||
|
await txn.rawQuery(
|
||||||
'SELECT MAX(sort_order) FROM shopping_list_items WHERE list_id = ?',
|
'SELECT MAX(sort_order) FROM shopping_list_items WHERE list_id = ?',
|
||||||
[listId],
|
[listId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final id = await db.insert('shopping_list_items', {
|
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,
|
'list_id': listId,
|
||||||
'product_id': productId,
|
'product_id': item.productId,
|
||||||
'name': name.trim(),
|
'name': name,
|
||||||
'checked': 0,
|
'checked': 0,
|
||||||
'sort_order': (max ?? -1) + 1,
|
'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',
|
||||||
|
|||||||
+163
-25
@@ -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 token = ++_loadToken;
|
||||||
|
try {
|
||||||
final shop = await widget.repository.getShop(widget.shopId);
|
final shop = await widget.repository.getShop(widget.shopId);
|
||||||
if (shop == null) {
|
if (shop == null) throw StateError('Shop not found');
|
||||||
throw StateError('Shop not found');
|
|
||||||
}
|
|
||||||
final items = await widget.repository.getShopItems(widget.shopId);
|
final items = await widget.repository.getShopItems(widget.shopId);
|
||||||
return (shop: shop, items: items);
|
if (!mounted || token != _loadToken) return;
|
||||||
|
setState(() {
|
||||||
|
_shop = shop;
|
||||||
|
_items = items;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted || token != _loadToken) return;
|
||||||
|
setState(() => _error = error);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reload() {
|
|
||||||
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;
|
||||||
|
|
||||||
|
final isLastItem = (_items?.length ?? 0) == 1;
|
||||||
|
final shop = _shop;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_items = [
|
||||||
|
for (final entry in _items ?? const <ShopItem>[])
|
||||||
|
if (entry.id != item.id) entry,
|
||||||
|
];
|
||||||
|
if (shop != null) {
|
||||||
|
_shop = Shop(
|
||||||
|
id: shop.id,
|
||||||
|
supermarket: shop.supermarket,
|
||||||
|
shoppedAt: shop.shoppedAt,
|
||||||
|
total: roundMoney(shop.total - item.price),
|
||||||
|
itemCount: shop.itemCount - 1,
|
||||||
|
name: shop.name,
|
||||||
|
receiptImagePath: shop.receiptImagePath,
|
||||||
|
tags: shop.tags,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
await widget.repository.deleteShopItem(item.id);
|
await widget.repository.deleteShopItem(item.id);
|
||||||
if (mounted) _reload();
|
if (!mounted || !isLastItem || shop == null) return;
|
||||||
|
await _confirmRemoveEmptyTrip(shop);
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) _load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmRemoveEmptyTrip(Shop shop) async {
|
||||||
|
final removeTrip = await confirmAction(
|
||||||
|
context,
|
||||||
|
title: 'Remove trip?',
|
||||||
|
message:
|
||||||
|
'This trip has no products left. Remove it from your shop log? '
|
||||||
|
'Products from this trip stay in your catalog.',
|
||||||
|
confirmLabel: 'Remove trip',
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!removeTrip) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await widget.repository.deleteShop(shop.id);
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Could not remove this trip.')),
|
||||||
|
);
|
||||||
|
_load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _editName(Shop shop) async {
|
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,6 +347,15 @@ 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),
|
||||||
|
if (_editing)
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(formatDate(shop.shoppedAt)),
|
||||||
|
subtitle: const Text('Trip date'),
|
||||||
|
trailing: const Icon(Icons.calendar_today_outlined),
|
||||||
|
onTap: () => _editDate(shop),
|
||||||
|
)
|
||||||
|
else
|
||||||
Text(
|
Text(
|
||||||
formatDate(shop.shoppedAt),
|
formatDate(shop.shoppedAt),
|
||||||
style: theme.textTheme.bodyLarge,
|
style: theme.textTheme.bodyLarge,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 token = ++_loadToken;
|
||||||
|
try {
|
||||||
final list = await widget.repository.getShoppingList(widget.listId);
|
final list = await widget.repository.getShoppingList(widget.listId);
|
||||||
if (list == null) {
|
if (list == null) throw StateError('List not found');
|
||||||
throw StateError('List not found');
|
|
||||||
}
|
|
||||||
final items = await widget.repository.getShoppingListItems(widget.listId);
|
final items = await widget.repository.getShoppingListItems(widget.listId);
|
||||||
return (list: list, items: items);
|
if (!mounted || token != _loadToken) return;
|
||||||
|
setState(() {
|
||||||
|
_list = list;
|
||||||
|
_items = items;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted || token != _loadToken) return;
|
||||||
|
setState(() => _error = error);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reload() {
|
|
||||||
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: result.name,
|
name: selections[i].name,
|
||||||
productId: result.productId,
|
checked: false,
|
||||||
|
sortOrder: (_items?.length ?? 0) + i,
|
||||||
|
productId: selections[i].productId,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
setState(() {
|
||||||
|
_items = [...?_items, ...pending];
|
||||||
|
if (_list != null) {
|
||||||
|
_list = ShoppingList(
|
||||||
|
id: _list!.id,
|
||||||
|
name: _list!.name,
|
||||||
|
itemCount: _list!.itemCount + pending.length,
|
||||||
|
checkedCount: _list!.checkedCount,
|
||||||
|
createdAt: _list!.createdAt,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
if (mounted) _reload();
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final added = await widget.repository.addShoppingListItems(
|
||||||
|
listId: widget.listId,
|
||||||
|
items: [
|
||||||
|
for (final selection in selections)
|
||||||
|
(name: selection.name, productId: selection.productId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
final withoutPending = _items
|
||||||
|
?.where((item) => item.id >= 0)
|
||||||
|
.toList(growable: false);
|
||||||
|
_items = [...?withoutPending, ...added];
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) _load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _editItem(ShoppingListItem item) async {
|
Future<void> _editItem(ShoppingListItem item) async {
|
||||||
@@ -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;
|
||||||
|
setState(() {
|
||||||
|
_items = [
|
||||||
|
for (final entry in _items ?? const <ShoppingListItem>[])
|
||||||
|
if (entry.id != item.id) entry,
|
||||||
|
];
|
||||||
|
if (_list != null) {
|
||||||
|
_list = ShoppingList(
|
||||||
|
id: _list!.id,
|
||||||
|
name: _list!.name,
|
||||||
|
itemCount: _list!.itemCount - 1,
|
||||||
|
checkedCount: item.checked
|
||||||
|
? _list!.checkedCount - 1
|
||||||
|
: _list!.checkedCount,
|
||||||
|
createdAt: _list!.createdAt,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
await widget.repository.deleteShoppingListItem(item.id);
|
await widget.repository.deleteShoppingListItem(item.id);
|
||||||
if (mounted) _reload();
|
} catch (_) {
|
||||||
|
if (mounted) _load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggle(ShoppingListItem item) async {
|
Future<void> _toggle(ShoppingListItem item) async {
|
||||||
|
final next = !item.checked;
|
||||||
|
setState(() {
|
||||||
|
_items = [
|
||||||
|
for (final entry in _items ?? const <ShoppingListItem>[])
|
||||||
|
if (entry.id == item.id)
|
||||||
|
ShoppingListItem(
|
||||||
|
id: entry.id,
|
||||||
|
listId: entry.listId,
|
||||||
|
name: entry.name,
|
||||||
|
checked: next,
|
||||||
|
sortOrder: entry.sortOrder,
|
||||||
|
productId: entry.productId,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
entry,
|
||||||
|
];
|
||||||
|
if (_list != null) {
|
||||||
|
_list = ShoppingList(
|
||||||
|
id: _list!.id,
|
||||||
|
name: _list!.name,
|
||||||
|
itemCount: _list!.itemCount,
|
||||||
|
checkedCount: _list!.checkedCount + (next ? 1 : -1),
|
||||||
|
createdAt: _list!.createdAt,
|
||||||
|
updatedAt: _list!.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
await widget.repository.updateShoppingListItem(
|
await widget.repository.updateShoppingListItem(
|
||||||
id: item.id,
|
id: item.id,
|
||||||
checked: !item.checked,
|
checked: next,
|
||||||
);
|
);
|
||||||
if (mounted) _reload();
|
} catch (_) {
|
||||||
|
if (mounted) _load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _shopAgain() async {
|
||||||
|
final list = _list;
|
||||||
|
if (list == null || list.checkedCount == 0) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_items = [
|
||||||
|
for (final item in _items ?? const <ShoppingListItem>[])
|
||||||
|
ShoppingListItem(
|
||||||
|
id: item.id,
|
||||||
|
listId: item.listId,
|
||||||
|
name: item.name,
|
||||||
|
checked: false,
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
productId: item.productId,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
_list = ShoppingList(
|
||||||
|
id: list.id,
|
||||||
|
name: list.name,
|
||||||
|
itemCount: list.itemCount,
|
||||||
|
checkedCount: 0,
|
||||||
|
createdAt: list.createdAt,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await widget.repository.resetShoppingListChecks(widget.listId);
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) _load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openProduct(int productId) async {
|
Future<void> _openProduct(int productId) async {
|
||||||
@@ -133,11 +270,7 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
if (_error != null && _list == null) {
|
||||||
body: FutureBuilder(
|
|
||||||
future: _future,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.hasError) {
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(),
|
appBar: AppBar(),
|
||||||
body: Center(
|
body: Center(
|
||||||
@@ -148,20 +281,25 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!snapshot.hasData) {
|
if (_list == null || _items == null) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(),
|
appBar: AppBar(),
|
||||||
body: const Center(child: CircularProgressIndicator()),
|
body: const Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final list = snapshot.data!.list;
|
final list = _list!;
|
||||||
final items = snapshot.data!.items;
|
final items = _items!;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(list.name),
|
title: Text('${list.name} (${formatDate(list.createdAt)})'),
|
||||||
actions: [
|
actions: [
|
||||||
|
if (list.checkedCount > 0 && !_editing)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _shopAgain,
|
||||||
|
child: const Text('Shop again'),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Rename',
|
tooltip: 'Rename',
|
||||||
onPressed: () => _rename(list),
|
onPressed: () => _rename(list),
|
||||||
@@ -194,7 +332,9 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
|
|||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Checkbox(
|
leading: Checkbox(
|
||||||
value: item.checked,
|
value: item.checked,
|
||||||
onChanged: _editing ? null : (_) => _toggle(item),
|
onChanged: _editing || item.id < 0
|
||||||
|
? null
|
||||||
|
: (_) => _toggle(item),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
item.name,
|
item.name,
|
||||||
@@ -211,12 +351,16 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
|
|||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Edit',
|
tooltip: 'Edit',
|
||||||
onPressed: () => _editItem(item),
|
onPressed: item.id < 0
|
||||||
|
? null
|
||||||
|
: () => _editItem(item),
|
||||||
icon: const Icon(Icons.edit_outlined),
|
icon: const Icon(Icons.edit_outlined),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Remove',
|
tooltip: 'Remove',
|
||||||
onPressed: () => _removeItem(item),
|
onPressed: item.id < 0
|
||||||
|
? null
|
||||||
|
: () => _removeItem(item),
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -228,97 +372,10 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
|
|||||||
onPressed: () => _openProduct(item.productId!),
|
onPressed: () => _openProduct(item.productId!),
|
||||||
icon: const Icon(Icons.chevron_right),
|
icon: const Icon(Icons.chevron_right),
|
||||||
),
|
),
|
||||||
onTap: _editing ? null : () => _toggle(item),
|
onTap: _editing || item.id < 0 ? null : () => _toggle(item),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AddListItemSheet extends StatefulWidget {
|
|
||||||
const _AddListItemSheet({required this.products});
|
|
||||||
|
|
||||||
final List<Product> products;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_AddListItemSheet> createState() => _AddListItemSheetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AddListItemSheetState extends State<_AddListItemSheet> {
|
|
||||||
final TextEditingController _controller = TextEditingController();
|
|
||||||
String? _error;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Product> get _matches {
|
|
||||||
final query = _controller.text.trim().toLowerCase();
|
|
||||||
if (query.isEmpty) {
|
|
||||||
return widget.products.take(8).toList();
|
|
||||||
}
|
|
||||||
return widget.products
|
|
||||||
.where((product) => product.name.toLowerCase().contains(query))
|
|
||||||
.take(8)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _add({String? name, int? productId}) {
|
|
||||||
final value = (name ?? _controller.text).trim();
|
|
||||||
if (value.isEmpty) {
|
|
||||||
setState(() => _error = 'Enter an item name.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Navigator.of(context).pop((name: value, productId: productId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
|
||||||
final matches = _matches;
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Text('Add item', style: theme.textTheme.titleLarge),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextField(
|
|
||||||
controller: _controller,
|
|
||||||
autofocus: true,
|
|
||||||
textCapitalization: TextCapitalization.sentences,
|
|
||||||
decoration: const InputDecoration(labelText: 'Item name'),
|
|
||||||
onChanged: (_) => setState(() => _error = null),
|
|
||||||
onSubmitted: (_) => _add(),
|
|
||||||
),
|
|
||||||
if (_error != null) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
|
||||||
],
|
|
||||||
if (matches.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text('From products', style: theme.textTheme.labelMedium),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
for (final product in matches)
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text(product.name),
|
|
||||||
onTap: () => _add(name: product.name, productId: product.id),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
FilledButton(onPressed: () => _add(), child: const Text('Add')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
/// Manage supermarket names, colors, and bonus card photos.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
import 'models/supermarket.dart';
|
||||||
|
import 'services/app_settings.dart';
|
||||||
|
import 'services/product_repository.dart';
|
||||||
|
import 'widgets/store_edit_dialog.dart';
|
||||||
|
import 'widgets/store_tag.dart';
|
||||||
|
|
||||||
|
class SupermarketsScreen extends StatefulWidget {
|
||||||
|
const SupermarketsScreen({super.key, required this.repository});
|
||||||
|
|
||||||
|
final ProductRepository repository;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SupermarketsScreen> createState() => _SupermarketsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SupermarketsScreenState extends State<SupermarketsScreen> {
|
||||||
|
List<Supermarket>? _stores;
|
||||||
|
bool _busy = false;
|
||||||
|
final ImagePicker _picker = ImagePicker();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.repository.addListener(_load);
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.repository.removeListener(_load);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
final stores = await widget.repository.getSupermarkets();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _stores = stores);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _stores ??= const []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncSettings() async {
|
||||||
|
await _settings?.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
AppSettings? get _settings => SettingsScope.maybeOf(context);
|
||||||
|
|
||||||
|
Future<void> _editStore(Supermarket? existing) async {
|
||||||
|
final result = await showStoreEditDialog(context, store: existing);
|
||||||
|
if (result == null) return;
|
||||||
|
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
final settings = _settings;
|
||||||
|
if (settings == null) return;
|
||||||
|
if (existing == null) {
|
||||||
|
await settings.addSupermarket(result.name, result.colorValue);
|
||||||
|
} else {
|
||||||
|
await settings.updateSupermarket(result);
|
||||||
|
}
|
||||||
|
await _load();
|
||||||
|
await _syncSettings();
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('That supermarket already exists.')),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteStore(Supermarket store) async {
|
||||||
|
if (store.id == null) return;
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
await _settings?.deleteSupermarket(store.id!);
|
||||||
|
await _load();
|
||||||
|
await _syncSettings();
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickBonusCard(Supermarket store) async {
|
||||||
|
if (store.id == null) return;
|
||||||
|
final source = await showModalBottomSheet<ImageSource>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.photo_camera_outlined),
|
||||||
|
title: const Text('Take photo'),
|
||||||
|
onTap: () => Navigator.pop(context, ImageSource.camera),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.photo_library_outlined),
|
||||||
|
title: const Text('Pick from gallery'),
|
||||||
|
onTap: () => Navigator.pop(context, ImageSource.gallery),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (source == null) return;
|
||||||
|
final file = await _picker.pickImage(source: source, imageQuality: 85);
|
||||||
|
if (file == null) return;
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
await widget.repository.setSupermarketBonusCard(store.id!, file.path);
|
||||||
|
await _load();
|
||||||
|
await _syncSettings();
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _viewBonusCard(String path) async {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return Dialog(
|
||||||
|
insetPadding: const EdgeInsets.all(16),
|
||||||
|
child: InteractiveViewer(
|
||||||
|
child: Image.file(File(path), fit: BoxFit.contain),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final stores = _stores;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Supermarkets')),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: _busy ? null : () => _editStore(null),
|
||||||
|
tooltip: 'Add supermarket',
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
body: stores == null
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
|
||||||
|
itemCount: stores.length + 1,
|
||||||
|
separatorBuilder: (_, index) =>
|
||||||
|
index == 0 ? const SizedBox(height: 4) : const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == 0) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Store names and colors appear on products. '
|
||||||
|
'Add a bonus card photo when you have one.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
if (_busy) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const LinearProgressIndicator(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final store = stores[index - 1];
|
||||||
|
return _SupermarketRow(
|
||||||
|
store: store,
|
||||||
|
stores: stores,
|
||||||
|
busy: _busy,
|
||||||
|
onEdit: () => _editStore(store),
|
||||||
|
onDelete: store.id == null ? null : () => _deleteStore(store),
|
||||||
|
onBonusCard: () => _pickBonusCard(store),
|
||||||
|
onViewBonusCard: store.bonusImagePath == null
|
||||||
|
? null
|
||||||
|
: () => _viewBonusCard(store.bonusImagePath!),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SupermarketRow extends StatelessWidget {
|
||||||
|
const _SupermarketRow({
|
||||||
|
required this.store,
|
||||||
|
required this.stores,
|
||||||
|
required this.busy,
|
||||||
|
required this.onEdit,
|
||||||
|
required this.onBonusCard,
|
||||||
|
this.onDelete,
|
||||||
|
this.onViewBonusCard,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Supermarket store;
|
||||||
|
final List<Supermarket> stores;
|
||||||
|
final bool busy;
|
||||||
|
final VoidCallback onEdit;
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
final VoidCallback onBonusCard;
|
||||||
|
final VoidCallback? onViewBonusCard;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final path = store.bonusImagePath;
|
||||||
|
final hasCard = path != null && path.isNotEmpty && File(path).existsSync();
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
leading: hasCard
|
||||||
|
? InkWell(
|
||||||
|
onTap: busy ? null : onViewBonusCard,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.file(
|
||||||
|
File(path),
|
||||||
|
width: 44,
|
||||||
|
height: 28,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: store.color,
|
||||||
|
child: Text(
|
||||||
|
store.name.isEmpty ? '?' : store.name.substring(0, 1),
|
||||||
|
style: TextStyle(
|
||||||
|
color: store.color.computeLuminance() > 0.55
|
||||||
|
? Colors.black87
|
||||||
|
: Colors.white,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: StoreTag(name: store.name, stores: stores, compact: true),
|
||||||
|
subtitle: hasCard ? null : const Text('No bonus card'),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: hasCard ? 'Replace bonus card' : 'Add bonus card',
|
||||||
|
onPressed: busy ? null : onBonusCard,
|
||||||
|
icon: Icon(hasCard ? Icons.photo_camera : Icons.card_membership_outlined),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Edit',
|
||||||
|
onPressed: busy ? null : onEdit,
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Delete',
|
||||||
|
onPressed: busy ? null : onDelete,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,9 +39,7 @@ abstract final class AppTheme {
|
|||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.surfaceContainerLow,
|
color: colorScheme.surfaceContainerLow,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
@@ -60,9 +58,7 @@ abstract final class AppTheme {
|
|||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
|
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
@@ -88,9 +84,7 @@ abstract final class AppTheme {
|
|||||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||||
backgroundColor: colorScheme.primary,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: colorScheme.onPrimary,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
dividerTheme: DividerThemeData(
|
dividerTheme: DividerThemeData(
|
||||||
color: colorScheme.outlineVariant,
|
color: colorScheme.outlineVariant,
|
||||||
@@ -98,14 +92,10 @@ abstract final class AppTheme {
|
|||||||
),
|
),
|
||||||
snackBarTheme: SnackBarThemeData(
|
snackBarTheme: SnackBarThemeData(
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
listTileTheme: ListTileThemeData(
|
listTileTheme: ListTileThemeData(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
iconColor: colorScheme.primary,
|
iconColor: colorScheme.primary,
|
||||||
),
|
),
|
||||||
shadowColor: isDark ? Colors.black : const Color(0x1A0F172A),
|
shadowColor: isDark ? Colors.black : const Color(0x1A0F172A),
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
/// Case-insensitive product name matching and duplicate grouping.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import '../models/product.dart';
|
||||||
|
|
||||||
|
String normalizeProductName(String name) =>
|
||||||
|
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
|
||||||
|
|
||||||
|
String normalizeSupermarket(String? supermarket) =>
|
||||||
|
supermarket?.trim().toUpperCase() ?? '';
|
||||||
|
|
||||||
|
String productConflictKey({
|
||||||
|
required String name,
|
||||||
|
required String? supermarket,
|
||||||
|
}) => '${normalizeSupermarket(supermarket)}|${normalizeProductName(name)}';
|
||||||
|
|
||||||
|
String mergeGroupKey(List<Product> products, String? supermarket) {
|
||||||
|
final ids = products.map((product) => product.id).toList()..sort();
|
||||||
|
return 'merge:${normalizeSupermarket(supermarket)}:'
|
||||||
|
'${normalizeProductName(products.first.name)}:${ids.join(',')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String spellingVariantKey(int productId, String? supermarket) =>
|
||||||
|
'spelling:$productId:${normalizeSupermarket(supermarket)}';
|
||||||
|
|
||||||
|
List<List<Product>> nameConflictGroups(Iterable<Product> products) {
|
||||||
|
final groups = <String, List<Product>>{};
|
||||||
|
for (final product in products) {
|
||||||
|
final key = productConflictKey(
|
||||||
|
name: product.name,
|
||||||
|
supermarket: product.supermarket,
|
||||||
|
);
|
||||||
|
if (normalizeProductName(product.name).isEmpty) continue;
|
||||||
|
(groups[key] ??= []).add(product);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (final group in groups.values)
|
||||||
|
if (group.length > 1) group,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<int> nameConflictIds(Iterable<Product> products) {
|
||||||
|
return {
|
||||||
|
for (final group in nameConflictGroups(products))
|
||||||
|
for (final product in group) product.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate catalog products with the same name at the same store.
|
||||||
|
class ProductMergeGroup {
|
||||||
|
const ProductMergeGroup({
|
||||||
|
required this.products,
|
||||||
|
required this.supermarket,
|
||||||
|
required this.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<Product> products;
|
||||||
|
final String? supermarket;
|
||||||
|
final String key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One catalog product bought under multiple spellings at one store.
|
||||||
|
class ProductSpellingVariant {
|
||||||
|
const ProductSpellingVariant({
|
||||||
|
required this.product,
|
||||||
|
required this.supermarket,
|
||||||
|
required this.variants,
|
||||||
|
required this.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Product product;
|
||||||
|
final String? supermarket;
|
||||||
|
final List<String> variants;
|
||||||
|
final String key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds merge groups from catalog names and trip lines at the same store.
|
||||||
|
List<ProductMergeGroup> buildNameConflictGroups({
|
||||||
|
required Iterable<Product> products,
|
||||||
|
required Iterable<({int productId, String name, String supermarket})>
|
||||||
|
shopLines,
|
||||||
|
}) {
|
||||||
|
final productById = {for (final product in products) product.id: product};
|
||||||
|
final idsByKey = <String, Set<int>>{};
|
||||||
|
final supermarketByKey = <String, String>{};
|
||||||
|
|
||||||
|
void add(int productId, String name, String? supermarket) {
|
||||||
|
final key = productConflictKey(name: name, supermarket: supermarket);
|
||||||
|
if (normalizeProductName(name).isEmpty) return;
|
||||||
|
final trimmed = supermarket?.trim();
|
||||||
|
if (trimmed != null && trimmed.isNotEmpty) {
|
||||||
|
supermarketByKey.putIfAbsent(key, () => trimmed);
|
||||||
|
}
|
||||||
|
(idsByKey[key] ??= {}).add(productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final product in products) {
|
||||||
|
add(product.id, product.name, product.supermarket);
|
||||||
|
}
|
||||||
|
for (final line in shopLines) {
|
||||||
|
add(line.productId, line.name, line.supermarket);
|
||||||
|
}
|
||||||
|
|
||||||
|
final groups = <ProductMergeGroup>[];
|
||||||
|
for (final entry in idsByKey.entries) {
|
||||||
|
if (entry.value.length < 2) continue;
|
||||||
|
final productsInGroup = [
|
||||||
|
for (final id in entry.value)
|
||||||
|
if (productById.containsKey(id)) productById[id]!,
|
||||||
|
];
|
||||||
|
if (productsInGroup.length < 2) continue;
|
||||||
|
|
||||||
|
productsInGroup.sort(
|
||||||
|
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||||
|
);
|
||||||
|
final supermarketLabel = supermarketByKey[entry.key];
|
||||||
|
groups.add(
|
||||||
|
ProductMergeGroup(
|
||||||
|
products: productsInGroup,
|
||||||
|
supermarket: supermarketLabel,
|
||||||
|
key: mergeGroupKey(productsInGroup, supermarketLabel),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.sort(
|
||||||
|
(a, b) =>
|
||||||
|
productConflictKey(
|
||||||
|
name: a.products.first.name,
|
||||||
|
supermarket: a.supermarket,
|
||||||
|
).compareTo(
|
||||||
|
productConflictKey(
|
||||||
|
name: b.products.first.name,
|
||||||
|
supermarket: b.supermarket,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds receipt spellings that differ only by capitals at one store.
|
||||||
|
List<ProductSpellingVariant> findSpellingVariants({
|
||||||
|
required Iterable<Product> products,
|
||||||
|
required Iterable<({int productId, String name, String supermarket})>
|
||||||
|
shopLines,
|
||||||
|
Iterable<int> mergeProductIds = const [],
|
||||||
|
Iterable<String> mergeGroupKeys = const [],
|
||||||
|
}) {
|
||||||
|
final productById = {for (final product in products) product.id: product};
|
||||||
|
final excludedProducts = mergeProductIds.toSet();
|
||||||
|
final excludedGroups = mergeGroupKeys.toSet();
|
||||||
|
final namesByProductStore = <String, Set<String>>{};
|
||||||
|
final supermarketByKey = <String, String>{};
|
||||||
|
|
||||||
|
void addName(int productId, String name, String supermarket) {
|
||||||
|
final storeKey = normalizeSupermarket(supermarket);
|
||||||
|
final bucketKey = '$productId|$storeKey';
|
||||||
|
supermarketByKey[bucketKey] = supermarket;
|
||||||
|
(namesByProductStore[bucketKey] ??= {}).add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final line in shopLines) {
|
||||||
|
addName(line.productId, line.name, line.supermarket);
|
||||||
|
}
|
||||||
|
for (final product in products) {
|
||||||
|
final supermarket = product.supermarket;
|
||||||
|
if (supermarket == null || supermarket.trim().isEmpty) continue;
|
||||||
|
addName(product.id, product.name, supermarket);
|
||||||
|
}
|
||||||
|
|
||||||
|
final issues = <ProductSpellingVariant>[];
|
||||||
|
for (final entry in namesByProductStore.entries) {
|
||||||
|
final parts = entry.key.split('|');
|
||||||
|
final productId = int.parse(parts.first);
|
||||||
|
if (excludedProducts.contains(productId)) continue;
|
||||||
|
|
||||||
|
final product = productById[productId];
|
||||||
|
if (product == null) continue;
|
||||||
|
|
||||||
|
final supermarket = supermarketByKey[entry.key];
|
||||||
|
final byNormalized = <String, Set<String>>{};
|
||||||
|
for (final name in entry.value) {
|
||||||
|
final key = normalizeProductName(name);
|
||||||
|
if (key.isEmpty) continue;
|
||||||
|
(byNormalized[key] ??= {}).add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final names in byNormalized.values) {
|
||||||
|
if (names.length < 2) continue;
|
||||||
|
final variants = names.toList()
|
||||||
|
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||||||
|
final issue = ProductSpellingVariant(
|
||||||
|
product: product,
|
||||||
|
supermarket: supermarket,
|
||||||
|
variants: variants,
|
||||||
|
key: spellingVariantKey(productId, supermarket),
|
||||||
|
);
|
||||||
|
if (excludedGroups.contains(issue.key)) continue;
|
||||||
|
issues.add(issue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
issues.sort(
|
||||||
|
(a, b) =>
|
||||||
|
productConflictKey(
|
||||||
|
name: a.product.name,
|
||||||
|
supermarket: a.supermarket,
|
||||||
|
).compareTo(
|
||||||
|
productConflictKey(name: b.product.name, supermarket: b.supermarket),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Catalog merges and receipt spelling variants for the Products screen.
|
||||||
|
class ProductNameIssues {
|
||||||
|
const ProductNameIssues({
|
||||||
|
required this.mergeGroups,
|
||||||
|
required this.spellingVariants,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<ProductMergeGroup> mergeGroups;
|
||||||
|
final List<ProductSpellingVariant> spellingVariants;
|
||||||
|
|
||||||
|
bool get isEmpty => mergeGroups.isEmpty && spellingVariants.isEmpty;
|
||||||
|
|
||||||
|
bool get isNotEmpty => !isEmpty;
|
||||||
|
|
||||||
|
int get issueCount => mergeGroups.length + spellingVariants.length;
|
||||||
|
|
||||||
|
Set<int> get productIds => {
|
||||||
|
for (final group in mergeGroups)
|
||||||
|
for (final product in group.products) product.id,
|
||||||
|
for (final variant in spellingVariants) variant.product.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ProductNameIssues buildProductNameIssues({
|
||||||
|
required Iterable<Product> products,
|
||||||
|
required Iterable<({int productId, String name, String supermarket})>
|
||||||
|
shopLines,
|
||||||
|
Set<String> dismissedKeys = const {},
|
||||||
|
}) {
|
||||||
|
final mergeGroups = [
|
||||||
|
for (final group in buildNameConflictGroups(
|
||||||
|
products: products,
|
||||||
|
shopLines: shopLines,
|
||||||
|
))
|
||||||
|
if (!dismissedKeys.contains(group.key)) group,
|
||||||
|
];
|
||||||
|
final mergeIds = {
|
||||||
|
for (final group in mergeGroups)
|
||||||
|
for (final product in group.products) product.id,
|
||||||
|
};
|
||||||
|
final mergeKeys = {for (final group in mergeGroups) group.key};
|
||||||
|
final spellingVariants = [
|
||||||
|
for (final variant in findSpellingVariants(
|
||||||
|
products: products,
|
||||||
|
shopLines: shopLines,
|
||||||
|
mergeProductIds: mergeIds,
|
||||||
|
mergeGroupKeys: mergeKeys,
|
||||||
|
))
|
||||||
|
if (!dismissedKeys.contains(variant.key)) variant,
|
||||||
|
];
|
||||||
|
return ProductNameIssues(
|
||||||
|
mergeGroups: mergeGroups,
|
||||||
|
spellingVariants: spellingVariants,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/// Multi-select sheet for adding catalog products to a shopping list.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/product.dart';
|
||||||
|
import 'product_image.dart';
|
||||||
|
|
||||||
|
class AddListItemSelection {
|
||||||
|
const AddListItemSelection({required this.name, this.productId});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final int? productId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<AddListItemSelection>?> showAddListItemsSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required List<Product> products,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<List<AddListItemSelection>>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => _AddListItemsSheet(products: products),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddListItemsSheet extends StatefulWidget {
|
||||||
|
const _AddListItemsSheet({required this.products});
|
||||||
|
|
||||||
|
final List<Product> products;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AddListItemsSheet> createState() => _AddListItemsSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddListItemsSheetState extends State<_AddListItemsSheet> {
|
||||||
|
final TextEditingController _controller = TextEditingController();
|
||||||
|
final Set<int> _selectedProductIds = {};
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Product> get _matches {
|
||||||
|
final query = _controller.text.trim().toLowerCase();
|
||||||
|
if (query.isEmpty) return widget.products;
|
||||||
|
return widget.products
|
||||||
|
.where((product) => product.name.toLowerCase().contains(query))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
int get _addCount {
|
||||||
|
var count = _selectedProductIds.length;
|
||||||
|
final typed = _controller.text.trim();
|
||||||
|
if (typed.isEmpty) return count;
|
||||||
|
final selectedNames = {
|
||||||
|
for (final product in widget.products)
|
||||||
|
if (_selectedProductIds.contains(product.id))
|
||||||
|
product.name.toLowerCase(),
|
||||||
|
};
|
||||||
|
if (!selectedNames.contains(typed.toLowerCase())) count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleProduct(Product product) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedProductIds.contains(product.id)) {
|
||||||
|
_selectedProductIds.remove(product.id);
|
||||||
|
} else {
|
||||||
|
_selectedProductIds.add(product.id);
|
||||||
|
}
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _add() {
|
||||||
|
final selections = <AddListItemSelection>[
|
||||||
|
for (final product in widget.products)
|
||||||
|
if (_selectedProductIds.contains(product.id))
|
||||||
|
AddListItemSelection(name: product.name, productId: product.id),
|
||||||
|
];
|
||||||
|
final typed = _controller.text.trim();
|
||||||
|
if (typed.isNotEmpty &&
|
||||||
|
!selections.any(
|
||||||
|
(item) => item.name.toLowerCase() == typed.toLowerCase(),
|
||||||
|
)) {
|
||||||
|
selections.add(AddListItemSelection(name: typed));
|
||||||
|
}
|
||||||
|
if (selections.isEmpty) {
|
||||||
|
setState(() => _error = 'Select products or enter an item name.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(selections);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
||||||
|
final matches = _matches;
|
||||||
|
final addCount = _addCount;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
|
||||||
|
child: SizedBox(
|
||||||
|
height: MediaQuery.sizeOf(context).height * 0.72,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text('Add items', style: theme.textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _controller,
|
||||||
|
autofocus: true,
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Search or type a new item',
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() => _error = null),
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('From products', style: theme.textTheme.labelMedium),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Expanded(
|
||||||
|
child: matches.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
widget.products.isEmpty
|
||||||
|
? 'No products in your catalog yet.'
|
||||||
|
: 'No products match this search.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: matches.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final product = matches[index];
|
||||||
|
final selected = _selectedProductIds.contains(
|
||||||
|
product.id,
|
||||||
|
);
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Checkbox(
|
||||||
|
value: selected,
|
||||||
|
onChanged: (_) => _toggleProduct(product),
|
||||||
|
),
|
||||||
|
title: Text(product.name),
|
||||||
|
trailing: ProductImage(
|
||||||
|
name: product.name,
|
||||||
|
path: product.imagePath,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
onTap: () => _toggleProduct(product),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: addCount == 0 ? null : _add,
|
||||||
|
child: Text(addCount == 1 ? 'Add 1 item' : 'Add $addCount items'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/// Bottom sheet for picking a shopping list to add a product to.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/shopping_list.dart';
|
||||||
|
import '../services/product_repository.dart';
|
||||||
|
import 'name_dialog.dart';
|
||||||
|
|
||||||
|
Future<int?> showPickShoppingListSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required ProductRepository repository,
|
||||||
|
}) async {
|
||||||
|
final lists = await repository.getShoppingLists();
|
||||||
|
if (!context.mounted) return null;
|
||||||
|
|
||||||
|
if (lists.isEmpty) {
|
||||||
|
final name = await showNameDialog(
|
||||||
|
context,
|
||||||
|
title: 'New shopping list',
|
||||||
|
label: 'List name',
|
||||||
|
confirmLabel: 'Create',
|
||||||
|
);
|
||||||
|
if (name == null || !context.mounted) return null;
|
||||||
|
return repository.createShoppingList(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return showModalBottomSheet<int>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) {
|
||||||
|
return _PickShoppingListSheet(repository: repository, lists: lists);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PickShoppingListSheet extends StatelessWidget {
|
||||||
|
const _PickShoppingListSheet({required this.repository, required this.lists});
|
||||||
|
|
||||||
|
final ProductRepository repository;
|
||||||
|
final List<ShoppingList> lists;
|
||||||
|
|
||||||
|
Future<void> _create(BuildContext context) async {
|
||||||
|
final name = await showNameDialog(
|
||||||
|
context,
|
||||||
|
title: 'New shopping list',
|
||||||
|
label: 'List name',
|
||||||
|
confirmLabel: 'Create',
|
||||||
|
);
|
||||||
|
if (name == null || !context.mounted) return;
|
||||||
|
final id = await repository.createShoppingList(name);
|
||||||
|
if (context.mounted) Navigator.of(context).pop(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: Text('Add to list', style: theme.textTheme.titleLarge),
|
||||||
|
),
|
||||||
|
for (final list in lists)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.checklist_outlined),
|
||||||
|
title: Text(list.name),
|
||||||
|
subtitle: Text(
|
||||||
|
list.itemCount == 0
|
||||||
|
? 'Empty'
|
||||||
|
: list.itemCount == 1
|
||||||
|
? '1 item'
|
||||||
|
: '${list.itemCount} items',
|
||||||
|
),
|
||||||
|
onTap: () => Navigator.of(context).pop(list.id),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.add),
|
||||||
|
title: const Text('Create new list'),
|
||||||
|
onTap: () => _create(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,9 +28,7 @@ class PriceChart extends StatelessWidget {
|
|||||||
height: 160,
|
height: 160,
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
painter: _PriceChartPainter(
|
painter: _PriceChartPainter(
|
||||||
prices: [
|
prices: [for (final purchase in points) purchase.unitOrLinePrice],
|
||||||
for (final purchase in points) purchase.unitOrLinePrice,
|
|
||||||
],
|
|
||||||
lineColor: theme.colorScheme.primary,
|
lineColor: theme.colorScheme.primary,
|
||||||
fillColor: theme.colorScheme.primary.withValues(alpha: 0.12),
|
fillColor: theme.colorScheme.primary.withValues(alpha: 0.12),
|
||||||
gridColor: theme.colorScheme.outlineVariant,
|
gridColor: theme.colorScheme.outlineVariant,
|
||||||
@@ -87,7 +85,12 @@ class _PriceChartPainter extends CustomPainter {
|
|||||||
const right = 8.0;
|
const right = 8.0;
|
||||||
const top = 12.0;
|
const top = 12.0;
|
||||||
const bottom = 8.0;
|
const bottom = 8.0;
|
||||||
final chart = Rect.fromLTRB(left, top, size.width - right, size.height - bottom);
|
final chart = Rect.fromLTRB(
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
size.width - right,
|
||||||
|
size.height - bottom,
|
||||||
|
);
|
||||||
|
|
||||||
final gridPaint = Paint()
|
final gridPaint = Paint()
|
||||||
..color = gridColor
|
..color = gridColor
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ class _LetterAvatar extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final trimmed = name.trim();
|
final trimmed = name.trim();
|
||||||
final letter = trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase();
|
final letter = trimmed.isEmpty
|
||||||
|
? '?'
|
||||||
|
: trimmed.substring(0, 1).toUpperCase();
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: colorScheme.secondaryContainer,
|
color: colorScheme.secondaryContainer,
|
||||||
child: Center(
|
child: Center(
|
||||||
|
|||||||
@@ -0,0 +1,618 @@
|
|||||||
|
/// Banner, list markers, and merge sheet for name duplicates.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/product.dart';
|
||||||
|
import '../services/product_repository.dart';
|
||||||
|
import '../utils/money.dart';
|
||||||
|
import '../utils/product_names.dart';
|
||||||
|
import 'product_image.dart';
|
||||||
|
|
||||||
|
const IconData kProductNameConflictIcon = Icons.merge_type;
|
||||||
|
|
||||||
|
class ProductNameConflictBanner extends StatelessWidget {
|
||||||
|
const ProductNameConflictBanner({
|
||||||
|
super.key,
|
||||||
|
required this.issues,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ProductNameIssues issues;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final scheme = theme.colorScheme;
|
||||||
|
final title = _bannerTitle(issues);
|
||||||
|
final subtitle = _bannerSubtitle(issues);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
|
child: Material(
|
||||||
|
color: scheme.errorContainer.withValues(alpha: 0.65),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(kProductNameConflictIcon, color: scheme.error),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: theme.textTheme.titleSmall),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(Icons.chevron_right, color: scheme.onSurfaceVariant),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _bannerTitle(ProductNameIssues issues) {
|
||||||
|
final count = issues.issueCount;
|
||||||
|
if (count == 1 && issues.mergeGroups.length == 1) {
|
||||||
|
return '1 duplicate product';
|
||||||
|
}
|
||||||
|
if (count == 1 && issues.spellingVariants.length == 1) {
|
||||||
|
return '1 product, multiple spellings';
|
||||||
|
}
|
||||||
|
return '$count name issues';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _bannerSubtitle(ProductNameIssues issues) {
|
||||||
|
final examples = <String>[];
|
||||||
|
for (final group in issues.mergeGroups.take(2)) {
|
||||||
|
final store = group.supermarket == null ? '' : ' @ ${group.supermarket}';
|
||||||
|
examples.add(
|
||||||
|
'${group.products.map((product) => product.name).join(' / ')}$store',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final variant in issues.spellingVariants.take(2)) {
|
||||||
|
final store = variant.supermarket == null
|
||||||
|
? ''
|
||||||
|
: ' @ ${variant.supermarket}';
|
||||||
|
examples.add('${variant.variants.join(' / ')}$store');
|
||||||
|
}
|
||||||
|
return examples.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProductNameConflictMarker extends StatelessWidget {
|
||||||
|
const ProductNameConflictMarker({
|
||||||
|
super.key,
|
||||||
|
this.onTap,
|
||||||
|
this.compact = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final icon = Icon(
|
||||||
|
kProductNameConflictIcon,
|
||||||
|
size: compact ? 14 : 20,
|
||||||
|
color: scheme.error,
|
||||||
|
);
|
||||||
|
if (onTap == null) return icon;
|
||||||
|
if (compact) {
|
||||||
|
return Material(
|
||||||
|
color: scheme.errorContainer,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
child: Padding(padding: const EdgeInsets.all(2), child: icon),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return IconButton(
|
||||||
|
tooltip: 'Resolve name issues',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||||
|
onPressed: onTap,
|
||||||
|
icon: icon,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> showProductNameConflictSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required ProductRepository repository,
|
||||||
|
required ProductNameIssues issues,
|
||||||
|
int? focusProductId,
|
||||||
|
}) {
|
||||||
|
if (issues.isEmpty) return Future.value(false);
|
||||||
|
|
||||||
|
return showModalBottomSheet<bool>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
builder: (context) {
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
return SizedBox(
|
||||||
|
width: size.width,
|
||||||
|
height: size.height * 0.85,
|
||||||
|
child: _ProductNameConflictSheet(
|
||||||
|
repository: repository,
|
||||||
|
issues: issues,
|
||||||
|
focusProductId: focusProductId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
).then((resolved) => resolved == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProductNameConflictSheet extends StatefulWidget {
|
||||||
|
const _ProductNameConflictSheet({
|
||||||
|
required this.repository,
|
||||||
|
required this.issues,
|
||||||
|
this.focusProductId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ProductRepository repository;
|
||||||
|
final ProductNameIssues issues;
|
||||||
|
final int? focusProductId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ProductNameConflictSheet> createState() =>
|
||||||
|
_ProductNameConflictSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProductNameConflictSheetState extends State<_ProductNameConflictSheet> {
|
||||||
|
late List<ProductMergeGroup> _mergeGroups;
|
||||||
|
late List<ProductSpellingVariant> _spellingVariants;
|
||||||
|
late Map<int, int> _keepByMergeGroup;
|
||||||
|
late Map<int, String> _spellingChoiceByIndex;
|
||||||
|
final Set<int> _busyMergeGroups = {};
|
||||||
|
final Set<int> _busySpellingGroups = {};
|
||||||
|
bool _resolvedAny = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_mergeGroups = [for (final group in widget.issues.mergeGroups) group];
|
||||||
|
_spellingVariants = [
|
||||||
|
for (final variant in widget.issues.spellingVariants) variant,
|
||||||
|
];
|
||||||
|
_rebuildChoices();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rebuildChoices() {
|
||||||
|
_keepByMergeGroup = {
|
||||||
|
for (var i = 0; i < _mergeGroups.length; i++)
|
||||||
|
i: _defaultKeep(_mergeGroups[i].products),
|
||||||
|
};
|
||||||
|
_spellingChoiceByIndex = {
|
||||||
|
for (var i = 0; i < _spellingVariants.length; i++)
|
||||||
|
i: _spellingVariants[i].variants.first,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
int _defaultKeep(List<Product> group) {
|
||||||
|
final sorted = [...group]
|
||||||
|
..sort((a, b) {
|
||||||
|
final seen = b.timesSeen.compareTo(a.timesSeen);
|
||||||
|
if (seen != 0) return seen;
|
||||||
|
return a.id.compareTo(b.id);
|
||||||
|
});
|
||||||
|
return sorted.first.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isEmpty => _mergeGroups.isEmpty && _spellingVariants.isEmpty;
|
||||||
|
|
||||||
|
int? _focusedMergeIndex() {
|
||||||
|
final focus = widget.focusProductId;
|
||||||
|
if (focus == null) return null;
|
||||||
|
for (var i = 0; i < _mergeGroups.length; i++) {
|
||||||
|
if (_mergeGroups[i].products.any((product) => product.id == focus)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _focusedSpellingIndex() {
|
||||||
|
final focus = widget.focusProductId;
|
||||||
|
if (focus == null) return null;
|
||||||
|
for (var i = 0; i < _spellingVariants.length; i++) {
|
||||||
|
if (_spellingVariants[i].product.id == focus) return i;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _maybeClose() {
|
||||||
|
if (_isEmpty && mounted) {
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _mergeGroup(int index) async {
|
||||||
|
if (_busyMergeGroups.contains(index)) return;
|
||||||
|
final group = _mergeGroups[index];
|
||||||
|
final keepId = _keepByMergeGroup[index]!;
|
||||||
|
setState(() => _busyMergeGroups.add(index));
|
||||||
|
try {
|
||||||
|
await widget.repository.mergeProducts(
|
||||||
|
keepId: keepId,
|
||||||
|
sourceIds: [for (final product in group.products) product.id],
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_resolvedAny = true;
|
||||||
|
_busyMergeGroups.remove(index);
|
||||||
|
_mergeGroups.removeAt(index);
|
||||||
|
_rebuildChoices();
|
||||||
|
});
|
||||||
|
_maybeClose();
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _busyMergeGroups.remove(index));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Could not merge these products.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _leaveMergeAsIs(int index) async {
|
||||||
|
final group = _mergeGroups[index];
|
||||||
|
await widget.repository.dismissNameIssue(group.key);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_resolvedAny = true;
|
||||||
|
_mergeGroups.removeAt(index);
|
||||||
|
_rebuildChoices();
|
||||||
|
});
|
||||||
|
_maybeClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applySpelling(int index) async {
|
||||||
|
if (_busySpellingGroups.contains(index)) return;
|
||||||
|
final variant = _spellingVariants[index];
|
||||||
|
final chosen = _spellingChoiceByIndex[index]!;
|
||||||
|
setState(() => _busySpellingGroups.add(index));
|
||||||
|
try {
|
||||||
|
await widget.repository.unifyProductSpelling(
|
||||||
|
productId: variant.product.id,
|
||||||
|
name: chosen,
|
||||||
|
supermarket: variant.supermarket,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_resolvedAny = true;
|
||||||
|
_busySpellingGroups.remove(index);
|
||||||
|
_spellingVariants.removeAt(index);
|
||||||
|
_rebuildChoices();
|
||||||
|
});
|
||||||
|
_maybeClose();
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _busySpellingGroups.remove(index));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Could not update this spelling.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _leaveSpellingAsIs(int index) async {
|
||||||
|
final variant = _spellingVariants[index];
|
||||||
|
await widget.repository.dismissNameIssue(variant.key);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_resolvedAny = true;
|
||||||
|
_spellingVariants.removeAt(index);
|
||||||
|
_rebuildChoices();
|
||||||
|
});
|
||||||
|
_maybeClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final focusMergeIndex = _focusedMergeIndex();
|
||||||
|
final focusSpellingIndex = _focusedSpellingIndex();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text('Resolve name issues', style: theme.textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Same name at the same store. Pick which entry to keep, '
|
||||||
|
'then merge or leave as separate products.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
_resolvedAny
|
||||||
|
? 'All name issues resolved.'
|
||||||
|
: 'No name issues found.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
children: [
|
||||||
|
if (_mergeGroups.isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
'Separate products to merge',
|
||||||
|
style: theme.textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (var i = 0; i < _mergeGroups.length; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(height: 12),
|
||||||
|
_MergeGroupCard(
|
||||||
|
group: _mergeGroups[i],
|
||||||
|
keepId: _keepByMergeGroup[i]!,
|
||||||
|
busy: _busyMergeGroups.contains(i),
|
||||||
|
highlighted: focusMergeIndex == i,
|
||||||
|
onKeepChanged: (id) {
|
||||||
|
setState(() => _keepByMergeGroup[i] = id);
|
||||||
|
},
|
||||||
|
onMerge: () => _mergeGroup(i),
|
||||||
|
onLeaveAsIs: () => _leaveMergeAsIs(i),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
if (_spellingVariants.isNotEmpty) ...[
|
||||||
|
if (_mergeGroups.isNotEmpty) const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'One product, multiple spellings',
|
||||||
|
style: theme.textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (var i = 0; i < _spellingVariants.length; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(height: 12),
|
||||||
|
_SpellingVariantCard(
|
||||||
|
variant: _spellingVariants[i],
|
||||||
|
chosen: _spellingChoiceByIndex[i]!,
|
||||||
|
busy: _busySpellingGroups.contains(i),
|
||||||
|
highlighted: focusSpellingIndex == i,
|
||||||
|
onChosen: (name) {
|
||||||
|
setState(() => _spellingChoiceByIndex[i] = name);
|
||||||
|
},
|
||||||
|
onApply: () => _applySpelling(i),
|
||||||
|
onLeaveAsIs: () => _leaveSpellingAsIs(i),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MergeGroupCard extends StatelessWidget {
|
||||||
|
const _MergeGroupCard({
|
||||||
|
required this.group,
|
||||||
|
required this.keepId,
|
||||||
|
required this.busy,
|
||||||
|
required this.highlighted,
|
||||||
|
required this.onKeepChanged,
|
||||||
|
required this.onMerge,
|
||||||
|
required this.onLeaveAsIs,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ProductMergeGroup group;
|
||||||
|
final int keepId;
|
||||||
|
final bool busy;
|
||||||
|
final bool highlighted;
|
||||||
|
final ValueChanged<int> onKeepChanged;
|
||||||
|
final VoidCallback onMerge;
|
||||||
|
final VoidCallback onLeaveAsIs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final products = group.products;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
color: highlighted
|
||||||
|
? theme.colorScheme.tertiaryContainer.withValues(alpha: 0.35)
|
||||||
|
: null,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
kProductNameConflictIcon,
|
||||||
|
size: 18,
|
||||||
|
color: theme.colorScheme.tertiary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
[
|
||||||
|
normalizeProductName(products.first.name),
|
||||||
|
if (group.supermarket != null) group.supermarket!,
|
||||||
|
].join(' · '),
|
||||||
|
style: theme.textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (final product in products)
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
leading: ProductImage(
|
||||||
|
name: product.name,
|
||||||
|
path: product.imagePath,
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
|
title: Text(product.name),
|
||||||
|
subtitle: Text(
|
||||||
|
[
|
||||||
|
formatEuro(product.lastPrice),
|
||||||
|
product.timesSeen == 1
|
||||||
|
? '1 purchase'
|
||||||
|
: '${product.timesSeen} purchases',
|
||||||
|
].join(' · '),
|
||||||
|
),
|
||||||
|
trailing: Icon(
|
||||||
|
keepId == product.id
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.circle_outlined,
|
||||||
|
color: keepId == product.id
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.outline,
|
||||||
|
),
|
||||||
|
onTap: busy ? null : () => onKeepChanged(product.id),
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
alignment: WrapAlignment.end,
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: busy ? null : onLeaveAsIs,
|
||||||
|
child: const Text('Leave as is'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: busy ? null : onMerge,
|
||||||
|
child: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Merge'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpellingVariantCard extends StatelessWidget {
|
||||||
|
const _SpellingVariantCard({
|
||||||
|
required this.variant,
|
||||||
|
required this.chosen,
|
||||||
|
required this.busy,
|
||||||
|
required this.highlighted,
|
||||||
|
required this.onChosen,
|
||||||
|
required this.onApply,
|
||||||
|
required this.onLeaveAsIs,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ProductSpellingVariant variant;
|
||||||
|
final String chosen;
|
||||||
|
final bool busy;
|
||||||
|
final bool highlighted;
|
||||||
|
final ValueChanged<String> onChosen;
|
||||||
|
final VoidCallback onApply;
|
||||||
|
final VoidCallback onLeaveAsIs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final product = variant.product;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
color: highlighted
|
||||||
|
? theme.colorScheme.tertiaryContainer.withValues(alpha: 0.35)
|
||||||
|
: null,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
leading: ProductImage(
|
||||||
|
name: product.name,
|
||||||
|
path: product.imagePath,
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
|
title: Text(product.name),
|
||||||
|
subtitle: Text(
|
||||||
|
[
|
||||||
|
if (variant.supermarket != null) variant.supermarket!,
|
||||||
|
product.timesSeen == 1
|
||||||
|
? '1 purchase'
|
||||||
|
: '${product.timesSeen} purchases',
|
||||||
|
].join(' · '),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('Recorded as:', style: theme.textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
for (final name in variant.variants)
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
title: Text(name),
|
||||||
|
trailing: Icon(
|
||||||
|
chosen == name ? Icons.check_circle : Icons.circle_outlined,
|
||||||
|
color: chosen == name
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.outline,
|
||||||
|
),
|
||||||
|
onTap: busy ? null : () => onChosen(name),
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
alignment: WrapAlignment.end,
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: busy ? null : onLeaveAsIs,
|
||||||
|
child: const Text('Leave as is'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: busy ? null : onApply,
|
||||||
|
child: Text(busy ? 'Saving…' : 'Use this spelling'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,9 +124,8 @@ 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(
|
|
||||||
height: MediaQuery.sizeOf(context).height * 0.75,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text('Settings', style: Theme.of(context).textTheme.titleLarge),
|
Text('Settings', style: Theme.of(context).textTheme.titleLarge),
|
||||||
@@ -129,6 +136,21 @@ class _SettingsSheetState extends State<_SettingsSheet> {
|
|||||||
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',
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: _busy
|
||||||
|
? null
|
||||||
|
: () => _openSupermarkets(context, settings),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text('Backup', style: Theme.of(context).textTheme.titleMedium),
|
Text('Backup', style: Theme.of(context).textTheme.titleMedium),
|
||||||
Text(
|
Text(
|
||||||
'Save everything to a JSON file, or replace this device from a backup.',
|
'Save everything to a JSON file, or replace this device from a backup.',
|
||||||
@@ -158,215 +180,10 @@ class _SettingsSheetState extends State<_SettingsSheet> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const LinearProgressIndicator(),
|
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(height: 8),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-1
@@ -6,7 +6,8 @@ import 'test_helpers.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
ensureTestSqlite();
|
ensureTestSqlite();
|
||||||
|
|
||||||
test('deleting a product keeps the trip line and drops the catalog row',
|
test(
|
||||||
|
'deleting a product keeps the trip line and drops the catalog row',
|
||||||
() async {
|
() async {
|
||||||
final repository = memoryRepository();
|
final repository = memoryRepository();
|
||||||
final shopId = await repository.saveShop(
|
final shopId = await repository.saveShop(
|
||||||
@@ -24,6 +25,31 @@ void main() {
|
|||||||
expect(items, hasLength(1));
|
expect(items, hasLength(1));
|
||||||
expect(items.single.name, 'Milk');
|
expect(items.single.name, 'Milk');
|
||||||
expect(items.single.productId, isNull);
|
expect(items.single.productId, isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('addShopItem appends a line and updates the trip total', () async {
|
||||||
|
final repository = memoryRepository();
|
||||||
|
final shopId = await repository.saveShop(
|
||||||
|
supermarket: 'Jumbo',
|
||||||
|
shoppedAt: DateTime(2026, 8, 12),
|
||||||
|
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.addShopItem(
|
||||||
|
shopId: shopId,
|
||||||
|
item: LineItem(id: '2', name: 'Bread', price: 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
final shop = (await repository.getShop(shopId))!;
|
||||||
|
expect(shop.total, 3.5);
|
||||||
|
final items = await repository.getShopItems(shopId);
|
||||||
|
expect(items, hasLength(2));
|
||||||
|
expect(items.map((item) => item.name), ['Milk', 'Bread']);
|
||||||
|
expect((await repository.getAll()).map((product) => product.name).toSet(), {
|
||||||
|
'Milk',
|
||||||
|
'Bread',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleting all products leaves shopping trips in place', () async {
|
test('deleting all products leaves shopping trips in place', () async {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('ranks closer matches higher', () {
|
test('ranks closer matches higher', () {
|
||||||
expect(fuzzyScore('melk', 'MELK')! > fuzzyScore('melk', 'JUMBO BIO VOLLE MELK')!, isTrue);
|
expect(
|
||||||
|
fuzzyScore('melk', 'MELK')! > fuzzyScore('melk', 'JUMBO BIO VOLLE MELK')!,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user