6 Commits
Author SHA1 Message Date
gitea cdf46bb12a fixes before build 3 2026-08-31 17:35:50 +02:00
gitea edbfade477 fixes before build 3 2026-08-31 17:35:46 +02:00
gitea b0708101b0 fixes before build 2 2026-08-31 17:05:22 +02:00
gitea 14fcb9a533 fixes before build 2026-08-31 17:03:03 +02:00
gitea 756f9db356 fixes before build 2026-08-31 16:51:31 +02:00
gitea 7692809219 home added 2026-08-31 12:57:17 +02:00
52 changed files with 5458 additions and 1221 deletions
+3 -5
View File
@@ -1,8 +1,6 @@
# Receipity
# Groci
Single-screen Flutter app that proves **capture → OCR**: take a photo of a shopping receipt (or pick one from the gallery), run Google ML Kit Text Recognition, and show the raw extracted text.
No database, no saving, no other screens.
Flutter app for scanning shopping receipts, tracking products and prices, shopping lists, and bonus cards.
## Run
@@ -11,4 +9,4 @@ flutter pub get
flutter run
```
Use a physical Android device or emulator with Google Play. ML Kit text recognition is on-device and does not require a network call.
Use a physical Android device or emulator with Google Play for receipt OCR.
+1 -1
View File
@@ -2,7 +2,7 @@
<uses-permission android:name="android.permission.CAMERA"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<application
android:label="Receipity"
android:label="Groci"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
+1 -1
View File
@@ -1,6 +1,6 @@
# Receipt parsing (implementation)
This is the working guide for Receipity's receipt parser. Use it when a new
This is the working guide for Groci's receipt parser. Use it when a new
shop's receipt does not parse well.
The original design notes live in [`receipt-parsing-spec.md`](../receipt-parsing-spec.md).
+1 -1
View File
@@ -57,7 +57,7 @@ class _CropReceiptScreenState extends State<CropReceiptScreen> {
Future<void> _saveAndPop(Uint8List bytes) async {
try {
final file = File(
'${Directory.systemTemp.path}/receipity_crop_${DateTime.now().millisecondsSinceEpoch}.jpg',
'${Directory.systemTemp.path}/groci_crop_${DateTime.now().millisecondsSinceEpoch}.jpg',
);
await file.writeAsBytes(bytes, flush: true);
if (!mounted) return;
+452
View File
@@ -0,0 +1,452 @@
/// Dashboard of catalog, trips, lists, and this month's spend.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'models/app_overview.dart';
import 'models/shop.dart';
import 'models/supermarket.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'shop_detail_screen.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'widgets/profile_button.dart';
import 'widgets/settings_button.dart';
import 'widgets/store_tag.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({
super.key,
required this.repository,
required this.onOpenProducts,
required this.onOpenLists,
required this.onOpenShopLog,
required this.onOpenAdd,
});
final ProductRepository repository;
final VoidCallback onOpenProducts;
final VoidCallback onOpenLists;
final VoidCallback onOpenShopLog;
final VoidCallback onOpenAdd;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
AppOverview? _overview;
List<Supermarket> _stores = const [];
Object? _error;
int _loadToken = 0;
final ImagePicker _picker = ImagePicker();
@override
void initState() {
super.initState();
widget.repository.addListener(_load);
_load();
}
@override
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final token = ++_loadToken;
try {
final overview = await widget.repository.getOverview();
final stores = await widget.repository.getSupermarkets();
final lastTripStores = await widget.repository.getLastTripSupermarkets();
if (!mounted || token != _loadToken) return;
setState(() {
_overview = overview;
_stores = sortStoresByLastTrip(stores, lastTripStores);
_error = null;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
}
Future<void> _openShop(Shop shop) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) =>
ShopDetailScreen(repository: widget.repository, shopId: shop.id),
),
);
if (mounted) _load();
}
Future<void> _openBonusCard(Supermarket store) async {
final path = store.bonusImagePath;
if (path != null && path.isNotEmpty && File(path).existsSync()) {
await _viewBonusCard(path);
return;
}
await _pickBonusCard(store);
}
Future<void> _viewBonusCard(String path) async {
await showDialog<void>(
context: context,
builder: (context) {
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: InteractiveViewer(
child: Image.file(File(path), fit: BoxFit.contain),
),
);
},
);
}
Future<void> _pickBonusCard(Supermarket store) async {
final source = await showModalBottomSheet<ImageSource>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Take photo'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Pick from gallery'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
);
},
);
if (source == null || store.id == null) return;
final file = await _picker.pickImage(source: source, imageQuality: 85);
if (file == null) return;
await widget.repository.setSupermarketBonusCard(store.id!, file.path);
if (mounted) _load();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Home'),
actions: const [ProfileButton(), SettingsButton()],
),
body: _error != null && _overview == null
? Center(
child: Text(
'Could not load an overview.',
style: theme.textTheme.bodyLarge,
),
)
: _overview == null
? const Center(child: CircularProgressIndicator())
: _HomeBody(
overview: _overview!,
stores: _stores,
onOpenProducts: widget.onOpenProducts,
onOpenLists: widget.onOpenLists,
onOpenShopLog: widget.onOpenShopLog,
onOpenAdd: widget.onOpenAdd,
onOpenShop: _openShop,
onOpenBonusCard: _openBonusCard,
),
);
}
}
class _HomeBody extends StatelessWidget {
const _HomeBody({
required this.overview,
required this.stores,
required this.onOpenProducts,
required this.onOpenLists,
required this.onOpenShopLog,
required this.onOpenAdd,
required this.onOpenShop,
required this.onOpenBonusCard,
});
final AppOverview overview;
final List<Supermarket> stores;
final VoidCallback onOpenProducts;
final VoidCallback onOpenLists;
final VoidCallback onOpenShopLog;
final VoidCallback onOpenAdd;
final ValueChanged<Shop> onOpenShop;
final ValueChanged<Supermarket> onOpenBonusCard;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final settingsStores =
SettingsScope.maybeOf(context)?.supermarkets ?? stores;
final empty =
overview.productCount == 0 &&
overview.tripCount == 0 &&
overview.listCount == 0;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Text('Overview', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
if (empty)
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Text(
'Nothing logged yet.',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Scan a receipt or add a trip to see products, lists, and spending here.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onOpenAdd,
icon: const Icon(Icons.add),
label: const Text('Add a trip'),
),
],
),
),
)
else ...[
Row(
children: [
Expanded(
child: _StatCard(
icon: Icons.receipt_long_outlined,
label: formatMonthYear(DateTime.now()),
value: formatEuro(overview.monthTotal),
detail: overview.monthTripCount == 0
? 'No trips yet'
: overview.monthTripCount == 1
? '1 trip'
: '${overview.monthTripCount} trips',
onTap: onOpenShopLog,
),
),
const SizedBox(width: 8),
Expanded(
child: _StatCard(
icon: Icons.inventory_2_outlined,
label: 'Products',
value: '${overview.productCount}',
detail: overview.favouriteCount == 0
? 'No favourites'
: overview.favouriteCount == 1
? '1 favourite'
: '${overview.favouriteCount} favourites',
onTap: onOpenProducts,
),
),
],
),
const SizedBox(height: 8),
_StatCard(
icon: Icons.checklist_outlined,
label: 'Shopping lists',
value: '${overview.listCount}',
detail: overview.listCount == 1 ? '1 list' : 'Open your lists',
onTap: onOpenLists,
),
],
if (stores.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Bonus cards', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 148,
child: ListView.separated(
key: ValueKey(
stores.isEmpty ? 'bonus-empty' : 'bonus-${stores.first.name}',
),
scrollDirection: Axis.horizontal,
itemCount: stores.length,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final store = stores[index];
return _BonusCardTile(
store: store,
stores: settingsStores,
onTap: () => onOpenBonusCard(store),
);
},
),
),
],
if (overview.recentShops.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Recent trips', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
for (final shop in overview.recentShops)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: shop.name == null || shop.name!.isEmpty
? StoreTag(name: shop.supermarket, stores: settingsStores)
: Text(shop.name!),
subtitle: Text(
[
formatDate(shop.shoppedAt),
formatEuro(shop.total),
].join(' · '),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => onOpenShop(shop),
),
),
],
],
);
}
}
class _BonusCardTile extends StatelessWidget {
const _BonusCardTile({
required this.store,
required this.stores,
required this.onTap,
});
final Supermarket store;
final List<Supermarket> stores;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final path = store.bonusImagePath;
final hasCard =
path != null && path.isNotEmpty && File(path).existsSync();
return SizedBox(
width: 220,
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: !hasCard
? ColoredBox(
color: store.color.withValues(alpha: 0.18),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.card_membership_outlined,
color: store.color,
size: 32,
),
const SizedBox(height: 8),
Text(
'Add bonus card',
style: theme.textTheme.bodySmall,
),
],
),
),
)
: Image.file(
File(path),
fit: BoxFit.cover,
width: double.infinity,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
child: Row(
children: [
StoreTag(name: store.name, stores: stores, compact: true),
const Spacer(),
Icon(
hasCard
? Icons.zoom_in
: Icons.camera_alt_outlined,
size: 16,
color: scheme.onSurfaceVariant,
),
],
),
),
],
),
),
),
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({
required this.icon,
required this.label,
required this.value,
required this.detail,
required this.onTap,
});
final IconData icon;
final String label;
final String value;
final String detail;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: scheme.primary),
const SizedBox(height: 12),
Text(label, style: theme.textTheme.bodyMedium),
const SizedBox(height: 4),
Text(value, style: theme.textTheme.titleLarge),
const SizedBox(height: 2),
Text(detail, style: theme.textTheme.bodyMedium),
],
),
),
),
);
}
}
+49 -22
View File
@@ -1,8 +1,9 @@
/// Bottom navigation: products, lists, add, and shop log.
/// Bottom navigation: home, products, lists, add, and shop log.
library;
import 'package:flutter/material.dart';
import 'home_screen.dart';
import 'products_screen.dart';
import 'scan_screen.dart';
import 'services/app_settings.dart';
@@ -20,11 +21,14 @@ class HomeShell extends StatefulWidget {
}
class _HomeShellState extends State<HomeShell> {
static const _homeIndex = 0;
static const _productsIndex = 1;
static const _addIndex = 2;
static const _shopLogIndex = 3;
static const _listsIndex = 3;
static const _shopLogIndex = 4;
static const _fabGap = 56.0;
int _index = _addIndex;
int _index = _homeIndex;
int _revision = 0;
AppSettings? _settings;
int _seenGeneration = 0;
@@ -77,15 +81,23 @@ class _HomeShellState extends State<HomeShell> {
return Scaffold(
body: switch (_index) {
0 => ProductsScreen(
_homeIndex => HomeScreen(
key: ValueKey('home-$_revision'),
repository: widget.repository,
onOpenProducts: () => _select(_productsIndex),
onOpenLists: () => _select(_listsIndex),
onOpenShopLog: () => _select(_shopLogIndex),
onOpenAdd: () => _select(_addIndex),
),
_productsIndex => ProductsScreen(
key: ValueKey('products-$_revision'),
repository: widget.repository,
),
1 => ShoppingListsScreen(
_listsIndex => ShoppingListsScreen(
key: ValueKey('lists-$_revision'),
repository: widget.repository,
),
2 => ScanScreen(
_addIndex => ScanScreen(
repository: widget.repository,
onTripSaved: _onTripSaved,
),
@@ -118,20 +130,20 @@ class _HomeShellState extends State<HomeShell> {
children: [
Expanded(
child: _NavButton(
icon: Icons.inventory_2_outlined,
selectedIcon: Icons.inventory_2,
label: 'Products',
selected: _index == 0,
onTap: () => _select(0),
icon: Icons.home_outlined,
selectedIcon: Icons.home,
label: 'Home',
selected: _index == _homeIndex,
onTap: () => _select(_homeIndex),
),
),
Expanded(
child: _NavButton(
icon: Icons.checklist_outlined,
selectedIcon: Icons.checklist,
label: 'Lists',
selected: _index == 1,
onTap: () => _select(1),
icon: Icons.inventory_2_outlined,
selectedIcon: Icons.inventory_2,
label: 'Products',
selected: _index == _productsIndex,
onTap: () => _select(_productsIndex),
),
),
],
@@ -139,12 +151,27 @@ class _HomeShellState extends State<HomeShell> {
),
const SizedBox(width: _fabGap),
Expanded(
child: _NavButton(
icon: Icons.receipt_long_outlined,
selectedIcon: Icons.receipt_long,
label: 'Shop log',
selected: _index == _shopLogIndex,
onTap: () => _select(_shopLogIndex),
child: Row(
children: [
Expanded(
child: _NavButton(
icon: Icons.checklist_outlined,
selectedIcon: Icons.checklist,
label: 'Lists',
selected: _index == _listsIndex,
onTap: () => _select(_listsIndex),
),
),
Expanded(
child: _NavButton(
icon: Icons.receipt_long_outlined,
selectedIcon: Icons.receipt_long,
label: 'Shop log',
selected: _index == _shopLogIndex,
onTap: () => _select(_shopLogIndex),
),
),
],
),
),
],
+5 -5
View File
@@ -13,20 +13,20 @@ import 'utils/constants.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await ReceiptParser.loadConfig();
runApp(ReceipityApp());
runApp(GrociApp());
}
class ReceipityApp extends StatefulWidget {
ReceipityApp({super.key, ProductRepository? repository})
class GrociApp extends StatefulWidget {
GrociApp({super.key, ProductRepository? repository})
: repository = repository ?? ProductRepository();
final ProductRepository repository;
@override
State<ReceipityApp> createState() => _ReceipityAppState();
State<GrociApp> createState() => _GrociAppState();
}
class _ReceipityAppState extends State<ReceipityApp> {
class _GrociAppState extends State<GrociApp> {
late final AppSettings _settings;
@override
+24
View File
@@ -0,0 +1,24 @@
/// Counts and recent trips shown on the home dashboard.
library;
import 'shop.dart';
class AppOverview {
const AppOverview({
required this.productCount,
required this.favouriteCount,
required this.tripCount,
required this.monthTripCount,
required this.monthTotal,
required this.listCount,
required this.recentShops,
});
final int productCount;
final int favouriteCount;
final int tripCount;
final int monthTripCount;
final double monthTotal;
final int listCount;
final List<Shop> recentShops;
}
+8
View File
@@ -22,6 +22,14 @@ class LineItem {
/// Negative amount when a discount was folded into [price].
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({
String? id,
String? name,
+1 -1
View File
@@ -72,4 +72,4 @@ class Product {
}
}
enum ProductSort { name, preferred }
enum ProductSort { name, preferred, favourite }
+3 -3
View File
@@ -59,7 +59,7 @@ class ShopItem {
const ShopItem({
required this.id,
required this.shopId,
required this.productId,
this.productId,
required this.name,
required this.price,
this.quantity,
@@ -68,7 +68,7 @@ class ShopItem {
final int id;
final int shopId;
final int productId;
final int? productId;
final String name;
final double price;
final int? quantity;
@@ -78,7 +78,7 @@ class ShopItem {
return ShopItem(
id: map['id']! as int,
shopId: map['shop_id']! as int,
productId: map['product_id']! as int,
productId: map['product_id'] as int?,
name: map['name']! as String,
price: (map['price'] as num).toDouble(),
quantity: map['quantity'] as int?,
+5
View File
@@ -7,6 +7,7 @@ class ShoppingList {
required this.name,
required this.itemCount,
required this.checkedCount,
required this.createdAt,
required this.updatedAt,
});
@@ -14,6 +15,7 @@ class ShoppingList {
final String name;
final int itemCount;
final int checkedCount;
final DateTime createdAt;
final DateTime updatedAt;
factory ShoppingList.fromMap(Map<String, Object?> map) {
@@ -22,6 +24,9 @@ class ShoppingList {
name: map['name']! as String,
itemCount: (map['item_count'] as num?)?.toInt() ?? 0,
checkedCount: (map['checked_count'] as num?)?.toInt() ?? 0,
createdAt: DateTime.parse(
(map['created_at'] ?? map['updated_at'])! as String,
),
updatedAt: DateTime.parse(map['updated_at']! as String),
);
}
+34
View File
@@ -9,12 +9,14 @@ class Supermarket {
required this.name,
required this.colorValue,
this.sortOrder = 0,
this.bonusImagePath,
});
final int? id;
final String name;
final int colorValue;
final int sortOrder;
final String? bonusImagePath;
Color get color => Color(colorValue);
@@ -24,6 +26,7 @@ class Supermarket {
name: map['name']! as String,
colorValue: map['color']! as int,
sortOrder: map['sort_order'] as int? ?? 0,
bonusImagePath: map['bonus_image_path'] as String?,
);
}
@@ -32,16 +35,47 @@ class Supermarket {
String? name,
int? colorValue,
int? sortOrder,
String? bonusImagePath,
bool clearBonusImage = false,
}) {
return Supermarket(
id: id ?? this.id,
name: name ?? this.name,
colorValue: colorValue ?? this.colorValue,
sortOrder: sortOrder ?? this.sortOrder,
bonusImagePath: clearBonusImage
? null
: (bonusImagePath ?? this.bonusImagePath),
);
}
}
/// Puts stores with the most recent trip first, then earlier trips, then unused.
List<Supermarket> sortStoresByLastTrip(
List<Supermarket> stores,
Iterable<String> lastTripStores,
) {
final rank = <String, int>{};
var next = 0;
for (final name in lastTripStores) {
final key = name.trim().toUpperCase();
if (key.isEmpty || rank.containsKey(key)) continue;
rank[key] = next++;
}
final sorted = [...stores];
sorted.sort((a, b) {
final aRank = rank[a.name.trim().toUpperCase()] ?? 1 << 20;
final bRank = rank[b.name.trim().toUpperCase()] ?? 1 << 20;
final byTrip = aRank.compareTo(bRank);
if (byTrip != 0) return byTrip;
final byOrder = a.sortOrder.compareTo(b.sortOrder);
if (byOrder != 0) return byOrder;
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
return sorted;
}
/// Palette offered when the user picks a store color.
const List<int> kStoreColorPalette = [
0xFFEEC21B, // Jumbo yellow
+231 -178
View File
@@ -13,6 +13,7 @@ import 'utils/categories.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'utils/supermarkets.dart';
import 'widgets/pick_shopping_list_sheet.dart';
import 'widgets/price_chart.dart';
import 'widgets/product_image.dart';
import 'widgets/store_tag.dart';
@@ -37,6 +38,7 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
late final TextEditingController _notesController;
final ImagePicker _picker = ImagePicker();
bool _ready = false;
bool _leaving = false;
@override
void initState() {
@@ -48,7 +50,7 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
@override
void dispose() {
_saveNotes();
_saveEdits();
_nameController.dispose();
_notesController.dispose();
super.dispose();
@@ -69,7 +71,18 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
}
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 {
@@ -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 {
await widget.repository.setFavourite(product.id, !product.favourite);
if (mounted) _reload();
@@ -169,6 +191,22 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
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 {
final used = await widget.repository.getUsedCategories();
if (!mounted) return;
@@ -238,10 +276,8 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
Future<void> _openShop(int shopId) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => ShopDetailScreen(
repository: widget.repository,
shopId: shopId,
),
builder: (_) =>
ShopDetailScreen(repository: widget.repository, shopId: shopId),
),
);
if (mounted) _reload();
@@ -250,186 +286,206 @@ class _ProductDetailScreenState extends State<ProductDetailScreen> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stores = SettingsScope.maybeOf(context)?.supermarkets ??
kDefaultSupermarkets;
final stores =
SettingsScope.maybeOf(context)?.supermarkets ?? kDefaultSupermarkets;
return Scaffold(
appBar: AppBar(title: const Text('Product')),
body: FutureBuilder(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text(
'Could not load this product.',
style: theme.textTheme.bodyLarge,
),
);
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
_leave();
},
child: Scaffold(
appBar: AppBar(title: const Text('Product')),
body: FutureBuilder(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text(
'Could not load this product.',
style: theme.textTheme.bodyLarge,
),
);
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final product = snapshot.data!.product;
final purchases = snapshot.data!.purchases;
final product = snapshot.data!.product;
final purchases = snapshot.data!.purchases;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: _chooseImageSource,
child: Stack(
children: [
ProductImage(
name: product.name,
path: product.imagePath,
size: 88,
),
Positioned(
right: 0,
bottom: 0,
child: Material(
color: theme.colorScheme.primary,
shape: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.camera_alt,
size: 14,
color: theme.colorScheme.onPrimary,
),
),
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: _chooseImageSource,
child: Stack(
children: [
ProductImage(
name: product.name,
path: product.imagePath,
size: 88,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _nameController,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Name',
isDense: true,
Positioned(
right: 0,
bottom: 0,
child: Material(
color: theme.colorScheme.primary,
shape: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.camera_alt,
size: 14,
color: theme.colorScheme.onPrimary,
),
onSubmitted: (_) => _saveName(),
onEditingComplete: _saveName,
),
),
IconButton(
tooltip: product.favourite
? 'Remove favourite'
: 'Add favourite',
onPressed: () => _toggleFavourite(product),
icon: Icon(
product.favourite
? Icons.star
: Icons.star_outline,
color: product.favourite
? theme.colorScheme.primary
: null,
),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: InkWell(
onTap: _pickStore,
borderRadius: BorderRadius.circular(8),
child: product.supermarket == null
? Text(
'Add supermarket',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
),
)
: StoreTag(
name: product.supermarket,
stores: stores,
),
),
),
],
],
),
),
),
],
),
const SizedBox(height: 24),
Text(
formatEuro(product.lastPrice),
style: theme.textTheme.headlineMedium,
),
Text('Price per unit', style: theme.textTheme.bodyMedium),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: ActionChip(
avatar: const Icon(Icons.category_outlined, size: 18),
label: Text(product.category ?? 'Add category'),
onPressed: () => _pickCategory(product),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _nameController,
textCapitalization:
TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Name',
isDense: true,
),
onSubmitted: (_) => _saveName(),
onEditingComplete: _saveName,
onTapOutside: (_) => _saveName(),
),
),
IconButton(
tooltip: product.favourite
? 'Remove favourite'
: 'Add favourite',
onPressed: () => _toggleFavourite(product),
icon: Icon(
product.favourite
? Icons.star
: Icons.star_outline,
color: product.favourite
? theme.colorScheme.primary
: null,
),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: InkWell(
onTap: _pickStore,
borderRadius: BorderRadius.circular(8),
child: product.supermarket == null
? Text(
'Add supermarket',
style: theme.textTheme.labelMedium
?.copyWith(
color: theme.colorScheme.primary,
),
)
: StoreTag(
name: product.supermarket,
stores: stores,
),
),
),
],
),
),
],
),
),
const SizedBox(height: 24),
Text(
'Price and purchase history',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
if (purchases.isEmpty)
const SizedBox(height: 24),
Text(
'Scan a receipt to start a history.',
style: theme.textTheme.bodyMedium,
)
else ...[
PriceChart(purchases: purchases),
const SizedBox(height: 8),
...purchases.map((purchase) {
return ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: const Icon(Icons.shopping_bag_outlined),
title: Text(formatEuro(purchase.unitOrLinePrice)),
subtitle: Text(
[
formatDate(purchase.boughtAt),
purchase.supermarket,
if (purchase.quantity != null) '${purchase.quantity}×',
].join(' · '),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _openShop(purchase.shopId),
);
}),
],
const SizedBox(height: 24),
Text('Notes', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
TextField(
controller: _notesController,
minLines: 3,
maxLines: 6,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
hintText: 'Add a note about this product',
formatEuro(product.lastPrice),
style: theme.textTheme.headlineMedium,
),
onEditingComplete: _saveNotes,
onTapOutside: (_) => _saveNotes(),
),
],
);
},
Text('Price per unit', style: theme.textTheme.bodyMedium),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: ActionChip(
avatar: const Icon(Icons.category_outlined, size: 18),
label: Text(product.category ?? 'Add category'),
onPressed: () => _pickCategory(product),
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
onPressed: () => _addToList(product),
icon: const Icon(Icons.playlist_add),
label: const Text('Add to list'),
),
),
const SizedBox(height: 24),
Text(
'Price and purchase history',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
if (purchases.isEmpty)
Text(
'Scan a receipt to start a history.',
style: theme.textTheme.bodyMedium,
)
else ...[
PriceChart(purchases: purchases),
const SizedBox(height: 8),
...purchases.map((purchase) {
return ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: const Icon(Icons.shopping_bag_outlined),
title: Text(formatEuro(purchase.unitOrLinePrice)),
subtitle: Text(
[
formatDate(purchase.boughtAt),
purchase.supermarket,
if (purchase.quantity != null)
'${purchase.quantity}×',
].join(' · '),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _openShop(purchase.shopId),
);
}),
],
const SizedBox(height: 24),
Text('Notes', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
TextField(
controller: _notesController,
minLines: 3,
maxLines: 6,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
hintText: 'Add a note about this product',
),
onEditingComplete: _saveNotes,
onTapOutside: (_) => _saveNotes(),
),
],
);
},
),
),
);
}
@@ -477,10 +533,7 @@ class _CategoryNameDialogState extends State<_CategoryNameDialog> {
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _save,
child: const Text('Save'),
),
FilledButton(onPressed: _save, child: const Text('Save')),
],
);
}
+333 -143
View File
@@ -4,12 +4,17 @@ library;
import 'package:flutter/material.dart';
import 'models/product.dart';
import 'models/supermarket.dart';
import 'product_detail_screen.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'utils/fuzzy.dart';
import 'utils/money.dart';
import 'utils/product_names.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/product_image.dart';
import 'widgets/product_name_conflicts.dart';
import 'widgets/profile_button.dart';
import 'widgets/settings_button.dart';
import 'widgets/store_tag.dart';
@@ -24,33 +29,56 @@ class ProductsScreen extends StatefulWidget {
class _ProductsScreenState extends State<ProductsScreen> {
ProductSort _sort = ProductSort.preferred;
bool _favouritesOnly = false;
bool _searching = false;
bool _editing = false;
final TextEditingController _search = TextEditingController();
late Future<List<Product>> _future;
List<Product>? _products;
ProductNameIssues _nameIssues = const ProductNameIssues(
mergeGroups: [],
spellingVariants: [],
);
Object? _error;
int _loadToken = 0;
bool get _hasProducts => _products != null && _products!.isNotEmpty;
@override
void initState() {
super.initState();
_future = _load();
widget.repository.addListener(_load);
_search.addListener(() => setState(() {}));
_load();
}
@override
void dispose() {
widget.repository.removeListener(_load);
_search.dispose();
super.dispose();
}
Future<List<Product>> _load() {
return widget.repository.getAll(
sort: _sort,
favouritesOnly: _favouritesOnly,
);
}
void _reload() {
setState(() => _future = _load());
Future<void> _load() async {
final token = ++_loadToken;
try {
final products = await widget.repository.getAll(sort: _sort);
final nameIssues = await widget.repository.getProductNameIssues();
if (!mounted || token != _loadToken) return;
setState(() {
_products = products;
_nameIssues = nameIssues;
_error = null;
if (products.isEmpty) {
_editing = false;
if (_searching) {
_searching = false;
_search.clear();
}
}
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
}
List<Product> _filter(List<Product> products) {
@@ -82,13 +110,98 @@ class _ProductsScreenState extends State<ProductsScreen> {
),
),
);
if (mounted) _reload();
if (mounted) _load();
}
Future<void> _removeProduct(Product product) async {
final confirmed = await confirmAction(
context,
title: 'Delete product?',
message:
'"${product.name}" will be removed from your catalog. Trip history keeps the purchase line.',
);
if (!confirmed || !mounted) return;
setState(() {
_products = [
for (final item in _products ?? const <Product>[])
if (item.id != product.id) item,
];
if (_products!.isEmpty) {
_editing = false;
_searching = false;
_search.clear();
}
});
try {
await widget.repository.deleteProduct(product.id);
} catch (_) {
if (mounted) _load();
}
}
Future<void> _removeAllProducts() async {
final confirmed = await confirmAction(
context,
title: 'Delete all products?',
message: 'This removes every product from your catalog. Shopping trips stay in the shop log.',
);
if (!confirmed || !mounted) return;
setState(() {
_products = const [];
_editing = false;
_searching = false;
_search.clear();
});
try {
await widget.repository.deleteAllProducts();
} catch (_) {
if (mounted) _load();
}
}
Set<int> get _issueProductIds => _nameIssues.productIds;
Future<void> _resolveNameIssues({int? focusProductId}) async {
final issues = await widget.repository.getProductNameIssues();
if (!mounted || issues.isEmpty) return;
final resolved = await showProductNameConflictSheet(
context,
repository: widget.repository,
issues: issues,
focusProductId: focusProductId,
);
if (resolved && mounted) _load();
}
Future<void> _toggleFavourite(Product product) async {
final next = !product.favourite;
setState(() {
_products = [
for (final item in _products ?? const <Product>[])
if (item.id == product.id) item.copyWith(favourite: next) else item,
];
if (_sort == ProductSort.favourite && !next) {
_products = [
for (final item in _products!)
if (item.id != product.id) item,
];
if (_products!.isEmpty) _editing = false;
}
});
try {
await widget.repository.setFavourite(product.id, next);
} catch (_) {
if (mounted) _load();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
final products = _products == null
? const <Product>[]
: _filter(_products!);
return Scaffold(
appBar: AppBar(
@@ -106,150 +219,227 @@ class _ProductsScreenState extends State<ProductsScreen> {
)
: const Text('Products'),
actions: [
IconButton(
tooltip: _searching ? 'Close search' : 'Search',
onPressed: () {
setState(() {
_searching = !_searching;
if (!_searching) _search.clear();
});
},
icon: Icon(_searching ? Icons.close : Icons.search),
),
const SettingsButton(),
if (!_editing)
IconButton(
tooltip: _searching ? 'Close search' : 'Search',
onPressed: _hasProducts
? () {
setState(() {
_searching = !_searching;
if (!_searching) _search.clear();
});
}
: null,
icon: Icon(_searching ? Icons.close : Icons.search),
),
if (_hasProducts && _nameIssues.isNotEmpty && !_editing)
IconButton(
tooltip: 'Resolve name issues',
onPressed: _resolveNameIssues,
icon: const Icon(kProductNameConflictIcon),
),
if (_hasProducts)
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() {
_editing = !_editing;
if (_editing) {
_searching = false;
_search.clear();
}
}),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
if (_editing)
IconButton(
tooltip: 'Delete all products',
onPressed: _removeAllProducts,
icon: const Icon(Icons.delete_outline),
),
if (!_editing) ...const [ProfileButton(), SettingsButton()],
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
child: Row(
children: [
Expanded(
child: SegmentedButton<ProductSort>(
segments: const [
ButtonSegment(
value: ProductSort.preferred,
label: Text('Preferred'),
),
ButtonSegment(
value: ProductSort.name,
label: Text('A–Z'),
),
],
selected: {_sort},
onSelectionChanged: (selected) {
setState(() {
_sort = selected.first;
_future = _load();
});
},
),
child: SegmentedButton<ProductSort>(
segments: const [
ButtonSegment(
value: ProductSort.name,
label: Text('A–Z'),
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: 'Favourites',
isSelected: _favouritesOnly,
onPressed: () {
setState(() {
_favouritesOnly = !_favouritesOnly;
_future = _load();
});
},
icon: Icon(
_favouritesOnly ? Icons.star : Icons.star_outline,
),
ButtonSegment(
value: ProductSort.preferred,
label: Text('Frequent'),
),
ButtonSegment(
value: ProductSort.favourite,
icon: Icon(Icons.star),
label: Text('Star'),
),
],
),
),
Expanded(
child: FutureBuilder<List<Product>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text(
'Could not load products.',
style: theme.textTheme.bodyLarge,
),
);
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final products = _filter(snapshot.data!);
if (snapshot.data!.isEmpty) {
return Center(
child: Text(
_favouritesOnly
? 'Star products to keep them as favourites.'
: 'Confirmed receipt items will show up here.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
);
}
if (products.isEmpty) {
return Center(
child: Text(
'No products match this search.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 16),
itemCount: products.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 0,
),
leading: ProductImage(
name: product.name,
path: product.imagePath,
size: 36,
),
title: Text(
product.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: product.supermarket == null
? null
: Padding(
padding: const EdgeInsets.only(top: 2),
child: Align(
alignment: Alignment.centerLeft,
child: StoreTag(
name: product.supermarket,
stores: stores,
compact: true,
),
),
),
trailing: Text(
formatEuro(product.lastPrice),
style: theme.textTheme.titleMedium,
),
onTap: () => _openProduct(product),
);
},
);
selected: {_sort},
onSelectionChanged: (selected) {
setState(() {
_sort = selected.first;
});
_load();
},
),
),
if (_nameIssues.isNotEmpty && !_editing)
ProductNameConflictBanner(
issues: _nameIssues,
onTap: _resolveNameIssues,
),
Expanded(child: _buildList(theme, stores, products)),
],
),
);
}
Widget _buildList(
ThemeData theme,
List<Supermarket> stores,
List<Product> products,
) {
if (_error != null && _products == null) {
return Center(
child: Text(
'Could not load products.',
style: theme.textTheme.bodyLarge,
),
);
}
if (_products == null) {
return const Center(child: CircularProgressIndicator());
}
if (_products!.isEmpty) {
return Center(
child: Text(
_sort == ProductSort.favourite
? 'Star products to keep them as favourites.'
: 'Confirmed receipt items will show up here.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
);
}
if (products.isEmpty) {
return Center(
child: Text(
'No products match this search.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 16),
itemCount: products.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final product = products[index];
final hasIssue = _issueProductIds.contains(product.id);
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 0,
),
minLeadingWidth: _editing ? 72 : 36,
leading: _editing
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Delete',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
onPressed: () => _removeProduct(product),
icon: const Icon(Icons.delete_outline),
),
IconButton(
tooltip: product.favourite
? 'Remove favourite'
: 'Favourite',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
onPressed: () => _toggleFavourite(product),
icon: Icon(
product.favourite ? Icons.star : Icons.star_outline,
),
),
],
)
: Stack(
clipBehavior: Clip.none,
children: [
ProductImage(
name: product.name,
path: product.imagePath,
size: 36,
),
if (hasIssue && !_editing)
Positioned(
right: -2,
top: -2,
child: ProductNameConflictMarker(
compact: true,
onTap: () =>
_resolveNameIssues(focusProductId: product.id),
),
),
],
),
title: Text(
product.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: product.supermarket == null
? null
: Padding(
padding: const EdgeInsets.only(top: 2),
child: Align(
alignment: Alignment.centerLeft,
child: StoreTag(
name: product.supermarket,
stores: stores,
compact: true,
),
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (hasIssue && !_editing)
Padding(
padding: const EdgeInsets.only(right: 4),
child: ProductNameConflictMarker(
onTap: () => _resolveNameIssues(focusProductId: product.id),
),
),
Text(
formatEuro(product.lastPrice),
style: theme.textTheme.titleMedium,
),
],
),
onTap: _editing ? null : () => _openProduct(product),
);
},
);
}
}
+44
View File
@@ -0,0 +1,44 @@
/// User profile placeholder. Sign-in will be added later.
library;
import 'package:flutter/material.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: ListView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 32),
children: [
CircleAvatar(
radius: 40,
backgroundColor: scheme.secondaryContainer,
child: Icon(
Icons.person_outline,
size: 40,
color: scheme.onSecondaryContainer,
),
),
const SizedBox(height: 16),
Text(
'Guest',
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Sign in, cloud sync, and account settings will show up here later.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
],
),
);
}
}
+49 -11
View File
@@ -1,6 +1,8 @@
/// Editable list of parsed receipt lines before they are saved as a shop trip.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'models/line_item.dart';
@@ -9,6 +11,7 @@ import 'services/product_repository.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'utils/supermarkets.dart';
import 'widgets/discounted_price.dart';
import 'widgets/item_edit_sheet.dart';
import 'widgets/store_tag.dart';
import 'widgets/tag_editor.dart';
@@ -112,6 +115,24 @@ class _ReviewScreenState extends State<ReviewScreen> {
setState(() => _shoppedAt = picked);
}
Future<void> _openReceipt() async {
final path = widget.imagePath;
if (path == null || path.isEmpty) return;
final file = File(path);
if (!file.existsSync()) return;
await showDialog<void>(
context: context,
builder: (context) {
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: InteractiveViewer(
child: Image.file(file, fit: BoxFit.contain),
),
);
},
);
}
Future<void> _confirm() async {
final supermarket = _resolvedSupermarket;
if (supermarket == null) {
@@ -159,6 +180,10 @@ class _ReviewScreenState extends State<ReviewScreen> {
if (_supermarket != null && !names.contains(_supermarket)) {
names.add(_supermarket!);
}
final receiptPath = widget.imagePath;
final hasReceipt = receiptPath != null &&
receiptPath.isNotEmpty &&
File(receiptPath).existsSync();
return Scaffold(
appBar: AppBar(
@@ -302,10 +327,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
child: ListTile(
title: Text(item.name),
subtitle: _itemSubtitle(item),
trailing: Text(
formatEuro(item.price),
style: theme.textTheme.titleMedium,
),
trailing: DiscountedPrice(item: item),
onTap: _saving ? null : () => _edit(existing: item),
),
);
@@ -315,6 +337,27 @@ class _ReviewScreenState extends State<ReviewScreen> {
],
),
),
if (hasReceipt)
ExpansionTile(
title: const Text('See receipt'),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: GestureDetector(
onTap: _openReceipt,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(receiptPath),
fit: BoxFit.contain,
height: 220,
width: double.infinity,
),
),
),
),
],
),
if (widget.rawText.isNotEmpty)
ExpansionTile(
title: const Text('Raw OCR text'),
@@ -356,12 +399,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
}
Widget? _itemSubtitle(LineItem item) {
final parts = [
if (item.quantity != null)
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
if (item.discount != 0) 'Discount ${formatEuro(item.discount.abs())}',
];
if (parts.isEmpty) return null;
return Text(parts.join(' · '));
if (item.quantity == null) return null;
return Text('${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}');
}
}
+5 -2
View File
@@ -16,6 +16,7 @@ import 'services/product_repository.dart';
import 'services/receipt_parser.dart';
import 'utils/constants.dart';
import 'utils/supermarkets.dart';
import 'widgets/profile_button.dart';
import 'widgets/settings_button.dart';
class ScanScreen extends StatefulWidget {
@@ -53,6 +54,8 @@ class _ScanScreenState extends State<ScanScreen> {
}
} on UnimplementedError {
// Desktop / platforms that do not support lost-data recovery.
} on MissingPluginException {
// Widget tests and hosts without the image_picker plugin.
}
}
@@ -66,7 +69,7 @@ class _ScanScreenState extends State<ScanScreen> {
try {
final data = await rootBundle.load(kSampleReceiptAsset);
final file = File(
'${Directory.systemTemp.path}/receipity_sample_receipt.jpeg',
'${Directory.systemTemp.path}/groci_sample_receipt.jpeg',
);
await file.writeAsBytes(data.buffer.asUint8List(), flush: true);
if (!mounted) return;
@@ -182,7 +185,7 @@ class _ScanScreenState extends State<ScanScreen> {
return Scaffold(
appBar: AppBar(
title: const Text(kAppName),
actions: const [SettingsButton()],
actions: const [ProfileButton(), SettingsButton()],
),
body: SafeArea(
child: Padding(
+12 -1
View File
@@ -68,6 +68,15 @@ class AppSettings extends ChangeNotifier {
catalogGeneration++;
notifyListeners();
}
Future<void> wipeAllData() async {
await repository.wipeAllData();
darkMode = false;
supermarkets = List<Supermarket>.from(kDefaultSupermarkets);
catalogGeneration++;
notifyListeners();
await load();
}
}
class SettingsScope extends InheritedNotifier<AppSettings> {
@@ -84,6 +93,8 @@ class SettingsScope extends InheritedNotifier<AppSettings> {
}
static AppSettings? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<SettingsScope>()?.notifier;
return context
.dependOnInheritedWidgetOfExactType<SettingsScope>()
?.notifier;
}
}
+7 -3
View File
@@ -5,6 +5,9 @@ import 'dart:convert';
const int kBackupVersion = 3;
const String kBackupFormat = 'groci-backup';
const String kLegacyBackupFormat = 'receipity-backup';
class BackupDocument {
const BackupDocument({
required this.version,
@@ -33,7 +36,7 @@ class BackupDocument {
final Map<String, String> images;
Map<String, Object?> toJson() => {
'format': 'receipity-backup',
'format': kBackupFormat,
'version': version,
'exportedAt': exportedAt,
'darkMode': darkMode,
@@ -54,8 +57,9 @@ class BackupDocument {
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Backup is not a JSON object.');
}
if (decoded['format'] != 'receipity-backup') {
throw const FormatException('This file is not a Receipity backup.');
final format = decoded['format'];
if (format != kBackupFormat && format != kLegacyBackupFormat) {
throw const FormatException('This file is not a Groci backup.');
}
final version = decoded['version'];
if (version is! int || version < 1 || version > kBackupVersion) {
+7 -6
View File
@@ -1,4 +1,4 @@
/// Build, share, and restore a Receipity JSON backup.
/// Build, share, and restore a Groci JSON backup.
library;
import 'dart:convert';
@@ -68,11 +68,11 @@ class BackupService {
final document = await buildBackup();
final bytes = Uint8List.fromList(utf8.encode(document.encode()));
final stamp = DateTime.now().toIso8601String().split('T').first;
final fileName = 'receipity-backup-$stamp.json';
final fileName = 'groci-backup-$stamp.json';
try {
final saved = await FilePicker.saveFile(
dialogTitle: 'Save Receipity backup',
dialogTitle: 'Save Groci backup',
fileName: fileName,
bytes: bytes,
mimeType: 'application/json',
@@ -90,8 +90,8 @@ class BackupService {
final result = await SharePlus.instance.share(
ShareParams(
files: [XFile(file.path, mimeType: 'application/json')],
subject: 'Receipity backup',
text: 'Receipity backup $stamp',
subject: 'Groci backup',
text: 'Groci backup $stamp',
),
);
return result.status != ShareResultStatus.dismissed;
@@ -99,7 +99,7 @@ class BackupService {
Future<bool> importBackup() async {
final picked = await FilePicker.pickFile(
dialogTitle: 'Import Receipity backup',
dialogTitle: 'Import Groci backup',
type: FileType.custom,
allowedExtensions: const ['json'],
);
@@ -126,6 +126,7 @@ class BackupService {
if (copy['normalized_name'] == null && copy['name'] is String) {
copy['normalized_name'] = ProductRepository.normalizeName(
copy['name']! as String,
supermarket: copy['supermarket'] as String?,
);
}
products.add(copy);
+679 -55
View File
@@ -1,12 +1,15 @@
/// Local SQLite store of products and shopping trips.
library;
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import '../models/app_overview.dart';
import '../models/line_item.dart';
import '../models/product.dart';
import '../models/purchase.dart';
@@ -14,26 +17,35 @@ import '../models/shop.dart';
import '../models/shopping_list.dart';
import '../models/supermarket.dart';
import '../utils/money.dart';
import '../utils/product_names.dart';
import '../utils/supermarkets.dart';
class ProductRepository {
class ProductRepository extends ChangeNotifier {
ProductRepository({this.databasePath});
final String? databasePath;
Database? _db;
Future<Database>? _opening;
Future<Database> get _database async {
_db ??= await _open();
return _db!;
Future<Database> get _database {
_opening ??= _open();
return _opening!;
}
Future<String> _defaultDatabasePath() async {
final dir = (await getApplicationDocumentsDirectory()).path;
final groci = p.join(dir, 'groci.db');
final legacy = p.join(dir, 'receipity.db');
if (!File(groci).existsSync() && File(legacy).existsSync()) {
await File(legacy).rename(groci);
}
return groci;
}
Future<Database> _open() async {
final path =
databasePath ??
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
final path = databasePath ?? await _defaultDatabasePath();
return openDatabase(
path,
version: 6,
version: 9,
onConfigure: (db) async {
await db.execute('PRAGMA foreign_keys = ON');
},
@@ -67,6 +79,17 @@ class ProductRepository {
if (oldVersion < 6) {
await _upgradeShopsToV6(db);
}
if (oldVersion < 7) {
await _upgradeShopItemsProductFk(db);
}
if (oldVersion < 8) {
await db.execute(
'ALTER TABLE supermarkets ADD COLUMN bonus_image_path TEXT',
);
}
if (oldVersion < 9) {
await _upgradeProductNormalizedNamesV9(db);
}
},
);
}
@@ -102,7 +125,8 @@ class ProductRepository {
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color INTEGER NOT NULL,
sort_order INTEGER NOT NULL
sort_order INTEGER NOT NULL,
bonus_image_path TEXT
)
''');
}
@@ -135,13 +159,13 @@ class ProductRepository {
CREATE TABLE shop_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
shop_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
product_id INTEGER,
name TEXT NOT NULL,
price REAL NOT NULL,
quantity INTEGER,
unit_price REAL,
FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id)
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE SET NULL
)
''');
await _createShopTagTable(db);
@@ -169,6 +193,31 @@ class ProductRepository {
await _createShopTagTable(db);
}
Future<void> _upgradeShopItemsProductFk(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE shop_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
shop_id INTEGER NOT NULL,
product_id INTEGER,
name TEXT NOT NULL,
price REAL NOT NULL,
quantity INTEGER,
unit_price REAL,
FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE SET NULL
)
''');
await db.execute('''
INSERT INTO shop_items_new (
id, shop_id, product_id, name, price, quantity, unit_price
)
SELECT id, shop_id, product_id, name, price, quantity, unit_price
FROM shop_items
''');
await db.execute('DROP TABLE shop_items');
await db.execute('ALTER TABLE shop_items_new RENAME TO shop_items');
}
Future<void> _createShoppingListTables(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS shopping_lists (
@@ -192,8 +241,28 @@ class ProductRepository {
''');
}
static String normalizeName(String name) =>
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
static String normalizeName(String name, {String? supermarket}) =>
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 {
final dir = await getApplicationDocumentsDirectory();
@@ -268,6 +337,7 @@ class ProductRepository {
}
}
notifyListeners();
return shopId;
}
@@ -277,7 +347,7 @@ class ProductRepository {
required String supermarket,
required String now,
}) async {
final key = normalizeName(item.name);
final key = normalizeName(item.name, supermarket: supermarket);
final existing = await txn.query(
'products',
where: 'normalized_name = ?',
@@ -328,12 +398,13 @@ class ProductRepository {
Future<List<Product>> getAll({
ProductSort sort = ProductSort.name,
bool favouritesOnly = false,
}) async {
final db = await _database;
final favouritesOnly = sort == ProductSort.favourite;
final orderBy = switch (sort) {
ProductSort.name => 'name COLLATE NOCASE ASC',
ProductSort.preferred => 'times_seen DESC, name COLLATE NOCASE ASC',
ProductSort.favourite => 'name COLLATE NOCASE ASC',
};
final rows = await db.rawQuery('''
SELECT
@@ -353,6 +424,174 @@ class ProductRepository {
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 {
final db = await _database;
final productCount =
Sqflite.firstIntValue(
await db.rawQuery('SELECT COUNT(*) FROM products'),
) ??
0;
final favouriteCount =
Sqflite.firstIntValue(
await db.rawQuery(
'SELECT COUNT(*) FROM products WHERE favourite = 1',
),
) ??
0;
final tripCount =
Sqflite.firstIntValue(
await db.rawQuery('SELECT COUNT(*) FROM shops'),
) ??
0;
final listCount =
Sqflite.firstIntValue(
await db.rawQuery('SELECT COUNT(*) FROM shopping_lists'),
) ??
0;
final now = DateTime.now();
final monthStart = DateTime(now.year, now.month, 1).toIso8601String();
final monthRows = await db.rawQuery(
'''
SELECT COUNT(*) AS trip_count, COALESCE(SUM(total), 0) AS month_total
FROM shops
WHERE shopped_at >= ?
''',
[monthStart],
);
final month = monthRows.first;
final shops = await getShops();
return AppOverview(
productCount: productCount,
favouriteCount: favouriteCount,
tripCount: tripCount,
monthTripCount: (month['trip_count'] as num?)?.toInt() ?? 0,
monthTotal: (month['month_total'] as num?)?.toDouble() ?? 0,
listCount: listCount,
recentShops: shops.take(3).toList(),
);
}
Future<Product?> getProduct(int id) async {
final db = await _database;
final rows = await db.rawQuery(
@@ -404,13 +643,28 @@ class ProductRepository {
bool clearCategory = false,
}) async {
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?>{
'updated_at': DateTime.now().toIso8601String(),
'normalized_name': normalizeName(
nextName,
supermarket: nextSupermarket,
),
};
if (name != null) {
values['name'] = name.trim();
values['normalized_name'] = normalizeName(name);
}
if (name != null) values['name'] = nextName;
if (favourite != null) values['favourite'] = favourite ? 1 : 0;
if (clearSupermarket) {
values['supermarket'] = null;
@@ -433,6 +687,7 @@ class ProductRepository {
values['category'] = category.trim();
}
await db.update('products', values, where: 'id = ?', whereArgs: [id]);
notifyListeners();
}
Future<void> setFavourite(int id, bool favourite) =>
@@ -444,6 +699,199 @@ class ProductRepository {
return stored;
}
Future<void> deleteProduct(int id) async {
final db = await _database;
final rows = await db.query(
'products',
columns: ['image_path'],
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
await db.transaction((txn) async {
await txn.update(
'shop_items',
{'product_id': null},
where: 'product_id = ?',
whereArgs: [id],
);
await txn.update(
'shopping_list_items',
{'product_id': null},
where: 'product_id = ?',
whereArgs: [id],
);
await txn.delete('products', where: 'id = ?', whereArgs: [id]);
});
if (rows.isNotEmpty) {
await _tryDeleteFile(rows.first['image_path'] as String?);
}
notifyListeners();
}
Future<void> deleteAllProducts() async {
final db = await _database;
final rows = await db.query('products', columns: ['image_path']);
await db.transaction((txn) async {
await txn.update('shop_items', {'product_id': null});
await txn.update('shopping_list_items', {'product_id': null});
await txn.delete('products');
});
for (final row in rows) {
await _tryDeleteFile(row['image_path'] as String?);
}
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 {
if (path == null || path.isEmpty) return;
try {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
}
Future<List<Purchase>> getPurchases(int productId) async {
final db = await _database;
final rows = await db.rawQuery(
@@ -560,22 +1008,26 @@ class ProductRepository {
required int id,
String? name,
bool clearName = false,
DateTime? shoppedAt,
List<String>? tags,
}) async {
final db = await _database;
await db.transaction((txn) async {
final values = <String, Object?>{};
if (clearName || name != null) {
await txn.update(
'shops',
{'name': clearName ? null : _optionalText(name)},
where: 'id = ?',
whereArgs: [id],
);
values['name'] = clearName ? null : _optionalText(name);
}
if (shoppedAt != null) {
values['shopped_at'] = shoppedAt.toIso8601String();
}
if (values.isNotEmpty) {
await txn.update('shops', values, where: 'id = ?', whereArgs: [id]);
}
if (tags != null) {
await _replaceShopTags(txn, shopId: id, tags: tags);
}
});
notifyListeners();
}
Future<void> _replaceShopTags(
@@ -624,12 +1076,53 @@ class ProductRepository {
await txn.delete('shop_tags', where: 'shop_id = ?', whereArgs: [id]);
await txn.delete('shops', where: 'id = ?', whereArgs: [id]);
});
final path = shop?.receiptImagePath;
if (path == null || path.isEmpty) return;
final file = File(path);
if (await file.exists()) {
await file.delete();
}
await _tryDeleteFile(shop?.receiptImagePath);
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.
@@ -646,6 +1139,7 @@ class ProductRepository {
final shopId = rows.first['shop_id']! as int;
await db.delete('shop_items', where: 'id = ?', whereArgs: [id]);
await _recalculateShopTotal(shopId);
notifyListeners();
}
Future<void> updateShopItem({
@@ -654,6 +1148,7 @@ class ProductRepository {
double? price,
int? quantity,
double? unitPrice,
bool clearQuantity = false,
}) async {
final db = await _database;
final rows = await db.query(
@@ -667,11 +1162,17 @@ class ProductRepository {
final values = <String, Object?>{};
if (name != null) values['name'] = name.trim();
if (price != null) values['price'] = price;
if (quantity != null) values['quantity'] = quantity;
if (unitPrice != null) values['unit_price'] = unitPrice;
if (clearQuantity) {
values['quantity'] = null;
values['unit_price'] = null;
} else {
if (quantity != null) values['quantity'] = quantity;
if (unitPrice != null) values['unit_price'] = unitPrice;
}
if (values.isEmpty) return;
await db.update('shop_items', values, where: 'id = ?', whereArgs: [id]);
await _recalculateShopTotal(shopId);
notifyListeners();
}
Future<void> _recalculateShopTotal(int shopId) async {
@@ -695,6 +1196,7 @@ class ProductRepository {
SELECT
shopping_lists.id,
shopping_lists.name,
shopping_lists.created_at,
shopping_lists.updated_at,
COUNT(shopping_list_items.id) AS item_count,
COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count
@@ -714,6 +1216,7 @@ class ProductRepository {
SELECT
shopping_lists.id,
shopping_lists.name,
shopping_lists.created_at,
shopping_lists.updated_at,
COUNT(shopping_list_items.id) AS item_count,
COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count
@@ -732,11 +1235,13 @@ class ProductRepository {
Future<int> createShoppingList(String name) async {
final db = await _database;
final now = DateTime.now().toIso8601String();
return db.insert('shopping_lists', {
final id = await db.insert('shopping_lists', {
'name': name.trim(),
'created_at': now,
'updated_at': now,
});
notifyListeners();
return id;
}
Future<void> renameShoppingList(int id, String name) async {
@@ -747,6 +1252,19 @@ class ProductRepository {
where: 'id = ?',
whereArgs: [id],
);
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 {
@@ -759,6 +1277,7 @@ class ProductRepository {
);
await txn.delete('shopping_lists', where: 'id = ?', whereArgs: [id]);
});
notifyListeners();
}
Future<List<ShoppingListItem>> getShoppingListItems(int listId) async {
@@ -777,22 +1296,72 @@ class ProductRepository {
required String name,
int? productId,
}) async {
final db = await _database;
final max = Sqflite.firstIntValue(
await db.rawQuery(
'SELECT MAX(sort_order) FROM shopping_list_items WHERE list_id = ?',
[listId],
),
final added = await addShoppingListItems(
listId: listId,
items: [(name: name, productId: productId)],
);
final id = await db.insert('shopping_list_items', {
'list_id': listId,
'product_id': productId,
'name': name.trim(),
'checked': 0,
'sort_order': (max ?? -1) + 1,
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 added = <ShoppingListItem>[];
await db.transaction((txn) async {
var max = Sqflite.firstIntValue(
await txn.rawQuery(
'SELECT MAX(sort_order) FROM shopping_list_items WHERE list_id = ?',
[listId],
),
);
var sortOrder = (max ?? -1) + 1;
for (final item in items) {
final name = item.name.trim();
if (name.isEmpty) continue;
final id = await txn.insert('shopping_list_items', {
'list_id': listId,
'product_id': item.productId,
'name': name,
'checked': 0,
'sort_order': sortOrder++,
});
added.add(
ShoppingListItem(
id: id,
listId: listId,
name: name,
checked: false,
sortOrder: sortOrder - 1,
productId: item.productId,
),
);
}
await txn.update(
'shopping_lists',
{'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [listId],
);
});
await _touchShoppingList(listId);
return id;
notifyListeners();
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({
@@ -822,6 +1391,7 @@ class ProductRepository {
whereArgs: [id],
);
await _touchShoppingList(rows.first['list_id']! as int);
notifyListeners();
}
Future<void> deleteShoppingListItem(int id) async {
@@ -836,6 +1406,7 @@ class ProductRepository {
if (rows.isEmpty) return;
await db.delete('shopping_list_items', where: 'id = ?', whereArgs: [id]);
await _touchShoppingList(rows.first['list_id']! as int);
notifyListeners();
}
Future<void> _touchShoppingList(int id) async {
@@ -881,6 +1452,21 @@ class ProductRepository {
return rows.map(Supermarket.fromMap).toList();
}
/// Store names from newest trip to oldest, unique.
Future<List<String>> getLastTripSupermarkets() async {
final db = await _database;
final rows = await db.rawQuery('''
SELECT supermarket
FROM shops
GROUP BY supermarket
ORDER BY MAX(shopped_at) DESC, MAX(id) DESC
''');
return [
for (final row in rows)
if (row['supermarket'] is String) row['supermarket']! as String,
];
}
Future<Supermarket> addSupermarket({
required String name,
required int colorValue,
@@ -920,6 +1506,7 @@ class ProductRepository {
'name': store.name.trim(),
'color': store.colorValue,
'sort_order': store.sortOrder,
'bonus_image_path': store.bonusImagePath,
},
where: 'id = ?',
whereArgs: [id],
@@ -959,15 +1546,45 @@ class ProductRepository {
}
Future<void> clearImageFolders() async {
final dir = await getApplicationDocumentsDirectory();
for (final name in ['receipts', 'products']) {
final folder = Directory(p.join(dir.path, name));
if (await folder.exists()) {
await folder.delete(recursive: true);
try {
final dir = await getApplicationDocumentsDirectory();
for (final name in ['receipts', 'products', 'bonus_cards']) {
final folder = Directory(p.join(dir.path, name));
if (await folder.exists()) {
await folder.delete(recursive: true);
}
}
} catch (_) {
// Platform folders are unavailable in some unit tests.
}
}
/// Deletes all app data and restores default supermarkets.
Future<void> wipeAllData() async {
final db = await _database;
await db.transaction((txn) async {
await txn.delete('shopping_list_items');
await txn.delete('shopping_lists');
await txn.delete('shop_tags');
await txn.delete('shop_items');
await txn.delete('shops');
await txn.delete('products');
await txn.delete('supermarkets');
await txn.delete('app_settings');
await _resetSequence(txn, 'supermarkets');
await _resetSequence(txn, 'products');
await _resetSequence(txn, 'shops');
await _resetSequence(txn, 'shop_items');
await _resetSequence(txn, 'shop_tags');
await _resetSequence(txn, 'shopping_lists');
await _resetSequence(txn, 'shopping_list_items');
await _seedSupermarkets(txn);
});
await clearImageFolders();
notifyListeners();
}
Future<void> replaceAllData({
required List<Map<String, Object?>> supermarkets,
required List<Map<String, Object?>> products,
@@ -1026,9 +1643,16 @@ class ProductRepository {
await _resetSequence(txn, 'shopping_lists');
await _resetSequence(txn, 'shopping_list_items');
});
notifyListeners();
}
static const _supermarketColumns = {'id', 'name', 'color', 'sort_order'};
static const _supermarketColumns = {
'id',
'name',
'color',
'sort_order',
'bonus_image_path',
};
static const _productColumns = {
'id',
'name',
+178 -29
View File
@@ -31,14 +31,18 @@ class ShopDetailScreen extends StatefulWidget {
}
class _ShopDetailScreenState extends State<ShopDetailScreen> {
late Future<({Shop shop, List<ShopItem> items})> _future;
Shop? _shop;
List<ShopItem>? _items;
Object? _error;
bool _editing = false;
List<String> _tagSuggestions = [];
int _loadToken = 0;
@override
void initState() {
super.initState();
_future = _load();
widget.repository.addListener(_load);
_load();
widget.repository
.getUsedShopTags()
.then((tags) {
@@ -47,17 +51,28 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
.catchError((_) {});
}
Future<({Shop shop, List<ShopItem> items})> _load() async {
final shop = await widget.repository.getShop(widget.shopId);
if (shop == null) {
throw StateError('Shop not found');
}
final items = await widget.repository.getShopItems(widget.shopId);
return (shop: shop, items: items);
@override
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
void _reload() {
setState(() => _future = _load());
Future<void> _load() async {
final token = ++_loadToken;
try {
final shop = await widget.repository.getShop(widget.shopId);
if (shop == null) throw StateError('Shop not found');
final items = await widget.repository.getShopItems(widget.shopId);
if (!mounted || token != _loadToken) return;
setState(() {
_shop = shop;
_items = items;
_error = null;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
}
Future<void> _openReceipt(File file) async {
@@ -85,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 {
final result = await showItemEditSheet(
context,
@@ -103,8 +168,9 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
price: result.price,
quantity: result.quantity,
unitPrice: result.unitPrice,
clearQuantity: result.quantity == null,
);
if (mounted) _reload();
if (mounted) _load();
}
Future<void> _removeItem(ShopItem item) async {
@@ -116,8 +182,59 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
confirmLabel: 'Remove',
);
if (!confirmed || !mounted) return;
await widget.repository.deleteShopItem(item.id);
if (mounted) _reload();
final isLastItem = (_items?.length ?? 0) == 1;
final shop = _shop;
setState(() {
_items = [
for (final entry in _items ?? const <ShopItem>[])
if (entry.id != item.id) entry,
];
if (shop != null) {
_shop = Shop(
id: shop.id,
supermarket: shop.supermarket,
shoppedAt: shop.shoppedAt,
total: roundMoney(shop.total - item.price),
itemCount: shop.itemCount - 1,
name: shop.name,
receiptImagePath: shop.receiptImagePath,
tags: shop.tags,
);
}
});
try {
await widget.repository.deleteShopItem(item.id);
if (!mounted || !isLastItem || shop == null) return;
await _confirmRemoveEmptyTrip(shop);
} catch (_) {
if (mounted) _load();
}
}
Future<void> _confirmRemoveEmptyTrip(Shop shop) async {
final removeTrip = await confirmAction(
context,
title: 'Remove trip?',
message:
'This trip has no products left. Remove it from your shop log? '
'Products from this trip stay in your catalog.',
confirmLabel: 'Remove trip',
);
if (!mounted) return;
if (!removeTrip) return;
try {
await widget.repository.deleteShop(shop.id);
if (mounted) Navigator.of(context).pop();
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not remove this trip.')),
);
_load();
}
}
Future<void> _editName(Shop shop) async {
@@ -134,12 +251,27 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
name: name,
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 {
await widget.repository.updateShop(id: shop.id, tags: tags);
if (mounted) _reload();
if (mounted) _load();
}
Widget? _itemSubtitle(ShopItem item) {
@@ -167,10 +299,16 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
),
],
),
body: FutureBuilder(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
floatingActionButton: _editing
? FloatingActionButton.extended(
onPressed: _addItem,
icon: const Icon(Icons.add),
label: const Text('Add product'),
)
: null,
body: Builder(
builder: (context) {
if (_error != null) {
return Center(
child: Text(
'Could not load this trip.',
@@ -178,18 +316,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());
}
final shop = snapshot.data!.shop;
final items = snapshot.data!.items;
final receipt = shop.receiptImagePath;
final receiptFile = receipt == null ? null : File(receipt);
final hasReceipt = receiptFile != null && receiptFile.existsSync();
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 96),
children: [
if (_editing)
ListTile(
@@ -210,10 +348,19 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
Text(shop.supermarket, style: theme.textTheme.titleMedium),
],
const SizedBox(height: 4),
Text(
formatDate(shop.shoppedAt),
style: theme.textTheme.bodyLarge,
),
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(
formatDate(shop.shoppedAt),
style: theme.textTheme.bodyLarge,
),
if (_editing) ...[
const SizedBox(height: 16),
TagEditor(
@@ -276,7 +423,9 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
formatEuro(item.price),
style: theme.textTheme.titleMedium,
),
onTap: _editing ? null : () => _openProduct(item.productId),
onTap: _editing || item.productId == null
? null
: () => _openProduct(item.productId!),
),
const Divider(),
ListTile(
+248 -153
View File
@@ -13,6 +13,7 @@ import 'utils/money.dart';
import 'utils/shop_calendar.dart';
import 'utils/shop_log_filter.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/profile_button.dart';
import 'widgets/settings_button.dart';
import 'widgets/shop_heatmap.dart';
import 'widgets/store_tag.dart';
@@ -28,8 +29,10 @@ class ShopLogScreen extends StatefulWidget {
}
class _ShopLogScreenState extends State<ShopLogScreen> {
late Future<({List<Shop> shops, Map<int, List<String>> productNames})>
_future;
List<Shop>? _shops;
Map<int, List<String>> _productNames = const {};
Object? _error;
int _loadToken = 0;
final _search = TextEditingController();
bool _editing = false;
bool _searching = false;
@@ -37,28 +40,45 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
DateTime? _selectedDay;
String? _tagFilter;
bool get _hasTrips => _shops != null && _shops!.isNotEmpty;
@override
void initState() {
super.initState();
_future = _load();
widget.repository.addListener(_load);
_search.addListener(() => setState(() {}));
_load();
}
@override
void dispose() {
widget.repository.removeListener(_load);
_search.dispose();
super.dispose();
}
Future<({List<Shop> shops, Map<int, List<String>> productNames})>
_load() async {
final shops = await widget.repository.getShops();
final productNames = await widget.repository.getShopItemNamesByShop();
return (shops: shops, productNames: productNames);
}
void _reload() {
setState(() => _future = _load());
Future<void> _load() async {
final token = ++_loadToken;
try {
final shops = await widget.repository.getShops();
final productNames = await widget.repository.getShopItemNamesByShop();
if (!mounted || token != _loadToken) return;
setState(() {
_shops = shops;
_productNames = productNames;
_error = null;
if (shops.isEmpty) {
_editing = false;
if (_searching) {
_searching = false;
_search.clear();
}
}
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
}
Future<void> _openShop(Shop shop) async {
@@ -68,7 +88,7 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
ShopDetailScreen(repository: widget.repository, shopId: shop.id),
),
);
if (mounted) _reload();
if (mounted) _load();
}
Future<void> _removeShop(Shop shop) async {
@@ -78,8 +98,23 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
message: 'This trip will be removed from the shop log. Products from this trip stay in your catalog.',
);
if (!confirmed || !mounted) return;
await widget.repository.deleteShop(shop.id);
if (mounted) _reload();
setState(() {
_shops = [
for (final item in _shops ?? const <Shop>[])
if (item.id != shop.id) item,
];
_productNames = Map.of(_productNames)..remove(shop.id);
if (_shops!.isEmpty) {
_editing = false;
_searching = false;
_search.clear();
}
});
try {
await widget.repository.deleteShop(shop.id);
} catch (_) {
if (mounted) _load();
}
}
void _selectDay(DateTime? day) {
@@ -120,142 +155,202 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
actions: [
IconButton(
tooltip: _searching ? 'Close search' : 'Search products',
onPressed: () {
setState(() {
_searching = !_searching;
if (!_searching) _search.clear();
});
},
onPressed: _hasTrips
? () {
setState(() {
_searching = !_searching;
if (!_searching) _search.clear();
});
}
: null,
icon: Icon(_searching ? Icons.close : Icons.search),
),
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
if (_hasTrips)
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
const ProfileButton(),
const SettingsButton(),
],
),
body: FutureBuilder(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text(
'Could not load the shop log.',
style: theme.textTheme.bodyLarge,
),
);
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final shops = snapshot.data!.shops;
final productNames = snapshot.data!.productNames;
final counts = tripCountsByDay([
for (final shop in shops)
if (isSameMonth(shop.shoppedAt, _month)) shop.shoppedAt,
]);
final monthShops = [
for (final shop in shops)
if (isSameMonth(shop.shoppedAt, _month)) shop,
];
final monthTotal = monthShops.fold<double>(
0,
(sum, shop) => sum + shop.total,
);
final visible = filterShops(
shops: shops,
productNames: productNames,
selectedDay: _selectedDay,
tag: _tagFilter,
query: _search.text,
);
return ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
ShopHeatmap(
month: _month,
counts: counts,
monthTotal: monthTotal,
monthTripCount: monthShops.length,
canGoForward: _month.isBefore(nowMonth),
selectedDay: _selectedDay,
onPreviousMonth: () => _goMonth(-1),
onNextMonth: () => _goMonth(1),
onSelectDay: _selectDay,
),
if (_selectedDay != null || _tagFilter != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (_selectedDay != null)
InputChip(
label: Text(formatDate(_selectedDay!)),
onDeleted: () => _selectDay(null),
),
if (_tagFilter != null)
InputChip(
label: Text(_tagFilter!),
onDeleted: () => setState(() => _tagFilter = null),
),
],
),
),
if (shops.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Scan a receipt or enter a trip to log it.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
)
else if (visible.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
_search.text.trim().isNotEmpty
? 'No trips contain that product.'
: 'No trips match this filter.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
)
else
for (final shop in visible)
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4),
child: _ShopLogTile(
shop: shop,
stores: stores,
editing: _editing,
selectedTag: _tagFilter,
onOpen: () => _openShop(shop),
onRemove: () => _removeShop(shop),
onTagTap: (tag) {
setState(() {
_tagFilter =
_tagFilter?.toLowerCase() == tag.toLowerCase()
? null
: tag;
});
},
),
),
],
);
body: _ShopLogBody(
theme: theme,
stores: stores,
nowMonth: nowMonth,
shops: _shops,
productNames: _productNames,
error: _error,
month: _month,
selectedDay: _selectedDay,
tagFilter: _tagFilter,
query: _search.text,
editing: _editing,
onPreviousMonth: () => _goMonth(-1),
onNextMonth: () => _goMonth(1),
onSelectDay: _selectDay,
onClearTag: () => setState(() => _tagFilter = null),
onTagTap: (tag) {
setState(() {
_tagFilter = _tagFilter?.toLowerCase() == tag.toLowerCase()
? null
: tag;
});
},
onOpen: _openShop,
onRemove: _removeShop,
),
);
}
}
class _ShopLogBody extends StatelessWidget {
const _ShopLogBody({
required this.theme,
required this.stores,
required this.nowMonth,
required this.shops,
required this.productNames,
required this.error,
required this.month,
required this.selectedDay,
required this.tagFilter,
required this.query,
required this.editing,
required this.onPreviousMonth,
required this.onNextMonth,
required this.onSelectDay,
required this.onClearTag,
required this.onTagTap,
required this.onOpen,
required this.onRemove,
});
final ThemeData theme;
final List<Supermarket> stores;
final DateTime nowMonth;
final List<Shop>? shops;
final Map<int, List<String>> productNames;
final Object? error;
final DateTime month;
final DateTime? selectedDay;
final String? tagFilter;
final String query;
final bool editing;
final VoidCallback onPreviousMonth;
final VoidCallback onNextMonth;
final ValueChanged<DateTime?> onSelectDay;
final VoidCallback onClearTag;
final ValueChanged<String> onTagTap;
final ValueChanged<Shop> onOpen;
final ValueChanged<Shop> onRemove;
@override
Widget build(BuildContext context) {
if (error != null && shops == null) {
return Center(
child: Text(
'Could not load the shop log.',
style: theme.textTheme.bodyLarge,
),
);
}
if (shops == null) {
return const Center(child: CircularProgressIndicator());
}
final allShops = shops!;
final counts = tripCountsByDay([
for (final shop in allShops)
if (isSameMonth(shop.shoppedAt, month)) shop.shoppedAt,
]);
final monthShops = [
for (final shop in allShops)
if (isSameMonth(shop.shoppedAt, month)) shop,
];
final monthTotal = monthShops.fold<double>(
0,
(sum, shop) => sum + shop.total,
);
final visible = filterShops(
shops: allShops,
productNames: productNames,
selectedDay: selectedDay,
tag: tagFilter,
query: query,
);
return ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
ShopHeatmap(
month: month,
counts: counts,
monthTotal: monthTotal,
monthTripCount: monthShops.length,
canGoForward: month.isBefore(nowMonth),
selectedDay: selectedDay,
onPreviousMonth: onPreviousMonth,
onNextMonth: onNextMonth,
onSelectDay: onSelectDay,
),
if (selectedDay != null || tagFilter != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (selectedDay != null)
InputChip(
label: Text(formatDate(selectedDay!)),
onDeleted: () => onSelectDay(null),
),
if (tagFilter != null)
InputChip(label: Text(tagFilter!), onDeleted: onClearTag),
],
),
),
if (allShops.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Scan a receipt or enter a trip to log it.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
)
else if (visible.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
query.trim().isNotEmpty
? 'No trips contain that product.'
: 'No trips match this filter.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
)
else
for (final shop in visible)
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4),
child: _ShopLogTile(
shop: shop,
stores: stores,
editing: editing,
selectedTag: tagFilter,
onOpen: () => onOpen(shop),
onRemove: () => onRemove(shop),
onTagTap: onTagTap,
),
),
],
);
}
}
class _ShopLogTile extends StatelessWidget {
const _ShopLogTile({
required this.shop,
@@ -295,6 +390,12 @@ class _ShopLogTile extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (editing)
IconButton(
tooltip: 'Remove',
onPressed: onRemove,
icon: const Icon(Icons.delete_outline),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -326,19 +427,13 @@ class _ShopLogTile extends StatelessWidget {
),
),
const SizedBox(width: 8),
editing
? IconButton(
tooltip: 'Remove',
onPressed: onRemove,
icon: const Icon(Icons.delete_outline),
)
: Padding(
padding: const EdgeInsets.only(top: 8, right: 8),
child: Text(
formatEuro(shop.total),
style: theme.textTheme.titleMedium,
),
),
Padding(
padding: const EdgeInsets.only(top: 8, right: 8),
child: Text(
formatEuro(shop.total),
style: theme.textTheme.titleMedium,
),
),
],
),
),
+283 -218
View File
@@ -3,10 +3,11 @@ library;
import 'package:flutter/material.dart';
import 'models/product.dart';
import 'models/shopping_list.dart';
import 'product_detail_screen.dart';
import 'services/product_repository.dart';
import 'utils/dates.dart';
import 'widgets/add_list_items_sheet.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/name_dialog.dart';
@@ -26,26 +27,41 @@ class ShoppingListDetailScreen extends StatefulWidget {
}
class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
late Future<({ShoppingList list, List<ShoppingListItem> items})> _future;
ShoppingList? _list;
List<ShoppingListItem>? _items;
Object? _error;
bool _editing = false;
int _loadToken = 0;
@override
void initState() {
super.initState();
_future = _load();
widget.repository.addListener(_load);
_load();
}
Future<({ShoppingList list, List<ShoppingListItem> items})> _load() async {
final list = await widget.repository.getShoppingList(widget.listId);
if (list == null) {
throw StateError('List not found');
@override
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final token = ++_loadToken;
try {
final list = await widget.repository.getShoppingList(widget.listId);
if (list == null) throw StateError('List not found');
final items = await widget.repository.getShoppingListItems(widget.listId);
if (!mounted || token != _loadToken) return;
setState(() {
_list = list;
_items = items;
_error = null;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
final items = await widget.repository.getShoppingListItems(widget.listId);
return (list: list, items: items);
}
void _reload() {
setState(() => _future = _load());
}
Future<void> _rename(ShoppingList list) async {
@@ -57,25 +73,70 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
);
if (name == null || !mounted) return;
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 {
final products = await widget.repository.getAll();
if (!mounted) return;
final result = await showModalBottomSheet<({String name, int? productId})>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => _AddListItemSheet(products: products),
);
if (result == null || !mounted) return;
await widget.repository.addShoppingListItem(
listId: widget.listId,
name: result.name,
productId: result.productId,
);
if (mounted) _reload();
final selections = await showAddListItemsSheet(context, products: products);
if (selections == null || selections.isEmpty || !mounted) return;
final pending = [
for (var i = 0; i < selections.length; i++)
ShoppingListItem(
id: -(i + 1),
listId: widget.listId,
name: selections[i].name,
checked: false,
sortOrder: (_items?.length ?? 0) + i,
productId: selections[i].productId,
),
];
setState(() {
_items = [...?_items, ...pending];
if (_list != null) {
_list = ShoppingList(
id: _list!.id,
name: _list!.name,
itemCount: _list!.itemCount + pending.length,
checkedCount: _list!.checkedCount,
createdAt: _list!.createdAt,
updatedAt: DateTime.now(),
);
}
});
try {
final added = await widget.repository.addShoppingListItems(
listId: widget.listId,
items: [
for (final selection in selections)
(name: selection.name, productId: selection.productId),
],
);
if (!mounted) return;
setState(() {
final withoutPending = _items
?.where((item) => item.id >= 0)
.toList(growable: false);
_items = [...?withoutPending, ...added];
});
} catch (_) {
if (mounted) _load();
}
}
Future<void> _editItem(ShoppingListItem item) async {
@@ -87,7 +148,7 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
);
if (name == null || !mounted) return;
await widget.repository.updateShoppingListItem(id: item.id, name: name);
if (mounted) _reload();
if (mounted) _load();
}
Future<void> _removeItem(ShoppingListItem item) async {
@@ -98,16 +159,100 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
confirmLabel: 'Remove',
);
if (!confirmed || !mounted) return;
await widget.repository.deleteShoppingListItem(item.id);
if (mounted) _reload();
setState(() {
_items = [
for (final entry in _items ?? const <ShoppingListItem>[])
if (entry.id != item.id) entry,
];
if (_list != null) {
_list = ShoppingList(
id: _list!.id,
name: _list!.name,
itemCount: _list!.itemCount - 1,
checkedCount: item.checked
? _list!.checkedCount - 1
: _list!.checkedCount,
createdAt: _list!.createdAt,
updatedAt: DateTime.now(),
);
}
});
try {
await widget.repository.deleteShoppingListItem(item.id);
} catch (_) {
if (mounted) _load();
}
}
Future<void> _toggle(ShoppingListItem item) async {
await widget.repository.updateShoppingListItem(
id: item.id,
checked: !item.checked,
);
if (mounted) _reload();
final next = !item.checked;
setState(() {
_items = [
for (final entry in _items ?? const <ShoppingListItem>[])
if (entry.id == item.id)
ShoppingListItem(
id: entry.id,
listId: entry.listId,
name: entry.name,
checked: next,
sortOrder: entry.sortOrder,
productId: entry.productId,
)
else
entry,
];
if (_list != null) {
_list = ShoppingList(
id: _list!.id,
name: _list!.name,
itemCount: _list!.itemCount,
checkedCount: _list!.checkedCount + (next ? 1 : -1),
createdAt: _list!.createdAt,
updatedAt: _list!.updatedAt,
);
}
});
try {
await widget.repository.updateShoppingListItem(
id: item.id,
checked: next,
);
} catch (_) {
if (mounted) _load();
}
}
Future<void> _shopAgain() async {
final list = _list;
if (list == null || list.checkedCount == 0) return;
setState(() {
_items = [
for (final item in _items ?? const <ShoppingListItem>[])
ShoppingListItem(
id: item.id,
listId: item.listId,
name: item.name,
checked: false,
sortOrder: item.sortOrder,
productId: item.productId,
),
];
_list = ShoppingList(
id: list.id,
name: list.name,
itemCount: list.itemCount,
checkedCount: 0,
createdAt: list.createdAt,
updatedAt: DateTime.now(),
);
});
try {
await widget.repository.resetShoppingListChecks(widget.listId);
} catch (_) {
if (mounted) _load();
}
}
Future<void> _openProduct(int productId) async {
@@ -125,192 +270,112 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: FutureBuilder(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Text(
'Could not load this list.',
style: theme.textTheme.bodyLarge,
),
),
);
}
if (!snapshot.hasData) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: CircularProgressIndicator()),
);
}
final list = snapshot.data!.list;
final items = snapshot.data!.items;
return Scaffold(
appBar: AppBar(
title: Text(list.name),
actions: [
IconButton(
tooltip: 'Rename',
onPressed: () => _rename(list),
icon: const Icon(Icons.drive_file_rename_outline),
),
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _openAddSheet,
tooltip: 'Add item',
child: const Icon(Icons.add),
),
body: items.isEmpty
? Center(
child: Text(
'Add things to buy.',
style: theme.textTheme.bodyLarge,
),
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
leading: Checkbox(
value: item.checked,
onChanged: _editing ? null : (_) => _toggle(item),
),
title: Text(
item.name,
style: item.checked
? theme.textTheme.bodyLarge?.copyWith(
decoration: TextDecoration.lineThrough,
color: theme.colorScheme.onSurfaceVariant,
)
: null,
),
trailing: _editing
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
onPressed: () => _editItem(item),
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Remove',
onPressed: () => _removeItem(item),
icon: const Icon(Icons.delete_outline),
),
],
)
: item.productId == null
? null
: IconButton(
tooltip: 'Product',
onPressed: () => _openProduct(item.productId!),
icon: const Icon(Icons.chevron_right),
),
onTap: _editing ? null : () => _toggle(item),
);
},
),
);
},
),
);
}
}
class _AddListItemSheet extends StatefulWidget {
const _AddListItemSheet({required this.products});
final List<Product> products;
@override
State<_AddListItemSheet> createState() => _AddListItemSheetState();
}
class _AddListItemSheetState extends State<_AddListItemSheet> {
final TextEditingController _controller = TextEditingController();
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
List<Product> get _matches {
final query = _controller.text.trim().toLowerCase();
if (query.isEmpty) {
return widget.products.take(8).toList();
}
return widget.products
.where((product) => product.name.toLowerCase().contains(query))
.take(8)
.toList();
}
void _add({String? name, int? productId}) {
final value = (name ?? _controller.text).trim();
if (value.isEmpty) {
setState(() => _error = 'Enter an item name.');
return;
}
Navigator.of(context).pop((name: value, productId: productId));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
final matches = _matches;
return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Add item', style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
TextField(
controller: _controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(labelText: 'Item name'),
onChanged: (_) => setState(() => _error = null),
onSubmitted: (_) => _add(),
if (_error != null && _list == null) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Text(
'Could not load this list.',
style: theme.textTheme.bodyLarge,
),
),
);
}
if (_list == null || _items == null) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: CircularProgressIndicator()),
);
}
final list = _list!;
final items = _items!;
return Scaffold(
appBar: AppBar(
title: Text('${list.name} (${formatDate(list.createdAt)})'),
actions: [
if (list.checkedCount > 0 && !_editing)
TextButton(
onPressed: _shopAgain,
child: const Text('Shop again'),
),
IconButton(
tooltip: 'Rename',
onPressed: () => _rename(list),
icon: const Icon(Icons.drive_file_rename_outline),
),
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
],
if (matches.isNotEmpty) ...[
const SizedBox(height: 12),
Text('From products', style: theme.textTheme.labelMedium),
const SizedBox(height: 4),
for (final product in matches)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(product.name),
onTap: () => _add(name: product.name, productId: product.id),
),
],
const SizedBox(height: 8),
FilledButton(onPressed: () => _add(), child: const Text('Add')),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _openAddSheet,
tooltip: 'Add item',
child: const Icon(Icons.add),
),
body: items.isEmpty
? Center(
child: Text(
'Add things to buy.',
style: theme.textTheme.bodyLarge,
),
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
leading: Checkbox(
value: item.checked,
onChanged: _editing || item.id < 0
? null
: (_) => _toggle(item),
),
title: Text(
item.name,
style: item.checked
? theme.textTheme.bodyLarge?.copyWith(
decoration: TextDecoration.lineThrough,
color: theme.colorScheme.onSurfaceVariant,
)
: null,
),
trailing: _editing
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
onPressed: item.id < 0
? null
: () => _editItem(item),
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Remove',
onPressed: item.id < 0
? null
: () => _removeItem(item),
icon: const Icon(Icons.delete_outline),
),
],
)
: item.productId == null
? null
: IconButton(
tooltip: 'Product',
onPressed: () => _openProduct(item.productId!),
icon: const Icon(Icons.chevron_right),
),
onTap: _editing || item.id < 0 ? null : () => _toggle(item),
);
},
),
);
}
}
+87 -61
View File
@@ -6,8 +6,10 @@ import 'package:flutter/material.dart';
import 'models/shopping_list.dart';
import 'services/product_repository.dart';
import 'shopping_list_detail_screen.dart';
import 'utils/dates.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/name_dialog.dart';
import 'widgets/profile_button.dart';
import 'widgets/settings_button.dart';
class ShoppingListsScreen extends StatefulWidget {
@@ -20,17 +22,40 @@ class ShoppingListsScreen extends StatefulWidget {
}
class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
late Future<List<ShoppingList>> _future;
List<ShoppingList>? _lists;
Object? _error;
bool _editing = false;
int _loadToken = 0;
bool get _hasLists => _lists != null && _lists!.isNotEmpty;
@override
void initState() {
super.initState();
_future = widget.repository.getShoppingLists();
widget.repository.addListener(_load);
_load();
}
void _reload() {
setState(() => _future = widget.repository.getShoppingLists());
@override
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final token = ++_loadToken;
try {
final lists = await widget.repository.getShoppingLists();
if (!mounted || token != _loadToken) return;
setState(() {
_lists = lists;
_error = null;
if (lists.isEmpty) _editing = false;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
}
Future<void> _create() async {
@@ -53,7 +78,7 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
ShoppingListDetailScreen(repository: widget.repository, listId: id),
),
);
if (mounted) _reload();
if (mounted) _load();
}
Future<void> _removeList(ShoppingList list) async {
@@ -63,8 +88,18 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
message: '"${list.name}" and its items will be deleted.',
);
if (!confirmed || !mounted) return;
await widget.repository.deleteShoppingList(list.id);
if (mounted) _reload();
setState(() {
_lists = [
for (final item in _lists ?? const <ShoppingList>[])
if (item.id != list.id) item,
];
if (_lists!.isEmpty) _editing = false;
});
try {
await widget.repository.deleteShoppingList(list.id);
} catch (_) {
if (mounted) _load();
}
}
@override
@@ -75,11 +110,13 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
appBar: AppBar(
title: const Text('Shopping lists'),
actions: [
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
if (_hasLists)
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
const ProfileButton(),
const SettingsButton(),
],
),
@@ -88,64 +125,53 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
tooltip: 'New list',
child: const Icon(Icons.add),
),
body: FutureBuilder<List<ShoppingList>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
body: _error != null && _lists == null
? Center(
child: Text(
'Could not load shopping lists.',
style: theme.textTheme.bodyLarge,
),
);
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final lists = snapshot.data!;
if (lists.isEmpty) {
return Center(
)
: _lists == null
? const Center(child: CircularProgressIndicator())
: _lists!.isEmpty
? Center(
child: Text(
'Create a list for your next shop.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
itemCount: lists.length,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final list = lists[index];
final remaining = list.itemCount - list.checkedCount;
return ListTile(
leading: CircleAvatar(child: Text('${list.itemCount}')),
title: Text(list.name),
subtitle: Text(
list.itemCount == 0
? 'Empty'
: remaining == 0
? 'All done'
: remaining == 1
? '1 item left'
: '$remaining items left',
),
trailing: _editing
? IconButton(
tooltip: 'Delete',
onPressed: () => _removeList(list),
icon: const Icon(Icons.delete_outline),
)
: const Icon(Icons.chevron_right),
onTap: _editing ? null : () => _openList(list.id),
);
},
);
},
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
itemCount: _lists!.length,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final list = _lists![index];
final remaining = list.itemCount - list.checkedCount;
return ListTile(
leading: CircleAvatar(child: Text('${list.itemCount}')),
title: Text('${list.name} (${formatDate(list.createdAt)})'),
subtitle: Text(
list.itemCount == 0
? 'Empty'
: remaining == 0
? 'All done'
: remaining == 1
? '1 item left'
: '$remaining items left',
),
trailing: _editing
? IconButton(
tooltip: 'Delete',
onPressed: () => _removeList(list),
icon: const Icon(Icons.delete_outline),
)
: const Icon(Icons.chevron_right),
onTap: _editing ? null : () => _openList(list.id),
);
},
),
);
}
}
+284
View File
@@ -0,0 +1,284 @@
/// Manage supermarket names, colors, and bonus card photos.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'models/supermarket.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'widgets/store_edit_dialog.dart';
import 'widgets/store_tag.dart';
class SupermarketsScreen extends StatefulWidget {
const SupermarketsScreen({super.key, required this.repository});
final ProductRepository repository;
@override
State<SupermarketsScreen> createState() => _SupermarketsScreenState();
}
class _SupermarketsScreenState extends State<SupermarketsScreen> {
List<Supermarket>? _stores;
bool _busy = false;
final ImagePicker _picker = ImagePicker();
@override
void initState() {
super.initState();
widget.repository.addListener(_load);
_load();
}
@override
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
try {
final stores = await widget.repository.getSupermarkets();
if (!mounted) return;
setState(() => _stores = stores);
} catch (_) {
if (!mounted) return;
setState(() => _stores ??= const []);
}
}
Future<void> _syncSettings() async {
await _settings?.reload();
}
AppSettings? get _settings => SettingsScope.maybeOf(context);
Future<void> _editStore(Supermarket? existing) async {
final result = await showStoreEditDialog(context, store: existing);
if (result == null) return;
setState(() => _busy = true);
try {
final settings = _settings;
if (settings == null) return;
if (existing == null) {
await settings.addSupermarket(result.name, result.colorValue);
} else {
await settings.updateSupermarket(result);
}
await _load();
await _syncSettings();
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('That supermarket already exists.')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _deleteStore(Supermarket store) async {
if (store.id == null) return;
setState(() => _busy = true);
try {
await _settings?.deleteSupermarket(store.id!);
await _load();
await _syncSettings();
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _pickBonusCard(Supermarket store) async {
if (store.id == null) return;
final source = await showModalBottomSheet<ImageSource>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Take photo'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Pick from gallery'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
);
},
);
if (source == null) return;
final file = await _picker.pickImage(source: source, imageQuality: 85);
if (file == null) return;
setState(() => _busy = true);
try {
await widget.repository.setSupermarketBonusCard(store.id!, file.path);
await _load();
await _syncSettings();
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _viewBonusCard(String path) async {
await showDialog<void>(
context: context,
builder: (context) {
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: InteractiveViewer(
child: Image.file(File(path), fit: BoxFit.contain),
),
);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stores = _stores;
return Scaffold(
appBar: AppBar(title: const Text('Supermarkets')),
floatingActionButton: FloatingActionButton(
onPressed: _busy ? null : () => _editStore(null),
tooltip: 'Add supermarket',
child: const Icon(Icons.add),
),
body: stores == null
? const Center(child: CircularProgressIndicator())
: ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
itemCount: stores.length + 1,
separatorBuilder: (_, index) =>
index == 0 ? const SizedBox(height: 4) : const Divider(height: 1),
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Store names and colors appear on products. '
'Add a bonus card photo when you have one.',
style: theme.textTheme.bodyMedium,
),
if (_busy) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(),
],
],
),
);
}
final store = stores[index - 1];
return _SupermarketRow(
store: store,
stores: stores,
busy: _busy,
onEdit: () => _editStore(store),
onDelete: store.id == null ? null : () => _deleteStore(store),
onBonusCard: () => _pickBonusCard(store),
onViewBonusCard: store.bonusImagePath == null
? null
: () => _viewBonusCard(store.bonusImagePath!),
);
},
),
);
}
}
class _SupermarketRow extends StatelessWidget {
const _SupermarketRow({
required this.store,
required this.stores,
required this.busy,
required this.onEdit,
required this.onBonusCard,
this.onDelete,
this.onViewBonusCard,
});
final Supermarket store;
final List<Supermarket> stores;
final bool busy;
final VoidCallback onEdit;
final VoidCallback? onDelete;
final VoidCallback onBonusCard;
final VoidCallback? onViewBonusCard;
@override
Widget build(BuildContext context) {
final path = store.bonusImagePath;
final hasCard = path != null && path.isNotEmpty && File(path).existsSync();
return ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
leading: hasCard
? InkWell(
onTap: busy ? null : onViewBonusCard,
borderRadius: BorderRadius.circular(8),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(path),
width: 44,
height: 28,
fit: BoxFit.cover,
),
),
)
: CircleAvatar(
radius: 16,
backgroundColor: store.color,
child: Text(
store.name.isEmpty ? '?' : store.name.substring(0, 1),
style: TextStyle(
color: store.color.computeLuminance() > 0.55
? Colors.black87
: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
),
title: StoreTag(name: store.name, stores: stores, compact: true),
subtitle: hasCard ? null : const Text('No bonus card'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: hasCard ? 'Replace bonus card' : 'Add bonus card',
onPressed: busy ? null : onBonusCard,
icon: Icon(hasCard ? Icons.photo_camera : Icons.card_membership_outlined),
),
IconButton(
tooltip: 'Edit',
onPressed: busy ? null : onEdit,
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Delete',
onPressed: busy ? null : onDelete,
icon: const Icon(Icons.delete_outline),
),
],
),
);
}
}
+5 -15
View File
@@ -39,9 +39,7 @@ abstract final class AppTheme {
cardTheme: CardThemeData(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
),
@@ -60,9 +58,7 @@ abstract final class AppTheme {
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.outlineVariant),
@@ -88,9 +84,7 @@ abstract final class AppTheme {
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
dividerTheme: DividerThemeData(
color: colorScheme.outlineVariant,
@@ -98,14 +92,10 @@ abstract final class AppTheme {
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
listTileTheme: ListTileThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
iconColor: colorScheme.primary,
),
shadowColor: isDark ? Colors.black : const Color(0x1A0F172A),
+1 -1
View File
@@ -1,7 +1,7 @@
/// Shared strings used across the app.
library;
const String kAppName = 'Receipity';
const String kAppName = 'Groci';
/// Bundled Jumbo receipt used to test OCR without the camera.
const String kSampleReceiptAsset = 'assets/receipt.jpeg';
+269
View File
@@ -0,0 +1,269 @@
/// Case-insensitive product name matching and duplicate grouping.
library;
import '../models/product.dart';
String normalizeProductName(String name) =>
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
String normalizeSupermarket(String? supermarket) =>
supermarket?.trim().toUpperCase() ?? '';
String productConflictKey({
required String name,
required String? supermarket,
}) => '${normalizeSupermarket(supermarket)}|${normalizeProductName(name)}';
String mergeGroupKey(List<Product> products, String? supermarket) {
final ids = products.map((product) => product.id).toList()..sort();
return 'merge:${normalizeSupermarket(supermarket)}:'
'${normalizeProductName(products.first.name)}:${ids.join(',')}';
}
String spellingVariantKey(int productId, String? supermarket) =>
'spelling:$productId:${normalizeSupermarket(supermarket)}';
List<List<Product>> nameConflictGroups(Iterable<Product> products) {
final groups = <String, List<Product>>{};
for (final product in products) {
final key = productConflictKey(
name: product.name,
supermarket: product.supermarket,
);
if (normalizeProductName(product.name).isEmpty) continue;
(groups[key] ??= []).add(product);
}
return [
for (final group in groups.values)
if (group.length > 1) group,
];
}
Set<int> nameConflictIds(Iterable<Product> products) {
return {
for (final group in nameConflictGroups(products))
for (final product in group) product.id,
};
}
/// Separate catalog products with the same name at the same store.
class ProductMergeGroup {
const ProductMergeGroup({
required this.products,
required this.supermarket,
required this.key,
});
final List<Product> products;
final String? supermarket;
final String key;
}
/// One catalog product bought under multiple spellings at one store.
class ProductSpellingVariant {
const ProductSpellingVariant({
required this.product,
required this.supermarket,
required this.variants,
required this.key,
});
final Product product;
final String? supermarket;
final List<String> variants;
final String key;
}
/// Builds merge groups from catalog names and trip lines at the same store.
List<ProductMergeGroup> buildNameConflictGroups({
required Iterable<Product> products,
required Iterable<({int productId, String name, String supermarket})>
shopLines,
}) {
final productById = {for (final product in products) product.id: product};
final idsByKey = <String, Set<int>>{};
final supermarketByKey = <String, String>{};
void add(int productId, String name, String? supermarket) {
final key = productConflictKey(name: name, supermarket: supermarket);
if (normalizeProductName(name).isEmpty) return;
final trimmed = supermarket?.trim();
if (trimmed != null && trimmed.isNotEmpty) {
supermarketByKey.putIfAbsent(key, () => trimmed);
}
(idsByKey[key] ??= {}).add(productId);
}
for (final product in products) {
add(product.id, product.name, product.supermarket);
}
for (final line in shopLines) {
add(line.productId, line.name, line.supermarket);
}
final groups = <ProductMergeGroup>[];
for (final entry in idsByKey.entries) {
if (entry.value.length < 2) continue;
final productsInGroup = [
for (final id in entry.value)
if (productById.containsKey(id)) productById[id]!,
];
if (productsInGroup.length < 2) continue;
productsInGroup.sort(
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
final supermarketLabel = supermarketByKey[entry.key];
groups.add(
ProductMergeGroup(
products: productsInGroup,
supermarket: supermarketLabel,
key: mergeGroupKey(productsInGroup, supermarketLabel),
),
);
}
groups.sort(
(a, b) =>
productConflictKey(
name: a.products.first.name,
supermarket: a.supermarket,
).compareTo(
productConflictKey(
name: b.products.first.name,
supermarket: b.supermarket,
),
),
);
return groups;
}
/// Finds receipt spellings that differ only by capitals at one store.
List<ProductSpellingVariant> findSpellingVariants({
required Iterable<Product> products,
required Iterable<({int productId, String name, String supermarket})>
shopLines,
Iterable<int> mergeProductIds = const [],
Iterable<String> mergeGroupKeys = const [],
}) {
final productById = {for (final product in products) product.id: product};
final excludedProducts = mergeProductIds.toSet();
final excludedGroups = mergeGroupKeys.toSet();
final namesByProductStore = <String, Set<String>>{};
final supermarketByKey = <String, String>{};
void addName(int productId, String name, String supermarket) {
final storeKey = normalizeSupermarket(supermarket);
final bucketKey = '$productId|$storeKey';
supermarketByKey[bucketKey] = supermarket;
(namesByProductStore[bucketKey] ??= {}).add(name);
}
for (final line in shopLines) {
addName(line.productId, line.name, line.supermarket);
}
for (final product in products) {
final supermarket = product.supermarket;
if (supermarket == null || supermarket.trim().isEmpty) continue;
addName(product.id, product.name, supermarket);
}
final issues = <ProductSpellingVariant>[];
for (final entry in namesByProductStore.entries) {
final parts = entry.key.split('|');
final productId = int.parse(parts.first);
if (excludedProducts.contains(productId)) continue;
final product = productById[productId];
if (product == null) continue;
final supermarket = supermarketByKey[entry.key];
final byNormalized = <String, Set<String>>{};
for (final name in entry.value) {
final key = normalizeProductName(name);
if (key.isEmpty) continue;
(byNormalized[key] ??= {}).add(name);
}
for (final names in byNormalized.values) {
if (names.length < 2) continue;
final variants = names.toList()
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
final issue = ProductSpellingVariant(
product: product,
supermarket: supermarket,
variants: variants,
key: spellingVariantKey(productId, supermarket),
);
if (excludedGroups.contains(issue.key)) continue;
issues.add(issue);
}
}
issues.sort(
(a, b) =>
productConflictKey(
name: a.product.name,
supermarket: a.supermarket,
).compareTo(
productConflictKey(name: b.product.name, supermarket: b.supermarket),
),
);
return issues;
}
/// Catalog merges and receipt spelling variants for the Products screen.
class ProductNameIssues {
const ProductNameIssues({
required this.mergeGroups,
required this.spellingVariants,
});
final List<ProductMergeGroup> mergeGroups;
final List<ProductSpellingVariant> spellingVariants;
bool get isEmpty => mergeGroups.isEmpty && spellingVariants.isEmpty;
bool get isNotEmpty => !isEmpty;
int get issueCount => mergeGroups.length + spellingVariants.length;
Set<int> get productIds => {
for (final group in mergeGroups)
for (final product in group.products) product.id,
for (final variant in spellingVariants) variant.product.id,
};
}
ProductNameIssues buildProductNameIssues({
required Iterable<Product> products,
required Iterable<({int productId, String name, String supermarket})>
shopLines,
Set<String> dismissedKeys = const {},
}) {
final mergeGroups = [
for (final group in buildNameConflictGroups(
products: products,
shopLines: shopLines,
))
if (!dismissedKeys.contains(group.key)) group,
];
final mergeIds = {
for (final group in mergeGroups)
for (final product in group.products) product.id,
};
final mergeKeys = {for (final group in mergeGroups) group.key};
final spellingVariants = [
for (final variant in findSpellingVariants(
products: products,
shopLines: shopLines,
mergeProductIds: mergeIds,
mergeGroupKeys: mergeKeys,
))
if (!dismissedKeys.contains(variant.key)) variant,
];
return ProductNameIssues(
mergeGroups: mergeGroups,
spellingVariants: spellingVariants,
);
}
+175
View File
@@ -0,0 +1,175 @@
/// Multi-select sheet for adding catalog products to a shopping list.
library;
import 'package:flutter/material.dart';
import '../models/product.dart';
import 'product_image.dart';
class AddListItemSelection {
const AddListItemSelection({required this.name, this.productId});
final String name;
final int? productId;
}
Future<List<AddListItemSelection>?> showAddListItemsSheet(
BuildContext context, {
required List<Product> products,
}) {
return showModalBottomSheet<List<AddListItemSelection>>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => _AddListItemsSheet(products: products),
);
}
class _AddListItemsSheet extends StatefulWidget {
const _AddListItemsSheet({required this.products});
final List<Product> products;
@override
State<_AddListItemsSheet> createState() => _AddListItemsSheetState();
}
class _AddListItemsSheetState extends State<_AddListItemsSheet> {
final TextEditingController _controller = TextEditingController();
final Set<int> _selectedProductIds = {};
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
List<Product> get _matches {
final query = _controller.text.trim().toLowerCase();
if (query.isEmpty) return widget.products;
return widget.products
.where((product) => product.name.toLowerCase().contains(query))
.toList();
}
int get _addCount {
var count = _selectedProductIds.length;
final typed = _controller.text.trim();
if (typed.isEmpty) return count;
final selectedNames = {
for (final product in widget.products)
if (_selectedProductIds.contains(product.id))
product.name.toLowerCase(),
};
if (!selectedNames.contains(typed.toLowerCase())) count++;
return count;
}
void _toggleProduct(Product product) {
setState(() {
if (_selectedProductIds.contains(product.id)) {
_selectedProductIds.remove(product.id);
} else {
_selectedProductIds.add(product.id);
}
_error = null;
});
}
void _add() {
final selections = <AddListItemSelection>[
for (final product in widget.products)
if (_selectedProductIds.contains(product.id))
AddListItemSelection(name: product.name, productId: product.id),
];
final typed = _controller.text.trim();
if (typed.isNotEmpty &&
!selections.any(
(item) => item.name.toLowerCase() == typed.toLowerCase(),
)) {
selections.add(AddListItemSelection(name: typed));
}
if (selections.isEmpty) {
setState(() => _error = 'Select products or enter an item name.');
return;
}
Navigator.of(context).pop(selections);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
final matches = _matches;
final addCount = _addCount;
return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.72,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Add items', style: theme.textTheme.titleLarge),
const SizedBox(height: 12),
TextField(
controller: _controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Search or type a new item',
),
onChanged: (_) => setState(() => _error = null),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
],
const SizedBox(height: 8),
Text('From products', style: theme.textTheme.labelMedium),
const SizedBox(height: 4),
Expanded(
child: matches.isEmpty
? Center(
child: Text(
widget.products.isEmpty
? 'No products in your catalog yet.'
: 'No products match this search.',
style: theme.textTheme.bodyMedium,
),
)
: ListView.builder(
itemCount: matches.length,
itemBuilder: (context, index) {
final product = matches[index];
final selected = _selectedProductIds.contains(
product.id,
);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Checkbox(
value: selected,
onChanged: (_) => _toggleProduct(product),
),
title: Text(product.name),
trailing: ProductImage(
name: product.name,
path: product.imagePath,
size: 32,
),
onTap: () => _toggleProduct(product),
);
},
),
),
FilledButton(
onPressed: addCount == 0 ? null : _add,
child: Text(addCount == 1 ? 'Add 1 item' : 'Add $addCount items'),
),
],
),
),
);
}
}
+37
View File
@@ -0,0 +1,37 @@
/// Paid amount, with the pre-discount price struck through when there is one.
library;
import 'package:flutter/material.dart';
import '../models/line_item.dart';
import '../utils/money.dart';
class DiscountedPrice extends StatelessWidget {
const DiscountedPrice({super.key, required this.item});
final LineItem item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final paid = Text(
formatEuro(item.price),
style: theme.textTheme.titleMedium,
);
if (!item.hasDiscount) return paid;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
formatEuro(item.originalPrice),
style: theme.textTheme.bodyMedium?.copyWith(
decoration: TextDecoration.lineThrough,
),
),
const SizedBox(width: 8),
paid,
],
);
}
}
+152 -16
View File
@@ -2,6 +2,7 @@
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/line_item.dart';
import '../utils/money.dart';
@@ -26,46 +27,149 @@ class ItemEditSheet extends StatefulWidget {
class _ItemEditSheetState extends State<ItemEditSheet> {
late final TextEditingController _nameController;
late final TextEditingController _priceController;
late final TextEditingController _quantityController;
late final TextEditingController _unitPriceController;
late final TextEditingController _totalController;
String? _error;
bool _syncing = false;
@override
void initState() {
super.initState();
final item = widget.item;
final quantity = item?.quantity ?? 1;
final total = item?.price;
final unit = item?.unitPrice ??
(total != null && quantity > 0 ? roundMoney(total / quantity) : null);
_nameController = TextEditingController(text: item?.name ?? '');
_priceController = TextEditingController(
text: item == null ? '' : item.price.toStringAsFixed(2),
_quantityController = TextEditingController(text: '$quantity');
_unitPriceController = TextEditingController(
text: unit == null ? '' : unit.toStringAsFixed(2),
);
_totalController = TextEditingController(
text: total == null ? '' : total.toStringAsFixed(2),
);
_quantityController.addListener(_onQuantityChanged);
_unitPriceController.addListener(_onUnitPriceChanged);
_totalController.addListener(_onTotalChanged);
}
@override
void dispose() {
_quantityController.removeListener(_onQuantityChanged);
_unitPriceController.removeListener(_onUnitPriceChanged);
_totalController.removeListener(_onTotalChanged);
_nameController.dispose();
_priceController.dispose();
_quantityController.dispose();
_unitPriceController.dispose();
_totalController.dispose();
super.dispose();
}
int? get _quantity {
final raw = _quantityController.text.trim();
if (raw.isEmpty) return null;
return int.tryParse(raw);
}
void _setText(TextEditingController controller, String value) {
if (controller.text == value) return;
controller.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(offset: value.length),
);
}
void _onQuantityChanged() {
if (_syncing) return;
final quantity = _quantity;
if (quantity == null || quantity <= 0) {
setState(() {});
return;
}
final unit = tryParsePrice(_unitPriceController.text);
final total = tryParsePrice(_totalController.text);
_syncing = true;
if (unit != null) {
_setText(_totalController, roundMoney(quantity * unit).toStringAsFixed(2));
} else if (total != null) {
_setText(
_unitPriceController,
roundMoney(total / quantity).toStringAsFixed(2),
);
}
_syncing = false;
setState(() {});
}
void _onUnitPriceChanged() {
if (_syncing) return;
final quantity = _quantity;
final unit = tryParsePrice(_unitPriceController.text);
if (quantity == null || quantity <= 0 || unit == null) {
setState(() {});
return;
}
_syncing = true;
_setText(_totalController, roundMoney(quantity * unit).toStringAsFixed(2));
_syncing = false;
setState(() {});
}
void _onTotalChanged() {
if (_syncing) return;
final quantity = _quantity;
final total = tryParsePrice(_totalController.text);
if (quantity == null || quantity <= 0 || total == null) {
setState(() {});
return;
}
_syncing = true;
_setText(
_unitPriceController,
roundMoney(total / quantity).toStringAsFixed(2),
);
_syncing = false;
setState(() {});
}
void _save() {
final name = _nameController.text.trim();
final price = tryParsePrice(_priceController.text);
if (name.isEmpty || price == null) {
setState(() => _error = 'Enter a product name and a price.');
final quantity = _quantity;
final unit = tryParsePrice(_unitPriceController.text);
final total = tryParsePrice(_totalController.text);
if (name.isEmpty) {
setState(() => _error = 'Enter a product name.');
return;
}
final rounded = roundMoney(price);
final quantity = widget.item?.quantity;
if (quantity == null || quantity <= 0) {
setState(() => _error = 'Enter a quantity of at least 1.');
return;
}
if (total == null && unit == null) {
setState(() => _error = 'Enter a unit price or a total.');
return;
}
final resolvedTotal = total ?? roundMoney(quantity * unit!);
final resolvedUnit = unit ?? roundMoney(resolvedTotal / quantity);
final hasMulti = quantity > 1;
Navigator.of(context).pop(
LineItem(
id:
widget.item?.id ??
'manual-${DateTime.now().microsecondsSinceEpoch}',
name: name,
price: rounded,
quantity: quantity,
unitPrice: quantity != null && quantity > 0
? roundMoney(rounded / quantity)
: widget.item?.unitPrice,
price: roundMoney(resolvedTotal),
quantity: hasMulti ? quantity : null,
unitPrice: hasMulti ? roundMoney(resolvedUnit) : null,
discount: widget.item?.price == roundMoney(resolvedTotal)
? widget.item?.discount ?? 0
: 0,
),
);
}
@@ -73,6 +177,11 @@ class _ItemEditSheetState extends State<ItemEditSheet> {
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.viewInsetsOf(context).bottom;
final quantity = _quantity;
final unit = tryParsePrice(_unitPriceController.text);
final preview = quantity != null && quantity > 1 && unit != null
? '$quantity × ${formatEuro(unit)}'
: null;
return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
@@ -92,10 +201,37 @@ class _ItemEditSheetState extends State<ItemEditSheet> {
autofocus: widget.item == null,
),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 88,
child: TextField(
controller: _quantityController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(labelText: 'Qty'),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _unitPriceController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(labelText: 'Unit price'),
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _priceController,
controller: _totalController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Price'),
decoration: InputDecoration(
labelText: 'Total price',
helperText: preview,
),
),
if (_error != null) ...[
const SizedBox(height: 8),
+90
View File
@@ -0,0 +1,90 @@
/// Bottom sheet for picking a shopping list to add a product to.
library;
import 'package:flutter/material.dart';
import '../models/shopping_list.dart';
import '../services/product_repository.dart';
import 'name_dialog.dart';
Future<int?> showPickShoppingListSheet(
BuildContext context, {
required ProductRepository repository,
}) async {
final lists = await repository.getShoppingLists();
if (!context.mounted) return null;
if (lists.isEmpty) {
final name = await showNameDialog(
context,
title: 'New shopping list',
label: 'List name',
confirmLabel: 'Create',
);
if (name == null || !context.mounted) return null;
return repository.createShoppingList(name);
}
return showModalBottomSheet<int>(
context: context,
showDragHandle: true,
builder: (context) {
return _PickShoppingListSheet(repository: repository, lists: lists);
},
);
}
class _PickShoppingListSheet extends StatelessWidget {
const _PickShoppingListSheet({required this.repository, required this.lists});
final ProductRepository repository;
final List<ShoppingList> lists;
Future<void> _create(BuildContext context) async {
final name = await showNameDialog(
context,
title: 'New shopping list',
label: 'List name',
confirmLabel: 'Create',
);
if (name == null || !context.mounted) return;
final id = await repository.createShoppingList(name);
if (context.mounted) Navigator.of(context).pop(id);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text('Add to list', style: theme.textTheme.titleLarge),
),
for (final list in lists)
ListTile(
leading: const Icon(Icons.checklist_outlined),
title: Text(list.name),
subtitle: Text(
list.itemCount == 0
? 'Empty'
: list.itemCount == 1
? '1 item'
: '${list.itemCount} items',
),
onTap: () => Navigator.of(context).pop(list.id),
),
ListTile(
leading: const Icon(Icons.add),
title: const Text('Create new list'),
onTap: () => _create(context),
),
],
),
);
}
}
+7 -4
View File
@@ -28,9 +28,7 @@ class PriceChart extends StatelessWidget {
height: 160,
child: CustomPaint(
painter: _PriceChartPainter(
prices: [
for (final purchase in points) purchase.unitOrLinePrice,
],
prices: [for (final purchase in points) purchase.unitOrLinePrice],
lineColor: theme.colorScheme.primary,
fillColor: theme.colorScheme.primary.withValues(alpha: 0.12),
gridColor: theme.colorScheme.outlineVariant,
@@ -87,7 +85,12 @@ class _PriceChartPainter extends CustomPainter {
const right = 8.0;
const top = 12.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()
..color = gridColor
+3 -1
View File
@@ -63,7 +63,9 @@ class _LetterAvatar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final trimmed = name.trim();
final letter = trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase();
final letter = trimmed.isEmpty
? '?'
: trimmed.substring(0, 1).toUpperCase();
return ColoredBox(
color: colorScheme.secondaryContainer,
child: Center(
+618
View File
@@ -0,0 +1,618 @@
/// Banner, list markers, and merge sheet for name duplicates.
library;
import 'package:flutter/material.dart';
import '../models/product.dart';
import '../services/product_repository.dart';
import '../utils/money.dart';
import '../utils/product_names.dart';
import 'product_image.dart';
const IconData kProductNameConflictIcon = Icons.merge_type;
class ProductNameConflictBanner extends StatelessWidget {
const ProductNameConflictBanner({
super.key,
required this.issues,
required this.onTap,
});
final ProductNameIssues issues;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final title = _bannerTitle(issues);
final subtitle = _bannerSubtitle(issues);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Material(
color: scheme.errorContainer.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
child: Row(
children: [
Icon(kProductNameConflictIcon, color: scheme.error),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleSmall),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
],
),
),
Icon(Icons.chevron_right, color: scheme.onSurfaceVariant),
],
),
),
),
),
);
}
}
String _bannerTitle(ProductNameIssues issues) {
final count = issues.issueCount;
if (count == 1 && issues.mergeGroups.length == 1) {
return '1 duplicate product';
}
if (count == 1 && issues.spellingVariants.length == 1) {
return '1 product, multiple spellings';
}
return '$count name issues';
}
String _bannerSubtitle(ProductNameIssues issues) {
final examples = <String>[];
for (final group in issues.mergeGroups.take(2)) {
final store = group.supermarket == null ? '' : ' @ ${group.supermarket}';
examples.add(
'${group.products.map((product) => product.name).join(' / ')}$store',
);
}
for (final variant in issues.spellingVariants.take(2)) {
final store = variant.supermarket == null
? ''
: ' @ ${variant.supermarket}';
examples.add('${variant.variants.join(' / ')}$store');
}
return examples.join(', ');
}
class ProductNameConflictMarker extends StatelessWidget {
const ProductNameConflictMarker({
super.key,
this.onTap,
this.compact = false,
});
final VoidCallback? onTap;
final bool compact;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final icon = Icon(
kProductNameConflictIcon,
size: compact ? 14 : 20,
color: scheme.error,
);
if (onTap == null) return icon;
if (compact) {
return Material(
color: scheme.errorContainer,
shape: const CircleBorder(),
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Padding(padding: const EdgeInsets.all(2), child: icon),
),
);
}
return IconButton(
tooltip: 'Resolve name issues',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: onTap,
icon: icon,
);
}
}
Future<bool> showProductNameConflictSheet(
BuildContext context, {
required ProductRepository repository,
required ProductNameIssues issues,
int? focusProductId,
}) {
if (issues.isEmpty) return Future.value(false);
return showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
builder: (context) {
final size = MediaQuery.sizeOf(context);
return SizedBox(
width: size.width,
height: size.height * 0.85,
child: _ProductNameConflictSheet(
repository: repository,
issues: issues,
focusProductId: focusProductId,
),
);
},
).then((resolved) => resolved == true);
}
class _ProductNameConflictSheet extends StatefulWidget {
const _ProductNameConflictSheet({
required this.repository,
required this.issues,
this.focusProductId,
});
final ProductRepository repository;
final ProductNameIssues issues;
final int? focusProductId;
@override
State<_ProductNameConflictSheet> createState() =>
_ProductNameConflictSheetState();
}
class _ProductNameConflictSheetState extends State<_ProductNameConflictSheet> {
late List<ProductMergeGroup> _mergeGroups;
late List<ProductSpellingVariant> _spellingVariants;
late Map<int, int> _keepByMergeGroup;
late Map<int, String> _spellingChoiceByIndex;
final Set<int> _busyMergeGroups = {};
final Set<int> _busySpellingGroups = {};
bool _resolvedAny = false;
@override
void initState() {
super.initState();
_mergeGroups = [for (final group in widget.issues.mergeGroups) group];
_spellingVariants = [
for (final variant in widget.issues.spellingVariants) variant,
];
_rebuildChoices();
}
void _rebuildChoices() {
_keepByMergeGroup = {
for (var i = 0; i < _mergeGroups.length; i++)
i: _defaultKeep(_mergeGroups[i].products),
};
_spellingChoiceByIndex = {
for (var i = 0; i < _spellingVariants.length; i++)
i: _spellingVariants[i].variants.first,
};
}
int _defaultKeep(List<Product> group) {
final sorted = [...group]
..sort((a, b) {
final seen = b.timesSeen.compareTo(a.timesSeen);
if (seen != 0) return seen;
return a.id.compareTo(b.id);
});
return sorted.first.id;
}
bool get _isEmpty => _mergeGroups.isEmpty && _spellingVariants.isEmpty;
int? _focusedMergeIndex() {
final focus = widget.focusProductId;
if (focus == null) return null;
for (var i = 0; i < _mergeGroups.length; i++) {
if (_mergeGroups[i].products.any((product) => product.id == focus)) {
return i;
}
}
return null;
}
int? _focusedSpellingIndex() {
final focus = widget.focusProductId;
if (focus == null) return null;
for (var i = 0; i < _spellingVariants.length; i++) {
if (_spellingVariants[i].product.id == focus) return i;
}
return null;
}
void _maybeClose() {
if (_isEmpty && mounted) {
Navigator.of(context).pop(true);
}
}
Future<void> _mergeGroup(int index) async {
if (_busyMergeGroups.contains(index)) return;
final group = _mergeGroups[index];
final keepId = _keepByMergeGroup[index]!;
setState(() => _busyMergeGroups.add(index));
try {
await widget.repository.mergeProducts(
keepId: keepId,
sourceIds: [for (final product in group.products) product.id],
);
if (!mounted) return;
setState(() {
_resolvedAny = true;
_busyMergeGroups.remove(index);
_mergeGroups.removeAt(index);
_rebuildChoices();
});
_maybeClose();
} catch (_) {
if (!mounted) return;
setState(() => _busyMergeGroups.remove(index));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not merge these products.')),
);
}
}
Future<void> _leaveMergeAsIs(int index) async {
final group = _mergeGroups[index];
await widget.repository.dismissNameIssue(group.key);
if (!mounted) return;
setState(() {
_resolvedAny = true;
_mergeGroups.removeAt(index);
_rebuildChoices();
});
_maybeClose();
}
Future<void> _applySpelling(int index) async {
if (_busySpellingGroups.contains(index)) return;
final variant = _spellingVariants[index];
final chosen = _spellingChoiceByIndex[index]!;
setState(() => _busySpellingGroups.add(index));
try {
await widget.repository.unifyProductSpelling(
productId: variant.product.id,
name: chosen,
supermarket: variant.supermarket,
);
if (!mounted) return;
setState(() {
_resolvedAny = true;
_busySpellingGroups.remove(index);
_spellingVariants.removeAt(index);
_rebuildChoices();
});
_maybeClose();
} catch (_) {
if (!mounted) return;
setState(() => _busySpellingGroups.remove(index));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not update this spelling.')),
);
}
}
Future<void> _leaveSpellingAsIs(int index) async {
final variant = _spellingVariants[index];
await widget.repository.dismissNameIssue(variant.key);
if (!mounted) return;
setState(() {
_resolvedAny = true;
_spellingVariants.removeAt(index);
_rebuildChoices();
});
_maybeClose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final focusMergeIndex = _focusedMergeIndex();
final focusSpellingIndex = _focusedSpellingIndex();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Resolve name issues', style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(
'Same name at the same store. Pick which entry to keep, '
'then merge or leave as separate products.',
style: theme.textTheme.bodyMedium,
),
],
),
),
const SizedBox(height: 12),
Expanded(
child: _isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
_resolvedAny
? 'All name issues resolved.'
: 'No name issues found.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
),
)
: ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
if (_mergeGroups.isNotEmpty) ...[
Text(
'Separate products to merge',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
for (var i = 0; i < _mergeGroups.length; i++) ...[
if (i > 0) const SizedBox(height: 12),
_MergeGroupCard(
group: _mergeGroups[i],
keepId: _keepByMergeGroup[i]!,
busy: _busyMergeGroups.contains(i),
highlighted: focusMergeIndex == i,
onKeepChanged: (id) {
setState(() => _keepByMergeGroup[i] = id);
},
onMerge: () => _mergeGroup(i),
onLeaveAsIs: () => _leaveMergeAsIs(i),
),
],
],
if (_spellingVariants.isNotEmpty) ...[
if (_mergeGroups.isNotEmpty) const SizedBox(height: 20),
Text(
'One product, multiple spellings',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
for (var i = 0; i < _spellingVariants.length; i++) ...[
if (i > 0) const SizedBox(height: 12),
_SpellingVariantCard(
variant: _spellingVariants[i],
chosen: _spellingChoiceByIndex[i]!,
busy: _busySpellingGroups.contains(i),
highlighted: focusSpellingIndex == i,
onChosen: (name) {
setState(() => _spellingChoiceByIndex[i] = name);
},
onApply: () => _applySpelling(i),
onLeaveAsIs: () => _leaveSpellingAsIs(i),
),
],
],
],
),
),
],
);
}
}
class _MergeGroupCard extends StatelessWidget {
const _MergeGroupCard({
required this.group,
required this.keepId,
required this.busy,
required this.highlighted,
required this.onKeepChanged,
required this.onMerge,
required this.onLeaveAsIs,
});
final ProductMergeGroup group;
final int keepId;
final bool busy;
final bool highlighted;
final ValueChanged<int> onKeepChanged;
final VoidCallback onMerge;
final VoidCallback onLeaveAsIs;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final products = group.products;
return Card(
color: highlighted
? theme.colorScheme.tertiaryContainer.withValues(alpha: 0.35)
: null,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
kProductNameConflictIcon,
size: 18,
color: theme.colorScheme.tertiary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
[
normalizeProductName(products.first.name),
if (group.supermarket != null) group.supermarket!,
].join(' · '),
style: theme.textTheme.titleSmall,
),
),
],
),
const SizedBox(height: 8),
for (final product in products)
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: ProductImage(
name: product.name,
path: product.imagePath,
size: 36,
),
title: Text(product.name),
subtitle: Text(
[
formatEuro(product.lastPrice),
product.timesSeen == 1
? '1 purchase'
: '${product.timesSeen} purchases',
].join(' · '),
),
trailing: Icon(
keepId == product.id
? Icons.check_circle
: Icons.circle_outlined,
color: keepId == product.id
? theme.colorScheme.primary
: theme.colorScheme.outline,
),
onTap: busy ? null : () => onKeepChanged(product.id),
),
Wrap(
alignment: WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
TextButton(
onPressed: busy ? null : onLeaveAsIs,
child: const Text('Leave as is'),
),
FilledButton(
onPressed: busy ? null : onMerge,
child: busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Merge'),
),
],
),
],
),
),
);
}
}
class _SpellingVariantCard extends StatelessWidget {
const _SpellingVariantCard({
required this.variant,
required this.chosen,
required this.busy,
required this.highlighted,
required this.onChosen,
required this.onApply,
required this.onLeaveAsIs,
});
final ProductSpellingVariant variant;
final String chosen;
final bool busy;
final bool highlighted;
final ValueChanged<String> onChosen;
final VoidCallback onApply;
final VoidCallback onLeaveAsIs;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final product = variant.product;
return Card(
color: highlighted
? theme.colorScheme.tertiaryContainer.withValues(alpha: 0.35)
: null,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: ProductImage(
name: product.name,
path: product.imagePath,
size: 36,
),
title: Text(product.name),
subtitle: Text(
[
if (variant.supermarket != null) variant.supermarket!,
product.timesSeen == 1
? '1 purchase'
: '${product.timesSeen} purchases',
].join(' · '),
),
),
const SizedBox(height: 4),
Text('Recorded as:', style: theme.textTheme.labelLarge),
const SizedBox(height: 4),
for (final name in variant.variants)
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(name),
trailing: Icon(
chosen == name ? Icons.check_circle : Icons.circle_outlined,
color: chosen == name
? theme.colorScheme.primary
: theme.colorScheme.outline,
),
onTap: busy ? null : () => onChosen(name),
),
Wrap(
alignment: WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
TextButton(
onPressed: busy ? null : onLeaveAsIs,
child: const Text('Leave as is'),
),
FilledButton(
onPressed: busy ? null : onApply,
child: Text(busy ? 'Saving…' : 'Use this spelling'),
),
],
),
],
),
),
);
}
}
+23
View File
@@ -0,0 +1,23 @@
/// Opens the profile screen from an app bar.
library;
import 'package:flutter/material.dart';
import '../profile_screen.dart';
class ProfileButton extends StatelessWidget {
const ProfileButton({super.key});
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: 'Profile',
onPressed: () {
Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => const ProfileScreen()));
},
icon: const Icon(Icons.person_outline),
);
}
}
+124 -252
View File
@@ -3,9 +3,10 @@ library;
import 'package:flutter/material.dart';
import '../models/supermarket.dart';
import '../services/app_settings.dart';
import '../services/backup_service.dart';
import '../supermarkets_screen.dart';
import 'confirm_dialog.dart';
class SettingsButton extends StatelessWidget {
const SettingsButton({super.key});
@@ -47,9 +48,8 @@ class _SettingsSheetState extends State<_SettingsSheet> {
final shared = await BackupService(settings.repository).exportBackup();
if (!mounted) return;
if (shared) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Backup ready.')),
);
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Backup ready.')));
}
} catch (_) {
if (!mounted) return;
@@ -92,9 +92,8 @@ class _SettingsSheetState extends State<_SettingsSheet> {
if (imported) {
await settings.reload();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Backup imported.')),
);
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Backup imported.')));
}
} catch (_) {
if (!mounted) return;
@@ -106,267 +105,140 @@ class _SettingsSheetState extends State<_SettingsSheet> {
}
}
Future<void> _wipeAllData(AppSettings settings) async {
final confirmed = await confirmAction(
context,
title: 'Delete all data?',
message:
'This permanently removes all products, shopping trips, lists, '
'photos, bonus cards, and settings on this device. '
'Export a backup first if you want to keep anything.',
confirmLabel: 'Delete everything',
);
if (!confirmed || !mounted) return;
setState(() => _busy = true);
try {
await settings.wipeAllData();
if (!mounted) return;
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All data deleted.')),
);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not delete all data.')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _openSupermarkets(BuildContext context, AppSettings settings) {
Navigator.of(context)
..pop()
..push(
MaterialPageRoute(
builder: (_) => SupermarketsScreen(repository: settings.repository),
),
);
}
@override
Widget build(BuildContext context) {
final settings = SettingsScope.of(context);
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final bottom = MediaQuery.viewInsetsOf(context).bottom;
final maxHeight = MediaQuery.sizeOf(context).height * 0.85;
return ListenableBuilder(
listenable: settings,
builder: (context, _) {
return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.75,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Settings', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Dark mode'),
value: settings.darkMode,
onChanged: _busy ? null : settings.setDarkMode,
return SizedBox(
height: maxHeight,
child: ListView(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
children: [
Text('Settings', style: theme.textTheme.titleLarge),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Dark mode'),
value: settings.darkMode,
onChanged: _busy ? null : settings.setDarkMode,
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.store_outlined),
title: const Text('Supermarkets'),
subtitle: Text(
settings.supermarkets.isEmpty
? 'Names, colors, and bonus cards'
: '${settings.supermarkets.length} stores',
),
Text('Backup', style: Theme.of(context).textTheme.titleMedium),
Text(
'Save everything to a JSON file, or replace this device from a backup.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _export(settings),
icon: const Icon(Icons.file_upload_outlined),
label: const Text('Export'),
),
trailing: const Icon(Icons.chevron_right),
onTap: _busy
? null
: () => _openSupermarkets(context, settings),
),
const SizedBox(height: 16),
Text('Backup', style: theme.textTheme.titleMedium),
Text(
'Save everything to a JSON file, or replace this device from a backup.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _export(settings),
icon: const Icon(Icons.file_upload_outlined),
label: const Text('Export'),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _import(settings),
icon: const Icon(Icons.file_download_outlined),
label: const Text('Import'),
),
),
],
),
if (_busy) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(),
],
const SizedBox(height: 16),
Text(
'Supermarkets',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'Name and color used as tags on products.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Expanded(
child: ListView(
children: [
for (final store in settings.supermarkets)
_StoreRow(
store: store,
onEdit: _busy
? () {}
: () => _editStore(context, settings, store),
onDelete: _busy || store.id == null
? null
: () => settings.deleteSupermarket(store.id!),
),
],
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _import(settings),
icon: const Icon(Icons.file_download_outlined),
label: const Text('Import'),
),
),
],
),
if (_busy) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _busy
? null
: () => _editStore(context, settings, null),
icon: const Icon(Icons.add),
label: const Text('Add supermarket'),
),
const LinearProgressIndicator(),
],
),
const SizedBox(height: 32),
Text(
'Danger zone',
style: theme.textTheme.titleMedium?.copyWith(
color: scheme.error,
),
),
const SizedBox(height: 4),
Text(
'Permanently delete everything on this device.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: scheme.error,
side: BorderSide(color: scheme.error),
),
onPressed: _busy ? null : () => _wipeAllData(settings),
icon: const Icon(Icons.delete_forever_outlined),
label: const Text('Delete all data'),
),
],
),
);
},
);
}
Future<void> _editStore(
BuildContext context,
AppSettings settings,
Supermarket? existing,
) async {
final result = await showDialog<Supermarket>(
context: context,
builder: (context) => _StoreEditDialog(store: existing),
);
if (result == null) return;
if (existing == null) {
try {
await settings.addSupermarket(result.name, result.colorValue);
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('That supermarket already exists.')),
);
}
}
} else {
await settings.updateSupermarket(
existing.copyWith(name: result.name, colorValue: result.colorValue),
);
}
}
}
class _StoreRow extends StatelessWidget {
const _StoreRow({
required this.store,
required this.onEdit,
this.onDelete,
});
final Supermarket store;
final VoidCallback onEdit;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final onColor = store.color.computeLuminance() > 0.55
? Colors.black87
: Colors.white;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: store.color,
child: Text(
store.name.isEmpty ? '?' : store.name.substring(0, 1),
style: TextStyle(color: onColor, fontWeight: FontWeight.w700),
),
),
title: Text(store.name),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Delete',
onPressed: onDelete,
icon: const Icon(Icons.delete_outline),
),
],
),
);
}
}
class _StoreEditDialog extends StatefulWidget {
const _StoreEditDialog({this.store});
final Supermarket? store;
@override
State<_StoreEditDialog> createState() => _StoreEditDialogState();
}
class _StoreEditDialogState extends State<_StoreEditDialog> {
late final TextEditingController _name;
late int _color;
@override
void initState() {
super.initState();
_name = TextEditingController(text: widget.store?.name ?? '');
_color = widget.store?.colorValue ?? kStoreColorPalette.first;
}
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.store == null ? 'Add supermarket' : 'Edit supermarket'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _name,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: widget.store == null,
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: Text('Color', style: Theme.of(context).textTheme.titleMedium),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final value in kStoreColorPalette)
GestureDetector(
onTap: () => setState(() => _color = value),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Color(value),
shape: BoxShape.circle,
border: Border.all(
color: _color == value
? Theme.of(context).colorScheme.onSurface
: Colors.transparent,
width: 2,
),
),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = _name.text.trim();
if (name.isEmpty) return;
Navigator.pop(
context,
Supermarket(
id: widget.store?.id,
name: name,
colorValue: _color,
sortOrder: widget.store?.sortOrder ?? 0,
),
);
},
child: const Text('Save'),
),
],
);
}
}
+119
View File
@@ -0,0 +1,119 @@
/// Dialog for adding or editing a supermarket name and color.
library;
import 'package:flutter/material.dart';
import '../models/supermarket.dart';
Future<Supermarket?> showStoreEditDialog(
BuildContext context, {
Supermarket? store,
}) {
return showDialog<Supermarket>(
context: context,
builder: (context) => _StoreEditDialog(store: store),
);
}
class _StoreEditDialog extends StatefulWidget {
const _StoreEditDialog({this.store});
final Supermarket? store;
@override
State<_StoreEditDialog> createState() => _StoreEditDialogState();
}
class _StoreEditDialogState extends State<_StoreEditDialog> {
late final TextEditingController _name;
late int _color;
@override
void initState() {
super.initState();
_name = TextEditingController(text: widget.store?.name ?? '');
_color = widget.store?.colorValue ?? kStoreColorPalette.first;
}
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
widget.store == null ? 'Add supermarket' : 'Edit supermarket',
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _name,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: widget.store == null,
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: Text(
'Color',
style: Theme.of(context).textTheme.titleMedium,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final value in kStoreColorPalette)
GestureDetector(
onTap: () => setState(() => _color = value),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Color(value),
shape: BoxShape.circle,
border: Border.all(
color: _color == value
? Theme.of(context).colorScheme.onSurface
: Colors.transparent,
width: 2,
),
),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = _name.text.trim();
if (name.isEmpty) return;
Navigator.pop(
context,
Supermarket(
id: widget.store?.id,
name: name,
colorValue: _color,
sortOrder: widget.store?.sortOrder ?? 0,
bonusImagePath: widget.store?.bonusImagePath,
),
);
},
child: const Text('Save'),
),
],
);
}
}
+32
View File
@@ -256,6 +256,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
google_mlkit_commons:
dependency: transitive
description:
@@ -464,6 +472,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72
url: "https://pub.dev"
source: hosted
version: "0.19.2"
objective_c:
dependency: transitive
description:
@@ -637,6 +653,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.5.11"
sqflite_common_ffi:
dependency: "direct dev"
description:
name: sqflite_common_ffi
sha256: d5564f1308cafbf064be498f2beb1e993e742985a7cca9e2e228d6cbccd84a05
url: "https://pub.dev"
source: hosted
version: "2.4.2+1"
sqflite_darwin:
dependency: transitive
description:
@@ -653,6 +677,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c"
url: "https://pub.dev"
source: hosted
version: "3.5.2"
stack_trace:
dependency: transitive
description:
+2 -1
View File
@@ -1,4 +1,4 @@
name: receipity
name: groci
description: Capture a shopping receipt and show Google ML Kit text recognition output.
publish_to: 'none'
version: 1.0.0+1
@@ -24,6 +24,7 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
sqflite_common_ffi: ^2.4.0
flutter:
uses-material-design: true
+4 -3
View File
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/services/backup_format.dart';
import 'package:groci/services/backup_format.dart';
void main() {
test('round-trips a backup document', () {
@@ -81,9 +81,10 @@ void main() {
expect(parsed.shoppingLists.single['name'], 'Weekly');
expect(parsed.shoppingListItems.single['name'], 'Milk');
expect(parsed.images['products/product_1.jpg'], 'abc123');
expect(parsed.encode(), contains('"format": "groci-backup"'));
});
test('accepts a version 1 backup without shopping lists', () {
test('accepts legacy receipity backup format', () {
const json = '''
{
"format": "receipity-backup",
@@ -104,7 +105,7 @@ void main() {
expect(parsed.shopTags, isEmpty);
});
test('rejects a file that is not a Receipity backup', () {
test('rejects a file that is not a Groci backup', () {
expect(
() => BackupDocument.parse('{"hello":"world"}'),
throwsFormatException,
+111
View File
@@ -0,0 +1,111 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:groci/models/line_item.dart';
import 'test_helpers.dart';
void main() {
ensureTestSqlite();
test(
'deleting a product keeps the trip line and drops the catalog row',
() async {
final repository = memoryRepository();
final shopId = await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 12),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
final products = await repository.getAll();
expect(products, hasLength(1));
await repository.deleteProduct(products.single.id);
expect(await repository.getAll(), isEmpty);
final items = await repository.getShopItems(shopId);
expect(items, hasLength(1));
expect(items.single.name, 'Milk');
expect(items.single.productId, isNull);
},
);
test('addShopItem appends a line and updates the trip total', () async {
final repository = memoryRepository();
final shopId = await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 12),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
await repository.addShopItem(
shopId: shopId,
item: LineItem(id: '2', name: 'Bread', price: 2),
);
final shop = (await repository.getShop(shopId))!;
expect(shop.total, 3.5);
final items = await repository.getShopItems(shopId);
expect(items, hasLength(2));
expect(items.map((item) => item.name), ['Milk', 'Bread']);
expect((await repository.getAll()).map((product) => product.name).toSet(), {
'Milk',
'Bread',
});
});
test('getLastTripSupermarkets lists unique stores newest first', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Albert Heijn',
shoppedAt: DateTime(2026, 8, 12),
items: [LineItem(id: '2', name: 'Bread', price: 2)],
);
expect(await repository.getLastTripSupermarkets(), [
'Albert Heijn',
'Jumbo',
]);
});
test('wipeAllData clears products, trips, lists, and settings', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 12),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
await repository.createShoppingList('Weekly');
await repository.setDarkMode(true);
await repository.wipeAllData();
expect(await repository.getAll(), isEmpty);
expect(await repository.getShops(), isEmpty);
expect(await repository.getShoppingLists(), isEmpty);
expect(await repository.getDarkMode(), isFalse);
expect((await repository.getSupermarkets()).length, greaterThan(0));
});
test('deleting all products leaves shopping trips in place', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 12),
items: [
LineItem(id: '1', name: 'Milk', price: 1.5),
LineItem(id: '2', name: 'Bread', price: 2),
],
);
await repository.deleteAllProducts();
expect(await repository.getAll(), isEmpty);
expect(await repository.getShops(), hasLength(1));
final overview = await repository.getOverview();
expect(overview.productCount, 0);
expect(overview.tripCount, 1);
});
}
+5 -2
View File
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/utils/fuzzy.dart';
import 'package:groci/utils/fuzzy.dart';
void main() {
test('matches substrings and character subsequences', () {
@@ -10,6 +10,9 @@ void main() {
});
test('ranks closer matches higher', () {
expect(fuzzyScore('melk', 'MELK')! > fuzzyScore('melk', 'JUMBO BIO VOLLE MELK')!, isTrue);
expect(
fuzzyScore('melk', 'MELK')! > fuzzyScore('melk', 'JUMBO BIO VOLLE MELK')!,
isTrue,
);
});
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:groci/models/line_item.dart';
import 'package:groci/widgets/item_edit_sheet.dart';
void main() {
testWidgets('edit sheet syncs quantity unit and total', (tester) async {
LineItem? saved;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) {
return TextButton(
onPressed: () async {
saved = await showItemEditSheet(
context,
item: LineItem(
id: '1',
name: 'Milk',
price: 3.98,
quantity: 2,
unitPrice: 1.99,
),
);
},
child: const Text('Open'),
);
},
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('Edit item'), findsOneWidget);
expect(find.text('Qty'), findsOneWidget);
expect(find.text('Unit price'), findsOneWidget);
expect(find.text('Total price'), findsOneWidget);
expect(find.text('2 × € 1.99'), findsOneWidget);
await tester.enterText(find.widgetWithText(TextField, 'Qty'), '3');
await tester.pump();
expect(find.text('3 × € 1.99'), findsOneWidget);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(saved, isNotNull);
expect(saved!.name, 'Milk');
expect(saved!.quantity, 3);
expect(saved!.unitPrice, 1.99);
expect(saved!.price, closeTo(5.97, 0.001));
});
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:groci/models/line_item.dart';
import 'package:groci/models/product.dart';
import 'package:groci/utils/product_names.dart';
import 'test_helpers.dart';
Product _product(int id, String name, {String? supermarket}) => Product(
id: id,
name: name,
lastPrice: 1,
timesSeen: 1,
updatedAt: DateTime(2026, 1, 1),
supermarket: supermarket,
);
void main() {
ensureTestSqlite();
group('nameConflictGroups', () {
test('groups products that differ only by case at the same store', () {
final groups = nameConflictGroups([
_product(1, 'Broccoli', supermarket: 'Jumbo'),
_product(2, 'BROCCOLI', supermarket: 'Jumbo'),
_product(3, 'Milk', supermarket: 'Jumbo'),
]);
expect(groups, hasLength(1));
expect(groups.single.map((product) => product.id), [1, 2]);
});
test('ignores same name at different stores', () {
final groups = nameConflictGroups([
_product(1, 'Broccoli', supermarket: 'Jumbo'),
_product(2, 'BROCCOLI', supermarket: 'Lidl'),
]);
expect(groups, isEmpty);
});
});
group('buildNameConflictGroups', () {
test('finds conflicts from trip lines linked to different products', () {
final groups = buildNameConflictGroups(
products: [
_product(1, 'Broccoli', supermarket: 'Jumbo'),
_product(2, 'BROCCOLI', supermarket: 'Jumbo'),
],
shopLines: const [
(productId: 1, name: 'Broccoli', supermarket: 'Jumbo'),
(productId: 2, name: 'BROCCOLI', supermarket: 'Jumbo'),
],
);
expect(groups, hasLength(1));
expect(groups.single.products.map((product) => product.id), [1, 2]);
expect(groups.single.supermarket, 'Jumbo');
});
});
group('findSpellingVariants', () {
test('finds multiple receipt spellings for one product at one store', () {
final variants = findSpellingVariants(
products: [_product(1, 'Broccoli', supermarket: 'Jumbo')],
shopLines: const [
(productId: 1, name: 'Broccoli', supermarket: 'Jumbo'),
(productId: 1, name: 'BROCCOLI', supermarket: 'Jumbo'),
],
);
expect(variants, hasLength(1));
expect(variants.single.product.id, 1);
expect(variants.single.supermarket, 'Jumbo');
expect(variants.single.variants, ['Broccoli', 'BROCCOLI']);
});
test('ignores different spellings at different stores', () {
final variants = findSpellingVariants(
products: [_product(1, 'Broccoli', supermarket: 'Jumbo')],
shopLines: const [
(productId: 1, name: 'Broccoli', supermarket: 'Jumbo'),
(productId: 1, name: 'BROCCOLI', supermarket: 'Lidl'),
],
);
expect(variants, isEmpty);
});
});
test('same name at different supermarkets creates separate products', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Lidl',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'Milk', price: 1.2)],
);
final products = await repository.getAll();
expect(products, hasLength(2));
expect(products.map((p) => p.supermarket).toSet(), {'Jumbo', 'Lidl'});
expect(await repository.getProductNameIssues(), isEmpty);
});
test('getNameConflictGroups finds duplicates in the database', () async {
final repository = memoryRepository();
await seedCaseConflictCatalog(repository);
final groups = await repository.getNameConflictGroups();
expect(groups, hasLength(1));
expect(groups.single.map((product) => product.name).toSet(), {
'Broccoli',
'BROCCOLI',
});
});
test('getProductNameIssues finds receipt spelling variants', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)],
);
final issues = await repository.getProductNameIssues();
expect(issues.mergeGroups, isEmpty);
expect(issues.spellingVariants, hasLength(1));
expect(issues.spellingVariants.single.supermarket, 'Jumbo');
expect(issues.spellingVariants.single.variants, ['Broccoli', 'BROCCOLI']);
});
test('dismissNameIssue hides a spelling variant', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)],
);
final issue =
(await repository.getProductNameIssues()).spellingVariants.single;
await repository.dismissNameIssue(issue.key);
expect(await repository.getProductNameIssues(), isEmpty);
});
test('mergeProducts combines purchases onto the kept product', () async {
final repository = memoryRepository();
await seedCaseConflictCatalog(repository);
await repository.mergeProducts(keepId: 1, sourceIds: [2]);
final after = await repository.getAll();
expect(after, hasLength(1));
expect(after.single.name, 'Broccoli');
expect(after.single.timesSeen, 2);
expect(await repository.getPurchases(1), hasLength(2));
});
test('unifyProductSpelling updates catalog and receipt lines', () async {
final repository = memoryRepository();
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)],
);
final product = (await repository.getAll()).single;
await repository.unifyProductSpelling(
productId: product.id,
name: 'BROCCOLI',
supermarket: 'Jumbo',
);
final updated = (await repository.getProduct(product.id))!;
expect(updated.name, 'BROCCOLI');
expect((await repository.getProductNameIssues()).isEmpty, isTrue);
});
}
+2 -1
View File
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/services/receipt_parser.dart';
import 'package:groci/services/receipt_parser.dart';
void main() {
test('parses name and price at the end of a line', () {
@@ -56,6 +56,7 @@ Totaal korting: -2,00
expect(result.items.map((item) => item.name), ['AARDBEIEN']);
expect(result.items.single.price, 5.98);
expect(result.items.single.discount, -2.00);
expect(result.items.single.originalPrice, 7.98);
expect(result.validationPassed, isTrue);
});
+3 -3
View File
@@ -1,7 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/models/shop.dart';
import 'package:receipity/utils/shop_calendar.dart';
import 'package:receipity/utils/shop_log_filter.dart';
import 'package:groci/models/shop.dart';
import 'package:groci/utils/shop_calendar.dart';
import 'package:groci/utils/shop_log_filter.dart';
Shop _shop({
required int id,
+30 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/utils/supermarkets.dart';
import 'package:groci/models/supermarket.dart';
import 'package:groci/utils/supermarkets.dart';
void main() {
test('detects Jumbo and Lidl from receipt text', () {
@@ -20,4 +21,32 @@ void main() {
test('returns null when no supermarket is present', () {
expect(detectSupermarket('Milk 1.50\nBread 2.00'), isNull);
});
test('sortStoresByLastTrip puts the latest trip store first', () {
const jumbo = Supermarket(id: 1, name: 'Jumbo', colorValue: 1, sortOrder: 0);
const lidl = Supermarket(id: 2, name: 'Lidl', colorValue: 2, sortOrder: 1);
const ah = Supermarket(
id: 3,
name: 'Albert Heijn',
colorValue: 3,
sortOrder: 2,
);
final afterJumbo = sortStoresByLastTrip([jumbo, lidl, ah], ['Jumbo']);
expect(afterJumbo.map((store) => store.name), [
'Jumbo',
'Lidl',
'Albert Heijn',
]);
final afterAh = sortStoresByLastTrip(
[jumbo, lidl, ah],
['Albert Heijn', 'Jumbo'],
);
expect(afterAh.map((store) => store.name), [
'Albert Heijn',
'Jumbo',
'Lidl',
]);
});
}
+108
View File
@@ -0,0 +1,108 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:groci/services/product_repository.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
var _dbSeq = 0;
var _sqliteReady = false;
void ensureTestSqlite() {
if (_sqliteReady) return;
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
_sqliteReady = true;
}
ProductRepository memoryRepository() {
ensureTestSqlite();
final dir = Directory(p.join('.dart_tool', 'groci_test_db'))
..createSync(recursive: true);
final name =
'db_${DateTime.now().microsecondsSinceEpoch}_${_dbSeq++}_${Random().nextInt(1 << 32)}.sqlite';
return ProductRepository(databasePath: p.join(dir.path, name));
}
Future<void> pumpUntilFound(
WidgetTester tester,
Finder finder, {
int attempts = 40,
}) async {
for (var i = 0; i < attempts; i++) {
await tester.pump();
if (finder.evaluate().isNotEmpty) return;
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 20)),
);
}
throw TestFailure('Timed out waiting for $finder');
}
const _seedTs = '2026-08-01T00:00:00.000';
Future<void> seedCaseConflictCatalog(ProductRepository repository) async {
await repository.replaceAllData(
darkMode: false,
supermarkets: [
{'id': 1, 'name': 'Jumbo', 'color': 0xFFEEC21B, 'sort_order': 0},
],
products: [
{
'id': 1,
'name': 'Broccoli',
'normalized_name': 'BROCCOLI_A',
'last_price': 1.5,
'times_seen': 1,
'favourite': 0,
'supermarket': 'Jumbo',
'created_at': _seedTs,
'updated_at': _seedTs,
},
{
'id': 2,
'name': 'BROCCOLI',
'normalized_name': 'BROCCOLI_B',
'last_price': 1.8,
'times_seen': 1,
'favourite': 0,
'supermarket': 'Jumbo',
'created_at': _seedTs,
'updated_at': _seedTs,
},
],
shops: [
{
'id': 1,
'supermarket': 'Jumbo',
'shopped_at': '2026-08-01T00:00:00.000',
'total': 1.5,
'created_at': _seedTs,
},
{
'id': 2,
'supermarket': 'Jumbo',
'shopped_at': '2026-08-02T00:00:00.000',
'total': 1.8,
'created_at': _seedTs,
},
],
shopItems: [
{
'id': 1,
'shop_id': 1,
'product_id': 1,
'name': 'Broccoli',
'price': 1.5,
},
{
'id': 2,
'shop_id': 2,
'product_id': 2,
'name': 'BROCCOLI',
'price': 1.8,
},
],
);
}
+249 -22
View File
@@ -1,18 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/main.dart';
import 'package:receipity/models/line_item.dart';
import 'package:receipity/review_screen.dart';
import 'package:receipity/services/product_repository.dart';
import 'package:receipity/widgets/shop_heatmap.dart';
import 'package:groci/main.dart';
import 'package:groci/models/line_item.dart';
import 'package:groci/review_screen.dart';
import 'package:groci/widgets/shop_heatmap.dart';
import 'test_helpers.dart';
void main() {
testWidgets('scan screen shows capture actions and bottom navigation', (
ensureTestSqlite();
testWidgets('home screen shows overview and bottom navigation', (
tester,
) async {
await tester.pumpWidget(ReceipityApp());
await tester.pumpWidget(GrociApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Nothing logged yet.'));
expect(find.text('Receipity'), findsOneWidget);
expect(find.text('Home'), findsWidgets);
expect(find.text('Products'), findsOneWidget);
expect(find.text('Lists'), findsOneWidget);
expect(find.byTooltip('Add'), findsOneWidget);
expect(find.byIcon(Icons.add), findsWidgets);
expect(find.text('Shop log'), findsOneWidget);
expect(find.byTooltip('Profile'), findsOneWidget);
expect(find.byTooltip('Settings'), findsOneWidget);
});
testWidgets('scan screen shows capture actions', (tester) async {
await tester.pumpWidget(GrociApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.byTooltip('Add'));
await tester.pump();
expect(find.text('Groci'), findsOneWidget);
expect(find.text('Take photo'), findsOneWidget);
expect(find.text('Pick from gallery'), findsOneWidget);
expect(find.text('Enter manually'), findsOneWidget);
@@ -21,18 +41,111 @@ void main() {
find.textContaining('Scan a receipt or enter a trip by hand'),
findsOneWidget,
);
expect(find.text('Products'), findsOneWidget);
expect(find.text('Lists'), findsOneWidget);
expect(find.byTooltip('Add'), findsOneWidget);
expect(find.byIcon(Icons.add), findsOneWidget);
expect(find.text('Shop log'), findsOneWidget);
});
testWidgets('shop log has product search', (tester) async {
await tester.pumpWidget(ReceipityApp());
testWidgets('empty shop log hides edit and disables search', (tester) async {
await tester.pumpWidget(GrociApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Shop log'));
await tester.pump();
await pumpUntilFound(
tester,
find.text('Scan a receipt or enter a trip to log it.'),
);
expect(find.byTooltip('Edit'), findsNothing);
expect(find.byTooltip('Search products'), findsOneWidget);
expect(
tester
.widget<IconButton>(
find.ancestor(
of: find.byIcon(Icons.search),
matching: find.byType(IconButton),
),
)
.onPressed,
isNull,
);
});
testWidgets('deleting a trip removes it without leaving the screen', (
tester,
) async {
final repository = memoryRepository();
await tester.runAsync(() async {
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime.now(),
name: 'Weekly shop',
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
});
await tester.pumpWidget(GrociApp(repository: repository));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Shop log'));
await pumpUntilFound(tester, find.text('Weekly shop'));
await tester.tap(find.byTooltip('Edit'));
await tester.pump();
await tester.tap(find.byTooltip('Remove'));
await tester.pump();
await tester.tap(find.text('Delete'));
await tester.pump();
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
expect(find.text('Weekly shop'), findsNothing);
expect(
find.text('Scan a receipt or enter a trip to log it.'),
findsOneWidget,
);
expect(find.byTooltip('Edit'), findsNothing);
});
testWidgets('products edit mode shows delete, star, and wipe all', (
tester,
) async {
final repository = memoryRepository();
await tester.runAsync(() async {
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime.now(),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
});
await tester.pumpWidget(GrociApp(repository: repository));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Products').last);
await pumpUntilFound(tester, find.text('Milk'));
await tester.tap(find.byTooltip('Edit'));
await tester.pump();
expect(find.byTooltip('Delete'), findsOneWidget);
expect(find.byTooltip('Favourite'), findsOneWidget);
expect(find.byTooltip('Delete all products'), findsOneWidget);
await tester.tap(find.byTooltip('Delete all products'));
await tester.pump();
await tester.tap(find.text('Delete'));
await tester.pump();
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
expect(find.text('Milk'), findsNothing);
expect(find.byTooltip('Edit'), findsNothing);
});
testWidgets('profile screen is a placeholder', (tester) async {
await tester.pumpWidget(GrociApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.byTooltip('Profile'));
await pumpUntilFound(tester, find.text('Guest'));
});
testWidgets('shop heatmap renders the visible month', (tester) async {
@@ -59,15 +172,18 @@ void main() {
});
testWidgets('enter manually opens a blank trip', (tester) async {
await tester.pumpWidget(ReceipityApp());
await tester.pumpWidget(GrociApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.byTooltip('Add'));
await tester.pump();
await tester.tap(find.text('Enter manually'));
await tester.pumpAndSettle();
expect(find.text('New trip'), findsOneWidget);
await pumpUntilFound(tester, find.text('New trip'));
expect(find.text('Add item'), findsOneWidget);
});
testWidgets('lists tab shows shopping lists', (tester) async {
await tester.pumpWidget(ReceipityApp());
await tester.pumpWidget(GrociApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Lists'));
await tester.pump();
expect(find.text('Shopping lists'), findsOneWidget);
@@ -82,7 +198,7 @@ void main() {
LineItem(id: '2', name: 'Bread', price: 2),
],
rawText: 'Milk 1.50\nBread 2.00',
repository: ProductRepository(databasePath: ':memory:'),
repository: memoryRepository(),
),
),
);
@@ -109,11 +225,122 @@ void main() {
home: ReviewScreen(
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
rawText: 'Jumbo\nMilk 1.50',
repository: ProductRepository(databasePath: ':memory:'),
repository: memoryRepository(),
),
),
);
expect(find.text('Jumbo'), findsWidgets);
});
testWidgets('review screen strikes through the pre-discount price', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: ReviewScreen(
items: [
LineItem(id: '1', name: 'Berries', price: 6.85, discount: -1.15),
],
rawText: 'Berries 8.00\nKorting -1.15',
repository: memoryRepository(),
),
),
);
expect(find.text('€ 8.00'), findsOneWidget);
expect(find.text('€ 6.85'), findsOneWidget);
final original = tester.widget<Text>(find.text('€ 8.00'));
expect(original.style?.decoration, TextDecoration.lineThrough);
});
testWidgets('product name is saved when leaving the screen', (tester) async {
final repository = memoryRepository();
await tester.runAsync(() async {
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime.now(),
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
);
});
await tester.pumpWidget(GrociApp(repository: repository));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Products').last);
await pumpUntilFound(tester, find.text('Milk'));
await tester.tap(find.text('Milk'));
await pumpUntilFound(tester, find.text('Name'));
await tester.enterText(find.byType(TextField).first, 'Oat milk');
await tester.tap(find.byTooltip('Back'));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 150)),
);
await tester.pump();
expect(find.byType(TextField), findsNothing);
expect(find.text('Oat milk'), findsOneWidget);
expect(find.text('Milk'), findsNothing);
});
testWidgets('products screen marks case-only duplicates', (tester) async {
final repository = memoryRepository();
await tester.runAsync(() => seedCaseConflictCatalog(repository));
await tester.pumpWidget(GrociApp(repository: repository));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Products').last);
await pumpUntilFound(tester, find.text('1 duplicate product'));
expect(find.text('Broccoli'), findsOneWidget);
expect(find.text('BROCCOLI'), findsOneWidget);
expect(find.byTooltip('Resolve name issues'), findsWidgets);
});
testWidgets('products screen marks receipt spelling variants', (
tester,
) async {
final repository = memoryRepository();
await tester.runAsync(() async {
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 1),
items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)],
);
await repository.saveShop(
supermarket: 'Jumbo',
shoppedAt: DateTime(2026, 8, 2),
items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)],
);
});
await tester.pumpWidget(GrociApp(repository: repository));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Products').last);
await pumpUntilFound(tester, find.text('1 product, multiple spellings'));
expect(find.text('Broccoli'), findsOneWidget);
expect(find.byTooltip('Resolve name issues'), findsWidgets);
});
testWidgets('resolve name issues sheet shows merge and leave as is', (
tester,
) async {
final repository = memoryRepository();
await tester.runAsync(() => seedCaseConflictCatalog(repository));
await tester.pumpWidget(GrociApp(repository: repository));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Products').last);
await pumpUntilFound(tester, find.text('1 duplicate product'));
await tester.tap(find.text('1 duplicate product'));
await pumpUntilFound(tester, find.text('Resolve name issues'));
expect(find.text('Resolve name issues'), findsOneWidget);
expect(find.text('Separate products to merge'), findsOneWidget);
expect(find.text('Broccoli'), findsWidgets);
expect(find.text('BROCCOLI'), findsWidgets);
expect(find.text('Merge'), findsOneWidget);
expect(find.text('Leave as is'), findsOneWidget);
});
}