Compare commits
7
Commits
3b698a3667
..
v1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdf46bb12a | ||
|
|
edbfade477 | ||
|
|
b0708101b0 | ||
|
|
14fcb9a533 | ||
|
|
756f9db356 | ||
|
|
7692809219 | ||
|
|
92581e6d55 |
@@ -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.
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
/// Dashboard of catalog, trips, lists, and this month's spend.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'models/app_overview.dart';
|
||||
import 'models/shop.dart';
|
||||
import 'models/supermarket.dart';
|
||||
import 'services/app_settings.dart';
|
||||
import 'services/product_repository.dart';
|
||||
import 'shop_detail_screen.dart';
|
||||
import 'utils/dates.dart';
|
||||
import 'utils/money.dart';
|
||||
import 'widgets/profile_button.dart';
|
||||
import 'widgets/settings_button.dart';
|
||||
import 'widgets/store_tag.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({
|
||||
super.key,
|
||||
required this.repository,
|
||||
required this.onOpenProducts,
|
||||
required this.onOpenLists,
|
||||
required this.onOpenShopLog,
|
||||
required this.onOpenAdd,
|
||||
});
|
||||
|
||||
final ProductRepository repository;
|
||||
final VoidCallback onOpenProducts;
|
||||
final VoidCallback onOpenLists;
|
||||
final VoidCallback onOpenShopLog;
|
||||
final VoidCallback onOpenAdd;
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
AppOverview? _overview;
|
||||
List<Supermarket> _stores = const [];
|
||||
Object? _error;
|
||||
int _loadToken = 0;
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.repository.addListener(_load);
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.repository.removeListener(_load);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final token = ++_loadToken;
|
||||
try {
|
||||
final overview = await widget.repository.getOverview();
|
||||
final stores = await widget.repository.getSupermarkets();
|
||||
final lastTripStores = await widget.repository.getLastTripSupermarkets();
|
||||
if (!mounted || token != _loadToken) return;
|
||||
setState(() {
|
||||
_overview = overview;
|
||||
_stores = sortStoresByLastTrip(stores, lastTripStores);
|
||||
_error = null;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted || token != _loadToken) return;
|
||||
setState(() => _error = error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openShop(Shop shop) async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
ShopDetailScreen(repository: widget.repository, shopId: shop.id),
|
||||
),
|
||||
);
|
||||
if (mounted) _load();
|
||||
}
|
||||
|
||||
Future<void> _openBonusCard(Supermarket store) async {
|
||||
final path = store.bonusImagePath;
|
||||
if (path != null && path.isNotEmpty && File(path).existsSync()) {
|
||||
await _viewBonusCard(path);
|
||||
return;
|
||||
}
|
||||
await _pickBonusCard(store);
|
||||
}
|
||||
|
||||
Future<void> _viewBonusCard(String path) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
child: InteractiveViewer(
|
||||
child: Image.file(File(path), fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickBonusCard(Supermarket store) async {
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: const Text('Take photo'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.camera),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: const Text('Pick from gallery'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.gallery),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (source == null || store.id == null) return;
|
||||
final file = await _picker.pickImage(source: source, imageQuality: 85);
|
||||
if (file == null) return;
|
||||
await widget.repository.setSupermarketBonusCard(store.id!, file.path);
|
||||
if (mounted) _load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Home'),
|
||||
actions: const [ProfileButton(), SettingsButton()],
|
||||
),
|
||||
body: _error != null && _overview == null
|
||||
? Center(
|
||||
child: Text(
|
||||
'Could not load an overview.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
)
|
||||
: _overview == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _HomeBody(
|
||||
overview: _overview!,
|
||||
stores: _stores,
|
||||
onOpenProducts: widget.onOpenProducts,
|
||||
onOpenLists: widget.onOpenLists,
|
||||
onOpenShopLog: widget.onOpenShopLog,
|
||||
onOpenAdd: widget.onOpenAdd,
|
||||
onOpenShop: _openShop,
|
||||
onOpenBonusCard: _openBonusCard,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeBody extends StatelessWidget {
|
||||
const _HomeBody({
|
||||
required this.overview,
|
||||
required this.stores,
|
||||
required this.onOpenProducts,
|
||||
required this.onOpenLists,
|
||||
required this.onOpenShopLog,
|
||||
required this.onOpenAdd,
|
||||
required this.onOpenShop,
|
||||
required this.onOpenBonusCard,
|
||||
});
|
||||
|
||||
final AppOverview overview;
|
||||
final List<Supermarket> stores;
|
||||
final VoidCallback onOpenProducts;
|
||||
final VoidCallback onOpenLists;
|
||||
final VoidCallback onOpenShopLog;
|
||||
final VoidCallback onOpenAdd;
|
||||
final ValueChanged<Shop> onOpenShop;
|
||||
final ValueChanged<Supermarket> onOpenBonusCard;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final settingsStores =
|
||||
SettingsScope.maybeOf(context)?.supermarkets ?? stores;
|
||||
final empty =
|
||||
overview.productCount == 0 &&
|
||||
overview.tripCount == 0 &&
|
||||
overview.listCount == 0;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
Text('Overview', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
if (empty)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Nothing logged yet.',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Scan a receipt or add a trip to see products, lists, and spending here.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: onOpenAdd,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add a trip'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
label: formatMonthYear(DateTime.now()),
|
||||
value: formatEuro(overview.monthTotal),
|
||||
detail: overview.monthTripCount == 0
|
||||
? 'No trips yet'
|
||||
: overview.monthTripCount == 1
|
||||
? '1 trip'
|
||||
: '${overview.monthTripCount} trips',
|
||||
onTap: onOpenShopLog,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
label: 'Products',
|
||||
value: '${overview.productCount}',
|
||||
detail: overview.favouriteCount == 0
|
||||
? 'No favourites'
|
||||
: overview.favouriteCount == 1
|
||||
? '1 favourite'
|
||||
: '${overview.favouriteCount} favourites',
|
||||
onTap: onOpenProducts,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_StatCard(
|
||||
icon: Icons.checklist_outlined,
|
||||
label: 'Shopping lists',
|
||||
value: '${overview.listCount}',
|
||||
detail: overview.listCount == 1 ? '1 list' : 'Open your lists',
|
||||
onTap: onOpenLists,
|
||||
),
|
||||
],
|
||||
if (stores.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text('Bonus cards', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 148,
|
||||
child: ListView.separated(
|
||||
key: ValueKey(
|
||||
stores.isEmpty ? 'bonus-empty' : 'bonus-${stores.first.name}',
|
||||
),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: stores.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final store = stores[index];
|
||||
return _BonusCardTile(
|
||||
store: store,
|
||||
stores: settingsStores,
|
||||
onTap: () => onOpenBonusCard(store),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
if (overview.recentShops.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text('Recent trips', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
for (final shop in overview.recentShops)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
title: shop.name == null || shop.name!.isEmpty
|
||||
? StoreTag(name: shop.supermarket, stores: settingsStores)
|
||||
: Text(shop.name!),
|
||||
subtitle: Text(
|
||||
[
|
||||
formatDate(shop.shoppedAt),
|
||||
formatEuro(shop.total),
|
||||
].join(' · '),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => onOpenShop(shop),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BonusCardTile extends StatelessWidget {
|
||||
const _BonusCardTile({
|
||||
required this.store,
|
||||
required this.stores,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final Supermarket store;
|
||||
final List<Supermarket> stores;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final path = store.bonusImagePath;
|
||||
final hasCard =
|
||||
path != null && path.isNotEmpty && File(path).existsSync();
|
||||
|
||||
return SizedBox(
|
||||
width: 220,
|
||||
child: Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: !hasCard
|
||||
? ColoredBox(
|
||||
color: store.color.withValues(alpha: 0.18),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.card_membership_outlined,
|
||||
color: store.color,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Add bonus card',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: Image.file(
|
||||
File(path),
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
StoreTag(name: store.name, stores: stores, compact: true),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
hasCard
|
||||
? Icons.zoom_in
|
||||
: Icons.camera_alt_outlined,
|
||||
size: 16,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatCard extends StatelessWidget {
|
||||
const _StatCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.detail,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final String detail;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: scheme.primary),
|
||||
const SizedBox(height: 12),
|
||||
Text(label, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 2),
|
||||
Text(detail, style: theme.textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+140
-29
@@ -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,10 +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;
|
||||
@@ -65,19 +70,34 @@ class _HomeShellState extends State<HomeShell> {
|
||||
});
|
||||
}
|
||||
|
||||
void _select(int index) {
|
||||
if (index == _index) return;
|
||||
setState(() => _index = index);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
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,
|
||||
),
|
||||
@@ -86,29 +106,120 @@ class _HomeShellState extends State<HomeShell> {
|
||||
repository: widget.repository,
|
||||
),
|
||||
},
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
onDestinationSelected: (index) => setState(() => _index = index),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.inventory_2_outlined),
|
||||
selectedIcon: Icon(Icons.inventory_2),
|
||||
label: 'Products',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.checklist_outlined),
|
||||
selectedIcon: Icon(Icons.checklist),
|
||||
label: 'Lists',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.add),
|
||||
selectedIcon: Icon(Icons.add_circle),
|
||||
label: 'Add',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.receipt_long_outlined),
|
||||
selectedIcon: Icon(Icons.receipt_long),
|
||||
label: 'Shop log',
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
||||
floatingActionButton: FloatingActionButton.small(
|
||||
onPressed: () => _select(_addIndex),
|
||||
tooltip: 'Add',
|
||||
elevation: _index == _addIndex ? 4 : 1,
|
||||
shape: const CircleBorder(),
|
||||
backgroundColor: scheme.primary,
|
||||
foregroundColor: scheme.onPrimary,
|
||||
child: const Icon(Icons.add, size: 24),
|
||||
),
|
||||
bottomNavigationBar: BottomAppBar(
|
||||
shape: const CircularNotchedRectangle(),
|
||||
notchMargin: 6,
|
||||
elevation: 2,
|
||||
height: 52,
|
||||
padding: EdgeInsets.zero,
|
||||
color: scheme.surfaceContainer,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _NavButton(
|
||||
icon: Icons.home_outlined,
|
||||
selectedIcon: Icons.home,
|
||||
label: 'Home',
|
||||
selected: _index == _homeIndex,
|
||||
onTap: () => _select(_homeIndex),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _NavButton(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
selectedIcon: Icons.inventory_2,
|
||||
label: 'Products',
|
||||
selected: _index == _productsIndex,
|
||||
onTap: () => _select(_productsIndex),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _fabGap),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _NavButton(
|
||||
icon: Icons.checklist_outlined,
|
||||
selectedIcon: Icons.checklist,
|
||||
label: 'Lists',
|
||||
selected: _index == _listsIndex,
|
||||
onTap: () => _select(_listsIndex),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _NavButton(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
selectedIcon: Icons.receipt_long,
|
||||
label: 'Shop log',
|
||||
selected: _index == _shopLogIndex,
|
||||
onTap: () => _select(_shopLogIndex),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavButton extends StatelessWidget {
|
||||
const _NavButton({
|
||||
required this.icon,
|
||||
required this.selectedIcon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final IconData selectedIcon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final color = selected ? scheme.primary : scheme.onSurfaceVariant;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(selected ? selectedIcon : icon, color: color, size: 20),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
height: 1.1,
|
||||
fontSize: 10,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -72,4 +72,4 @@ class Product {
|
||||
}
|
||||
}
|
||||
|
||||
enum ProductSort { name, preferred }
|
||||
enum ProductSort { name, preferred, favourite }
|
||||
|
||||
+30
-5
@@ -8,22 +8,47 @@ class Shop {
|
||||
required this.shoppedAt,
|
||||
required this.total,
|
||||
required this.itemCount,
|
||||
this.name,
|
||||
this.receiptImagePath,
|
||||
this.tags = const [],
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String supermarket;
|
||||
final String? name;
|
||||
final DateTime shoppedAt;
|
||||
final double total;
|
||||
final int itemCount;
|
||||
final String? receiptImagePath;
|
||||
final List<String> tags;
|
||||
|
||||
String get displayName {
|
||||
final tripName = name?.trim();
|
||||
if (tripName != null && tripName.isNotEmpty) return tripName;
|
||||
return supermarket;
|
||||
}
|
||||
|
||||
Shop copyWith({String? name, bool clearName = false, List<String>? tags}) {
|
||||
return Shop(
|
||||
id: id,
|
||||
supermarket: supermarket,
|
||||
shoppedAt: shoppedAt,
|
||||
total: total,
|
||||
itemCount: itemCount,
|
||||
receiptImagePath: receiptImagePath,
|
||||
name: clearName ? null : (name ?? this.name),
|
||||
tags: tags ?? this.tags,
|
||||
);
|
||||
}
|
||||
|
||||
factory Shop.fromMap(Map<String, Object?> map) {
|
||||
final rawName = (map['name'] as String?)?.trim();
|
||||
return Shop(
|
||||
id: map['id']! as int,
|
||||
supermarket: map['supermarket']! as String,
|
||||
name: (rawName == null || rawName.isEmpty) ? null : rawName,
|
||||
shoppedAt: DateTime.parse(map['shopped_at']! as String),
|
||||
total: (map['total']! as num).toDouble(),
|
||||
total: (map['total'] as num).toDouble(),
|
||||
itemCount: (map['item_count'] as num?)?.toInt() ?? 0,
|
||||
receiptImagePath: map['receipt_image_path'] as String?,
|
||||
);
|
||||
@@ -34,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,
|
||||
@@ -43,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;
|
||||
@@ -53,9 +78,9 @@ 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(),
|
||||
price: (map['price'] as num).toDouble(),
|
||||
quantity: map['quantity'] as int?,
|
||||
unitPrice: (map['unit_price'] as num?)?.toDouble(),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+140
-48
@@ -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,8 +11,10 @@ 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';
|
||||
|
||||
class ReviewScreen extends StatefulWidget {
|
||||
const ReviewScreen({
|
||||
@@ -42,6 +46,9 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
String? _supermarket;
|
||||
String? _customStore;
|
||||
bool _saving = false;
|
||||
final _tripName = TextEditingController();
|
||||
List<String> _tags = [];
|
||||
List<String> _tagSuggestions = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -57,6 +64,18 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
} else if (widget.detectedStore != null) {
|
||||
_supermarket = widget.detectedStore;
|
||||
}
|
||||
widget.repository
|
||||
.getUsedShopTags()
|
||||
.then((tags) {
|
||||
if (mounted) setState(() => _tagSuggestions = tags);
|
||||
})
|
||||
.catchError((_) {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tripName.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? get _resolvedSupermarket {
|
||||
@@ -96,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) {
|
||||
@@ -117,6 +154,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
shoppedAt: _shoppedAt,
|
||||
items: _items,
|
||||
receiptImagePath: widget.imagePath,
|
||||
name: _tripName.text,
|
||||
tags: _tags,
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
@@ -141,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(
|
||||
@@ -214,53 +257,107 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _items.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No line items yet.\nAdd products for this trip.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _tripName,
|
||||
enabled: !_saving,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Trip name',
|
||||
hintText: 'Optional, e.g. Weekly shop',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TagEditor(
|
||||
tags: _tags,
|
||||
suggestions: _tagSuggestions,
|
||||
enabled: !_saving,
|
||||
onChanged: (tags) => setState(() => _tags = tags),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_items.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No line items yet.\nAdd products for this trip.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
itemCount: _items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final item = _items[index];
|
||||
return Dismissible(
|
||||
key: ValueKey(item.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
sliver: SliverList.separated(
|
||||
itemCount: _items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final item = _items[index];
|
||||
return Dismissible(
|
||||
key: ValueKey(item.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.delete_outline,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.delete_outline,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
onDismissed: (_) {
|
||||
setState(() {
|
||||
_items.removeWhere((row) => row.id == item.id);
|
||||
});
|
||||
},
|
||||
child: ListTile(
|
||||
title: Text(item.name),
|
||||
subtitle: _itemSubtitle(item),
|
||||
trailing: DiscountedPrice(item: item),
|
||||
onTap: _saving ? null : () => _edit(existing: item),
|
||||
),
|
||||
),
|
||||
onDismissed: (_) {
|
||||
setState(() {
|
||||
_items.removeWhere((row) => row.id == item.id);
|
||||
});
|
||||
},
|
||||
child: ListTile(
|
||||
title: Text(item.name),
|
||||
subtitle: _itemSubtitle(item),
|
||||
trailing: Text(
|
||||
formatEuro(item.price),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
onTap: _saving ? null : () => _edit(existing: item),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (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'),
|
||||
@@ -302,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)}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
const int kBackupVersion = 2;
|
||||
const int kBackupVersion = 3;
|
||||
|
||||
const String kBackupFormat = 'groci-backup';
|
||||
const String kLegacyBackupFormat = 'receipity-backup';
|
||||
|
||||
class BackupDocument {
|
||||
const BackupDocument({
|
||||
@@ -17,6 +20,7 @@ class BackupDocument {
|
||||
required this.shoppingLists,
|
||||
required this.shoppingListItems,
|
||||
required this.images,
|
||||
this.shopTags = const [],
|
||||
});
|
||||
|
||||
final int version;
|
||||
@@ -26,12 +30,13 @@ class BackupDocument {
|
||||
final List<Map<String, Object?>> products;
|
||||
final List<Map<String, Object?>> shops;
|
||||
final List<Map<String, Object?>> shopItems;
|
||||
final List<Map<String, Object?>> shopTags;
|
||||
final List<Map<String, Object?>> shoppingLists;
|
||||
final List<Map<String, Object?>> shoppingListItems;
|
||||
final Map<String, String> images;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'format': 'receipity-backup',
|
||||
'format': kBackupFormat,
|
||||
'version': version,
|
||||
'exportedAt': exportedAt,
|
||||
'darkMode': darkMode,
|
||||
@@ -39,6 +44,7 @@ class BackupDocument {
|
||||
'products': products,
|
||||
'shops': shops,
|
||||
'shopItems': shopItems,
|
||||
'shopTags': shopTags,
|
||||
'shoppingLists': shoppingLists,
|
||||
'shoppingListItems': shoppingListItems,
|
||||
'images': images,
|
||||
@@ -51,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) {
|
||||
@@ -66,6 +73,7 @@ class BackupDocument {
|
||||
products: _maps(decoded['products']),
|
||||
shops: _maps(decoded['shops']),
|
||||
shopItems: _maps(decoded['shopItems']),
|
||||
shopTags: _maps(decoded['shopTags']),
|
||||
shoppingLists: _maps(decoded['shoppingLists']),
|
||||
shoppingListItems: _maps(decoded['shoppingListItems']),
|
||||
images: _strings(decoded['images']),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/// Build, share, and restore a Receipity JSON backup.
|
||||
/// Build, share, and restore a Groci JSON backup.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
@@ -57,6 +57,7 @@ class BackupService {
|
||||
products: rewrittenProducts,
|
||||
shops: rewrittenShops,
|
||||
shopItems: await repository.dumpTable('shop_items'),
|
||||
shopTags: await repository.dumpTable('shop_tags'),
|
||||
shoppingLists: await repository.dumpTable('shopping_lists'),
|
||||
shoppingListItems: await repository.dumpTable('shopping_list_items'),
|
||||
images: images,
|
||||
@@ -67,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',
|
||||
@@ -89,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;
|
||||
@@ -98,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'],
|
||||
);
|
||||
@@ -125,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);
|
||||
@@ -146,6 +148,7 @@ class BackupService {
|
||||
products: products,
|
||||
shops: shops,
|
||||
shopItems: document.shopItems,
|
||||
shopTags: document.shopTags,
|
||||
shoppingLists: document.shoppingLists,
|
||||
shoppingListItems: document.shoppingListItems,
|
||||
darkMode: document.darkMode,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+237
-29
@@ -13,6 +13,8 @@ import 'utils/dates.dart';
|
||||
import 'utils/money.dart';
|
||||
import 'widgets/confirm_dialog.dart';
|
||||
import 'widgets/item_edit_sheet.dart';
|
||||
import 'widgets/name_dialog.dart';
|
||||
import 'widgets/tag_editor.dart';
|
||||
|
||||
class ShopDetailScreen extends StatefulWidget {
|
||||
const ShopDetailScreen({
|
||||
@@ -29,26 +31,48 @@ 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) {
|
||||
if (mounted) setState(() => _tagSuggestions = tags);
|
||||
})
|
||||
.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');
|
||||
@override
|
||||
void dispose() {
|
||||
widget.repository.removeListener(_load);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final token = ++_loadToken;
|
||||
try {
|
||||
final shop = await widget.repository.getShop(widget.shopId);
|
||||
if (shop == null) throw StateError('Shop not found');
|
||||
final items = await widget.repository.getShopItems(widget.shopId);
|
||||
if (!mounted || token != _loadToken) return;
|
||||
setState(() {
|
||||
_shop = shop;
|
||||
_items = items;
|
||||
_error = null;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted || token != _loadToken) return;
|
||||
setState(() => _error = error);
|
||||
}
|
||||
final items = await widget.repository.getShopItems(widget.shopId);
|
||||
return (shop: shop, items: items);
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() => _future = _load());
|
||||
}
|
||||
|
||||
Future<void> _openReceipt(File file) async {
|
||||
@@ -76,6 +100,56 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addItem() async {
|
||||
final shop = _shop;
|
||||
if (shop == null) return;
|
||||
final result = await showItemEditSheet(context);
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
final pendingId = -DateTime.now().microsecondsSinceEpoch;
|
||||
final pending = ShopItem(
|
||||
id: pendingId,
|
||||
shopId: shop.id,
|
||||
name: result.name,
|
||||
price: result.price,
|
||||
quantity: result.quantity,
|
||||
unitPrice: result.unitPrice,
|
||||
);
|
||||
setState(() {
|
||||
_items = [...?_items, pending];
|
||||
_shop = Shop(
|
||||
id: shop.id,
|
||||
supermarket: shop.supermarket,
|
||||
shoppedAt: shop.shoppedAt,
|
||||
total: roundMoney(shop.total + result.price),
|
||||
itemCount: shop.itemCount + 1,
|
||||
name: shop.name,
|
||||
receiptImagePath: shop.receiptImagePath,
|
||||
tags: shop.tags,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
final saved = await widget.repository.addShopItem(
|
||||
shopId: shop.id,
|
||||
item: result,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = [
|
||||
for (final entry in _items ?? const <ShopItem>[])
|
||||
if (entry.id == pendingId) saved else entry,
|
||||
];
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not add this product.')),
|
||||
);
|
||||
_load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editItem(ShopItem item) async {
|
||||
final result = await showItemEditSheet(
|
||||
context,
|
||||
@@ -94,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 {
|
||||
@@ -107,8 +182,96 @@ 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 {
|
||||
final name = await showNameDialog(
|
||||
context,
|
||||
title: 'Trip name',
|
||||
label: 'Name',
|
||||
initial: shop.name,
|
||||
allowEmpty: true,
|
||||
);
|
||||
if (name == null || !mounted) return;
|
||||
await widget.repository.updateShop(
|
||||
id: shop.id,
|
||||
name: name,
|
||||
clearName: name.isEmpty,
|
||||
);
|
||||
if (mounted) _load();
|
||||
}
|
||||
|
||||
Future<void> _editDate(Shop shop) async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: shop.shoppedAt,
|
||||
firstDate: DateTime(2018),
|
||||
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
await widget.repository.updateShop(
|
||||
id: shop.id,
|
||||
shoppedAt: DateTime(picked.year, picked.month, picked.day),
|
||||
);
|
||||
if (mounted) _load();
|
||||
}
|
||||
|
||||
Future<void> _setTags(Shop shop, List<String> tags) async {
|
||||
await widget.repository.updateShop(id: shop.id, tags: tags);
|
||||
if (mounted) _load();
|
||||
}
|
||||
|
||||
Widget? _itemSubtitle(ShopItem item) {
|
||||
@@ -136,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.',
|
||||
@@ -147,25 +316,62 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!snapshot.hasData) {
|
||||
final shop = _shop;
|
||||
final items = _items;
|
||||
if (shop == null || items == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
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: [
|
||||
Text(shop.supermarket, style: theme.textTheme.headlineMedium),
|
||||
if (_editing)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
shop.name?.isNotEmpty == true
|
||||
? shop.name!
|
||||
: 'Add a trip name',
|
||||
),
|
||||
subtitle: const Text('Optional name for this shop log'),
|
||||
trailing: const Icon(Icons.edit_outlined),
|
||||
onTap: () => _editName(shop),
|
||||
)
|
||||
else
|
||||
Text(shop.displayName, style: theme.textTheme.headlineMedium),
|
||||
if (_editing || (shop.name != null && shop.name!.isNotEmpty)) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(shop.supermarket, style: theme.textTheme.titleMedium),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
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(
|
||||
tags: shop.tags,
|
||||
suggestions: _tagSuggestions,
|
||||
onChanged: (tags) => _setTags(shop, tags),
|
||||
),
|
||||
] else if (shop.tags.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
TagChips(tags: shop.tags),
|
||||
],
|
||||
if (hasReceipt) ...[
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
@@ -217,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(
|
||||
|
||||
+360
-100
@@ -1,19 +1,23 @@
|
||||
/// Log of saved shopping trips.
|
||||
/// Log of saved shopping trips, with a monthly heatmap and product search.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.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 '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';
|
||||
import 'widgets/tag_editor.dart';
|
||||
|
||||
class ShopLogScreen extends StatefulWidget {
|
||||
const ShopLogScreen({super.key, required this.repository});
|
||||
@@ -25,17 +29,56 @@ class ShopLogScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
late Future<List<Shop>> _future;
|
||||
List<Shop>? _shops;
|
||||
Map<int, List<String>> _productNames = const {};
|
||||
Object? _error;
|
||||
int _loadToken = 0;
|
||||
final _search = TextEditingController();
|
||||
bool _editing = false;
|
||||
bool _searching = false;
|
||||
DateTime _month = monthStart(DateTime.now());
|
||||
DateTime? _selectedDay;
|
||||
String? _tagFilter;
|
||||
|
||||
bool get _hasTrips => _shops != null && _shops!.isNotEmpty;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.repository.getShops();
|
||||
widget.repository.addListener(_load);
|
||||
_search.addListener(() => setState(() {}));
|
||||
_load();
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() => _future = widget.repository.getShops());
|
||||
@override
|
||||
void dispose() {
|
||||
widget.repository.removeListener(_load);
|
||||
_search.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final token = ++_loadToken;
|
||||
try {
|
||||
final shops = await widget.repository.getShops();
|
||||
final productNames = await widget.repository.getShopItemNamesByShop();
|
||||
if (!mounted || token != _loadToken) return;
|
||||
setState(() {
|
||||
_shops = shops;
|
||||
_productNames = productNames;
|
||||
_error = null;
|
||||
if (shops.isEmpty) {
|
||||
_editing = false;
|
||||
if (_searching) {
|
||||
_searching = false;
|
||||
_search.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted || token != _loadToken) return;
|
||||
setState(() => _error = error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openShop(Shop shop) async {
|
||||
@@ -45,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 {
|
||||
@@ -55,128 +98,345 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
message: 'This trip will be removed from the shop log. Products from this trip stay in your catalog.',
|
||||
);
|
||||
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) {
|
||||
setState(() => _selectedDay = day);
|
||||
}
|
||||
|
||||
void _goMonth(int delta) {
|
||||
final next = addMonths(_month, delta);
|
||||
final current = monthStart(DateTime.now());
|
||||
if (delta > 0 && !next.isBefore(current) && next != current) return;
|
||||
setState(() {
|
||||
_month = next.isAfter(current) ? current : next;
|
||||
_selectedDay = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
|
||||
final nowMonth = monthStart(DateTime.now());
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Shop log'),
|
||||
title: _searching
|
||||
? TextField(
|
||||
controller: _search,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search products in trips',
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
),
|
||||
)
|
||||
: const Text('Shop log'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: _editing ? 'Done' : 'Edit',
|
||||
onPressed: () => setState(() => _editing = !_editing),
|
||||
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
|
||||
tooltip: _searching ? 'Close search' : 'Search products',
|
||||
onPressed: _hasTrips
|
||||
? () {
|
||||
setState(() {
|
||||
_searching = !_searching;
|
||||
if (!_searching) _search.clear();
|
||||
});
|
||||
}
|
||||
: null,
|
||||
icon: Icon(_searching ? Icons.close : Icons.search),
|
||||
),
|
||||
if (_hasTrips)
|
||||
IconButton(
|
||||
tooltip: _editing ? 'Done' : 'Edit',
|
||||
onPressed: () => setState(() => _editing = !_editing),
|
||||
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
|
||||
),
|
||||
const ProfileButton(),
|
||||
const SettingsButton(),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<List<Shop>>(
|
||||
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!;
|
||||
if (shops.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Scan a receipt or enter a trip to log it.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
|
||||
itemCount: shops.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final shop = shops[index];
|
||||
final count = shop.itemCount;
|
||||
return ListTile(
|
||||
leading: _ReceiptThumb(path: shop.receiptImagePath),
|
||||
title: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: StoreTag(name: shop.supermarket, stores: stores),
|
||||
),
|
||||
subtitle: Text(
|
||||
[
|
||||
formatDate(shop.shoppedAt),
|
||||
count == 1 ? '1 product' : '$count products',
|
||||
].join(' · '),
|
||||
),
|
||||
trailing: _editing
|
||||
? IconButton(
|
||||
tooltip: 'Remove',
|
||||
onPressed: () => _removeShop(shop),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
)
|
||||
: Text(
|
||||
formatEuro(shop.total),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
onTap: _editing ? null : () => _openShop(shop),
|
||||
);
|
||||
},
|
||||
);
|
||||
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 _ReceiptThumb extends StatelessWidget {
|
||||
const _ReceiptThumb({this.path});
|
||||
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 String? path;
|
||||
final ThemeData theme;
|
||||
final List<Supermarket> stores;
|
||||
final DateTime nowMonth;
|
||||
final List<Shop>? shops;
|
||||
final Map<int, List<String>> productNames;
|
||||
final Object? error;
|
||||
final DateTime month;
|
||||
final DateTime? selectedDay;
|
||||
final String? tagFilter;
|
||||
final String query;
|
||||
final bool editing;
|
||||
final VoidCallback onPreviousMonth;
|
||||
final VoidCallback onNextMonth;
|
||||
final ValueChanged<DateTime?> onSelectDay;
|
||||
final VoidCallback onClearTag;
|
||||
final ValueChanged<String> onTagTap;
|
||||
final ValueChanged<Shop> onOpen;
|
||||
final ValueChanged<Shop> onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final file = path == null ? null : File(path!);
|
||||
final hasFile = file != null && file.existsSync();
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
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());
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: hasFile
|
||||
? Image.file(
|
||||
file,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 144,
|
||||
errorBuilder: (_, _, _) => ColoredBox(
|
||||
color: scheme.secondaryContainer,
|
||||
child: Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
color: scheme.onSecondaryContainer,
|
||||
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,
|
||||
required this.stores,
|
||||
required this.editing,
|
||||
required this.onOpen,
|
||||
required this.onRemove,
|
||||
required this.onTagTap,
|
||||
this.selectedTag,
|
||||
});
|
||||
|
||||
final Shop shop;
|
||||
final List<Supermarket> stores;
|
||||
final bool editing;
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onRemove;
|
||||
final ValueChanged<String> onTagTap;
|
||||
final String? selectedTag;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final count = shop.itemCount;
|
||||
final hasName = shop.name != null && shop.name!.isNotEmpty;
|
||||
final meta = [
|
||||
formatDate(shop.shoppedAt),
|
||||
count == 1 ? '1 product' : '$count products',
|
||||
].join(' · ');
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: editing ? null : onOpen,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (editing)
|
||||
IconButton(
|
||||
tooltip: 'Remove',
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
)
|
||||
: ColoredBox(
|
||||
color: scheme.secondaryContainer,
|
||||
child: Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
color: scheme.onSecondaryContainer,
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (hasName)
|
||||
Text(shop.name!, style: theme.textTheme.titleMedium)
|
||||
else
|
||||
StoreTag(name: shop.supermarket, stores: stores),
|
||||
const SizedBox(height: 4),
|
||||
if (hasName) ...[
|
||||
StoreTag(
|
||||
name: shop.supermarket,
|
||||
stores: stores,
|
||||
compact: true,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(meta, style: theme.textTheme.bodyMedium),
|
||||
if (shop.tags.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
TagChips(
|
||||
tags: shop.tags,
|
||||
compact: true,
|
||||
selected: selectedTag,
|
||||
onTap: onTagTap,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, right: 8),
|
||||
child: Text(
|
||||
formatEuro(shop.total),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/// Manage supermarket names, colors, and bonus card photos.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'models/supermarket.dart';
|
||||
import 'services/app_settings.dart';
|
||||
import 'services/product_repository.dart';
|
||||
import 'widgets/store_edit_dialog.dart';
|
||||
import 'widgets/store_tag.dart';
|
||||
|
||||
class SupermarketsScreen extends StatefulWidget {
|
||||
const SupermarketsScreen({super.key, required this.repository});
|
||||
|
||||
final ProductRepository repository;
|
||||
|
||||
@override
|
||||
State<SupermarketsScreen> createState() => _SupermarketsScreenState();
|
||||
}
|
||||
|
||||
class _SupermarketsScreenState extends State<SupermarketsScreen> {
|
||||
List<Supermarket>? _stores;
|
||||
bool _busy = false;
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.repository.addListener(_load);
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.repository.removeListener(_load);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final stores = await widget.repository.getSupermarkets();
|
||||
if (!mounted) return;
|
||||
setState(() => _stores = stores);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _stores ??= const []);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncSettings() async {
|
||||
await _settings?.reload();
|
||||
}
|
||||
|
||||
AppSettings? get _settings => SettingsScope.maybeOf(context);
|
||||
|
||||
Future<void> _editStore(Supermarket? existing) async {
|
||||
final result = await showStoreEditDialog(context, store: existing);
|
||||
if (result == null) return;
|
||||
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final settings = _settings;
|
||||
if (settings == null) return;
|
||||
if (existing == null) {
|
||||
await settings.addSupermarket(result.name, result.colorValue);
|
||||
} else {
|
||||
await settings.updateSupermarket(result);
|
||||
}
|
||||
await _load();
|
||||
await _syncSettings();
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('That supermarket already exists.')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteStore(Supermarket store) async {
|
||||
if (store.id == null) return;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await _settings?.deleteSupermarket(store.id!);
|
||||
await _load();
|
||||
await _syncSettings();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickBonusCard(Supermarket store) async {
|
||||
if (store.id == null) return;
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: const Text('Take photo'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.camera),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: const Text('Pick from gallery'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.gallery),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (source == null) return;
|
||||
final file = await _picker.pickImage(source: source, imageQuality: 85);
|
||||
if (file == null) return;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await widget.repository.setSupermarketBonusCard(store.id!, file.path);
|
||||
await _load();
|
||||
await _syncSettings();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _viewBonusCard(String path) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
child: InteractiveViewer(
|
||||
child: Image.file(File(path), fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final stores = _stores;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Supermarkets')),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _busy ? null : () => _editStore(null),
|
||||
tooltip: 'Add supermarket',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: stores == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
|
||||
itemCount: stores.length + 1,
|
||||
separatorBuilder: (_, index) =>
|
||||
index == 0 ? const SizedBox(height: 4) : const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Store names and colors appear on products. '
|
||||
'Add a bonus card photo when you have one.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
if (_busy) ...[
|
||||
const SizedBox(height: 8),
|
||||
const LinearProgressIndicator(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final store = stores[index - 1];
|
||||
return _SupermarketRow(
|
||||
store: store,
|
||||
stores: stores,
|
||||
busy: _busy,
|
||||
onEdit: () => _editStore(store),
|
||||
onDelete: store.id == null ? null : () => _deleteStore(store),
|
||||
onBonusCard: () => _pickBonusCard(store),
|
||||
onViewBonusCard: store.bonusImagePath == null
|
||||
? null
|
||||
: () => _viewBonusCard(store.bonusImagePath!),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SupermarketRow extends StatelessWidget {
|
||||
const _SupermarketRow({
|
||||
required this.store,
|
||||
required this.stores,
|
||||
required this.busy,
|
||||
required this.onEdit,
|
||||
required this.onBonusCard,
|
||||
this.onDelete,
|
||||
this.onViewBonusCard,
|
||||
});
|
||||
|
||||
final Supermarket store;
|
||||
final List<Supermarket> stores;
|
||||
final bool busy;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback? onDelete;
|
||||
final VoidCallback onBonusCard;
|
||||
final VoidCallback? onViewBonusCard;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final path = store.bonusImagePath;
|
||||
final hasCard = path != null && path.isNotEmpty && File(path).existsSync();
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
leading: hasCard
|
||||
? InkWell(
|
||||
onTap: busy ? null : onViewBonusCard,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
File(path),
|
||||
width: 44,
|
||||
height: 28,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
)
|
||||
: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: store.color,
|
||||
child: Text(
|
||||
store.name.isEmpty ? '?' : store.name.substring(0, 1),
|
||||
style: TextStyle(
|
||||
color: store.color.computeLuminance() > 0.55
|
||||
? Colors.black87
|
||||
: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: StoreTag(name: store.name, stores: stores, compact: true),
|
||||
subtitle: hasCard ? null : const Text('No bonus card'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: hasCard ? 'Replace bonus card' : 'Add bonus card',
|
||||
onPressed: busy ? null : onBonusCard,
|
||||
icon: Icon(hasCard ? Icons.photo_camera : Icons.card_membership_outlined),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
onPressed: busy ? null : onEdit,
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete',
|
||||
onPressed: busy ? null : onDelete,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,7 @@ abstract final class AppTheme {
|
||||
cardTheme: CardThemeData(
|
||||
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,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';
|
||||
|
||||
@@ -16,5 +16,23 @@ const _months = [
|
||||
'Dec',
|
||||
];
|
||||
|
||||
const _monthNames = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
String formatDate(DateTime date) =>
|
||||
'${date.day} ${_months[date.month - 1]} ${date.year}';
|
||||
|
||||
String formatMonthYear(DateTime date) =>
|
||||
'${_monthNames[date.month - 1]} ${date.year}';
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/// Case-insensitive product name matching and duplicate grouping.
|
||||
library;
|
||||
|
||||
import '../models/product.dart';
|
||||
|
||||
String normalizeProductName(String name) =>
|
||||
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
|
||||
|
||||
String normalizeSupermarket(String? supermarket) =>
|
||||
supermarket?.trim().toUpperCase() ?? '';
|
||||
|
||||
String productConflictKey({
|
||||
required String name,
|
||||
required String? supermarket,
|
||||
}) => '${normalizeSupermarket(supermarket)}|${normalizeProductName(name)}';
|
||||
|
||||
String mergeGroupKey(List<Product> products, String? supermarket) {
|
||||
final ids = products.map((product) => product.id).toList()..sort();
|
||||
return 'merge:${normalizeSupermarket(supermarket)}:'
|
||||
'${normalizeProductName(products.first.name)}:${ids.join(',')}';
|
||||
}
|
||||
|
||||
String spellingVariantKey(int productId, String? supermarket) =>
|
||||
'spelling:$productId:${normalizeSupermarket(supermarket)}';
|
||||
|
||||
List<List<Product>> nameConflictGroups(Iterable<Product> products) {
|
||||
final groups = <String, List<Product>>{};
|
||||
for (final product in products) {
|
||||
final key = productConflictKey(
|
||||
name: product.name,
|
||||
supermarket: product.supermarket,
|
||||
);
|
||||
if (normalizeProductName(product.name).isEmpty) continue;
|
||||
(groups[key] ??= []).add(product);
|
||||
}
|
||||
return [
|
||||
for (final group in groups.values)
|
||||
if (group.length > 1) group,
|
||||
];
|
||||
}
|
||||
|
||||
Set<int> nameConflictIds(Iterable<Product> products) {
|
||||
return {
|
||||
for (final group in nameConflictGroups(products))
|
||||
for (final product in group) product.id,
|
||||
};
|
||||
}
|
||||
|
||||
/// Separate catalog products with the same name at the same store.
|
||||
class ProductMergeGroup {
|
||||
const ProductMergeGroup({
|
||||
required this.products,
|
||||
required this.supermarket,
|
||||
required this.key,
|
||||
});
|
||||
|
||||
final List<Product> products;
|
||||
final String? supermarket;
|
||||
final String key;
|
||||
}
|
||||
|
||||
/// One catalog product bought under multiple spellings at one store.
|
||||
class ProductSpellingVariant {
|
||||
const ProductSpellingVariant({
|
||||
required this.product,
|
||||
required this.supermarket,
|
||||
required this.variants,
|
||||
required this.key,
|
||||
});
|
||||
|
||||
final Product product;
|
||||
final String? supermarket;
|
||||
final List<String> variants;
|
||||
final String key;
|
||||
}
|
||||
|
||||
/// Builds merge groups from catalog names and trip lines at the same store.
|
||||
List<ProductMergeGroup> buildNameConflictGroups({
|
||||
required Iterable<Product> products,
|
||||
required Iterable<({int productId, String name, String supermarket})>
|
||||
shopLines,
|
||||
}) {
|
||||
final productById = {for (final product in products) product.id: product};
|
||||
final idsByKey = <String, Set<int>>{};
|
||||
final supermarketByKey = <String, String>{};
|
||||
|
||||
void add(int productId, String name, String? supermarket) {
|
||||
final key = productConflictKey(name: name, supermarket: supermarket);
|
||||
if (normalizeProductName(name).isEmpty) return;
|
||||
final trimmed = supermarket?.trim();
|
||||
if (trimmed != null && trimmed.isNotEmpty) {
|
||||
supermarketByKey.putIfAbsent(key, () => trimmed);
|
||||
}
|
||||
(idsByKey[key] ??= {}).add(productId);
|
||||
}
|
||||
|
||||
for (final product in products) {
|
||||
add(product.id, product.name, product.supermarket);
|
||||
}
|
||||
for (final line in shopLines) {
|
||||
add(line.productId, line.name, line.supermarket);
|
||||
}
|
||||
|
||||
final groups = <ProductMergeGroup>[];
|
||||
for (final entry in idsByKey.entries) {
|
||||
if (entry.value.length < 2) continue;
|
||||
final productsInGroup = [
|
||||
for (final id in entry.value)
|
||||
if (productById.containsKey(id)) productById[id]!,
|
||||
];
|
||||
if (productsInGroup.length < 2) continue;
|
||||
|
||||
productsInGroup.sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||
);
|
||||
final supermarketLabel = supermarketByKey[entry.key];
|
||||
groups.add(
|
||||
ProductMergeGroup(
|
||||
products: productsInGroup,
|
||||
supermarket: supermarketLabel,
|
||||
key: mergeGroupKey(productsInGroup, supermarketLabel),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
groups.sort(
|
||||
(a, b) =>
|
||||
productConflictKey(
|
||||
name: a.products.first.name,
|
||||
supermarket: a.supermarket,
|
||||
).compareTo(
|
||||
productConflictKey(
|
||||
name: b.products.first.name,
|
||||
supermarket: b.supermarket,
|
||||
),
|
||||
),
|
||||
);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// Finds receipt spellings that differ only by capitals at one store.
|
||||
List<ProductSpellingVariant> findSpellingVariants({
|
||||
required Iterable<Product> products,
|
||||
required Iterable<({int productId, String name, String supermarket})>
|
||||
shopLines,
|
||||
Iterable<int> mergeProductIds = const [],
|
||||
Iterable<String> mergeGroupKeys = const [],
|
||||
}) {
|
||||
final productById = {for (final product in products) product.id: product};
|
||||
final excludedProducts = mergeProductIds.toSet();
|
||||
final excludedGroups = mergeGroupKeys.toSet();
|
||||
final namesByProductStore = <String, Set<String>>{};
|
||||
final supermarketByKey = <String, String>{};
|
||||
|
||||
void addName(int productId, String name, String supermarket) {
|
||||
final storeKey = normalizeSupermarket(supermarket);
|
||||
final bucketKey = '$productId|$storeKey';
|
||||
supermarketByKey[bucketKey] = supermarket;
|
||||
(namesByProductStore[bucketKey] ??= {}).add(name);
|
||||
}
|
||||
|
||||
for (final line in shopLines) {
|
||||
addName(line.productId, line.name, line.supermarket);
|
||||
}
|
||||
for (final product in products) {
|
||||
final supermarket = product.supermarket;
|
||||
if (supermarket == null || supermarket.trim().isEmpty) continue;
|
||||
addName(product.id, product.name, supermarket);
|
||||
}
|
||||
|
||||
final issues = <ProductSpellingVariant>[];
|
||||
for (final entry in namesByProductStore.entries) {
|
||||
final parts = entry.key.split('|');
|
||||
final productId = int.parse(parts.first);
|
||||
if (excludedProducts.contains(productId)) continue;
|
||||
|
||||
final product = productById[productId];
|
||||
if (product == null) continue;
|
||||
|
||||
final supermarket = supermarketByKey[entry.key];
|
||||
final byNormalized = <String, Set<String>>{};
|
||||
for (final name in entry.value) {
|
||||
final key = normalizeProductName(name);
|
||||
if (key.isEmpty) continue;
|
||||
(byNormalized[key] ??= {}).add(name);
|
||||
}
|
||||
|
||||
for (final names in byNormalized.values) {
|
||||
if (names.length < 2) continue;
|
||||
final variants = names.toList()
|
||||
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||||
final issue = ProductSpellingVariant(
|
||||
product: product,
|
||||
supermarket: supermarket,
|
||||
variants: variants,
|
||||
key: spellingVariantKey(productId, supermarket),
|
||||
);
|
||||
if (excludedGroups.contains(issue.key)) continue;
|
||||
issues.add(issue);
|
||||
}
|
||||
}
|
||||
|
||||
issues.sort(
|
||||
(a, b) =>
|
||||
productConflictKey(
|
||||
name: a.product.name,
|
||||
supermarket: a.supermarket,
|
||||
).compareTo(
|
||||
productConflictKey(name: b.product.name, supermarket: b.supermarket),
|
||||
),
|
||||
);
|
||||
return issues;
|
||||
}
|
||||
|
||||
/// Catalog merges and receipt spelling variants for the Products screen.
|
||||
class ProductNameIssues {
|
||||
const ProductNameIssues({
|
||||
required this.mergeGroups,
|
||||
required this.spellingVariants,
|
||||
});
|
||||
|
||||
final List<ProductMergeGroup> mergeGroups;
|
||||
final List<ProductSpellingVariant> spellingVariants;
|
||||
|
||||
bool get isEmpty => mergeGroups.isEmpty && spellingVariants.isEmpty;
|
||||
|
||||
bool get isNotEmpty => !isEmpty;
|
||||
|
||||
int get issueCount => mergeGroups.length + spellingVariants.length;
|
||||
|
||||
Set<int> get productIds => {
|
||||
for (final group in mergeGroups)
|
||||
for (final product in group.products) product.id,
|
||||
for (final variant in spellingVariants) variant.product.id,
|
||||
};
|
||||
}
|
||||
|
||||
ProductNameIssues buildProductNameIssues({
|
||||
required Iterable<Product> products,
|
||||
required Iterable<({int productId, String name, String supermarket})>
|
||||
shopLines,
|
||||
Set<String> dismissedKeys = const {},
|
||||
}) {
|
||||
final mergeGroups = [
|
||||
for (final group in buildNameConflictGroups(
|
||||
products: products,
|
||||
shopLines: shopLines,
|
||||
))
|
||||
if (!dismissedKeys.contains(group.key)) group,
|
||||
];
|
||||
final mergeIds = {
|
||||
for (final group in mergeGroups)
|
||||
for (final product in group.products) product.id,
|
||||
};
|
||||
final mergeKeys = {for (final group in mergeGroups) group.key};
|
||||
final spellingVariants = [
|
||||
for (final variant in findSpellingVariants(
|
||||
products: products,
|
||||
shopLines: shopLines,
|
||||
mergeProductIds: mergeIds,
|
||||
mergeGroupKeys: mergeKeys,
|
||||
))
|
||||
if (!dismissedKeys.contains(variant.key)) variant,
|
||||
];
|
||||
return ProductNameIssues(
|
||||
mergeGroups: mergeGroups,
|
||||
spellingVariants: spellingVariants,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/// Month-grid helpers for the shop-log heatmap.
|
||||
library;
|
||||
|
||||
DateTime dateOnly(DateTime date) => DateTime(date.year, date.month, date.day);
|
||||
|
||||
DateTime monthStart(DateTime date) => DateTime(date.year, date.month);
|
||||
|
||||
bool isSameDay(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
|
||||
bool isSameMonth(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month;
|
||||
|
||||
DateTime addMonths(DateTime month, int delta) =>
|
||||
DateTime(month.year, month.month + delta);
|
||||
|
||||
/// Monday-first calendar cells for [month], padded with nulls to full weeks.
|
||||
List<DateTime?> monthGrid(DateTime month) {
|
||||
final start = DateTime(month.year, month.month, 1);
|
||||
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
|
||||
final leading = start.weekday - DateTime.monday;
|
||||
final cells = <DateTime?>[
|
||||
...List<DateTime?>.filled(leading, null),
|
||||
for (var day = 1; day <= daysInMonth; day++)
|
||||
DateTime(month.year, month.month, day),
|
||||
];
|
||||
while (cells.length % 7 != 0) {
|
||||
cells.add(null);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
/// How many trips landed on each calendar day.
|
||||
Map<DateTime, int> tripCountsByDay(Iterable<DateTime> shoppedAt) {
|
||||
final counts = <DateTime, int>{};
|
||||
for (final date in shoppedAt) {
|
||||
final key = dateOnly(date);
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// GitHub-style 0–4 intensity from a trip count.
|
||||
int heatmapLevel(int count) {
|
||||
if (count <= 0) return 0;
|
||||
if (count >= 4) return 4;
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/// Filters shop-log rows by day, tag, and product search.
|
||||
library;
|
||||
|
||||
import '../models/shop.dart';
|
||||
import 'fuzzy.dart';
|
||||
import 'shop_calendar.dart';
|
||||
|
||||
List<Shop> filterShops({
|
||||
required List<Shop> shops,
|
||||
required Map<int, List<String>> productNames,
|
||||
DateTime? selectedDay,
|
||||
String? tag,
|
||||
String query = '',
|
||||
}) {
|
||||
final needle = query.trim();
|
||||
final tagFilter = tag?.trim();
|
||||
return [
|
||||
for (final shop in shops)
|
||||
if (_matches(
|
||||
shop: shop,
|
||||
names: productNames[shop.id] ?? const [],
|
||||
selectedDay: selectedDay,
|
||||
tag: tagFilter,
|
||||
query: needle,
|
||||
))
|
||||
shop,
|
||||
];
|
||||
}
|
||||
|
||||
bool _matches({
|
||||
required Shop shop,
|
||||
required List<String> names,
|
||||
required DateTime? selectedDay,
|
||||
required String? tag,
|
||||
required String query,
|
||||
}) {
|
||||
if (selectedDay != null && !isSameDay(shop.shoppedAt, selectedDay)) {
|
||||
return false;
|
||||
}
|
||||
if (tag != null && tag.isNotEmpty) {
|
||||
final wanted = tag.toLowerCase();
|
||||
if (!shop.tags.any((item) => item.toLowerCase() == wanted)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (query.isEmpty) return true;
|
||||
return names.any((name) => fuzzyScore(query, name) != null);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/// Multi-select sheet for adding catalog products to a shopping list.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/product.dart';
|
||||
import 'product_image.dart';
|
||||
|
||||
class AddListItemSelection {
|
||||
const AddListItemSelection({required this.name, this.productId});
|
||||
|
||||
final String name;
|
||||
final int? productId;
|
||||
}
|
||||
|
||||
Future<List<AddListItemSelection>?> showAddListItemsSheet(
|
||||
BuildContext context, {
|
||||
required List<Product> products,
|
||||
}) {
|
||||
return showModalBottomSheet<List<AddListItemSelection>>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => _AddListItemsSheet(products: products),
|
||||
);
|
||||
}
|
||||
|
||||
class _AddListItemsSheet extends StatefulWidget {
|
||||
const _AddListItemsSheet({required this.products});
|
||||
|
||||
final List<Product> products;
|
||||
|
||||
@override
|
||||
State<_AddListItemsSheet> createState() => _AddListItemsSheetState();
|
||||
}
|
||||
|
||||
class _AddListItemsSheetState extends State<_AddListItemsSheet> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final Set<int> _selectedProductIds = {};
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Product> get _matches {
|
||||
final query = _controller.text.trim().toLowerCase();
|
||||
if (query.isEmpty) return widget.products;
|
||||
return widget.products
|
||||
.where((product) => product.name.toLowerCase().contains(query))
|
||||
.toList();
|
||||
}
|
||||
|
||||
int get _addCount {
|
||||
var count = _selectedProductIds.length;
|
||||
final typed = _controller.text.trim();
|
||||
if (typed.isEmpty) return count;
|
||||
final selectedNames = {
|
||||
for (final product in widget.products)
|
||||
if (_selectedProductIds.contains(product.id))
|
||||
product.name.toLowerCase(),
|
||||
};
|
||||
if (!selectedNames.contains(typed.toLowerCase())) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
void _toggleProduct(Product product) {
|
||||
setState(() {
|
||||
if (_selectedProductIds.contains(product.id)) {
|
||||
_selectedProductIds.remove(product.id);
|
||||
} else {
|
||||
_selectedProductIds.add(product.id);
|
||||
}
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _add() {
|
||||
final selections = <AddListItemSelection>[
|
||||
for (final product in widget.products)
|
||||
if (_selectedProductIds.contains(product.id))
|
||||
AddListItemSelection(name: product.name, productId: product.id),
|
||||
];
|
||||
final typed = _controller.text.trim();
|
||||
if (typed.isNotEmpty &&
|
||||
!selections.any(
|
||||
(item) => item.name.toLowerCase() == typed.toLowerCase(),
|
||||
)) {
|
||||
selections.add(AddListItemSelection(name: typed));
|
||||
}
|
||||
if (selections.isEmpty) {
|
||||
setState(() => _error = 'Select products or enter an item name.');
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(selections);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
||||
final matches = _matches;
|
||||
final addCount = _addCount;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
|
||||
child: SizedBox(
|
||||
height: MediaQuery.sizeOf(context).height * 0.72,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Add items', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Search or type a new item',
|
||||
),
|
||||
onChanged: (_) => setState(() => _error = null),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text('From products', style: theme.textTheme.labelMedium),
|
||||
const SizedBox(height: 4),
|
||||
Expanded(
|
||||
child: matches.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
widget.products.isEmpty
|
||||
? 'No products in your catalog yet.'
|
||||
: 'No products match this search.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: matches.length,
|
||||
itemBuilder: (context, index) {
|
||||
final product = matches[index];
|
||||
final selected = _selectedProductIds.contains(
|
||||
product.id,
|
||||
);
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Checkbox(
|
||||
value: selected,
|
||||
onChanged: (_) => _toggleProduct(product),
|
||||
),
|
||||
title: Text(product.name),
|
||||
trailing: ProductImage(
|
||||
name: product.name,
|
||||
path: product.imagePath,
|
||||
size: 32,
|
||||
),
|
||||
onTap: () => _toggleProduct(product),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: addCount == 0 ? null : _add,
|
||||
child: Text(addCount == 1 ? 'Add 1 item' : 'Add $addCount items'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// Paid amount, with the pre-discount price struck through when there is one.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/line_item.dart';
|
||||
import '../utils/money.dart';
|
||||
|
||||
class DiscountedPrice extends StatelessWidget {
|
||||
const DiscountedPrice({super.key, required this.item});
|
||||
|
||||
final LineItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final paid = Text(
|
||||
formatEuro(item.price),
|
||||
style: theme.textTheme.titleMedium,
|
||||
);
|
||||
if (!item.hasDiscount) return paid;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
formatEuro(item.originalPrice),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
paid,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -9,6 +9,7 @@ Future<String?> showNameDialog(
|
||||
String? initial,
|
||||
String label = 'Name',
|
||||
String confirmLabel = 'Save',
|
||||
bool allowEmpty = false,
|
||||
}) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
@@ -17,6 +18,7 @@ Future<String?> showNameDialog(
|
||||
initial: initial ?? '',
|
||||
label: label,
|
||||
confirmLabel: confirmLabel,
|
||||
allowEmpty: allowEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -27,12 +29,14 @@ class _NameDialog extends StatefulWidget {
|
||||
required this.initial,
|
||||
required this.label,
|
||||
required this.confirmLabel,
|
||||
required this.allowEmpty,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String initial;
|
||||
final String label;
|
||||
final String confirmLabel;
|
||||
final bool allowEmpty;
|
||||
|
||||
@override
|
||||
State<_NameDialog> createState() => _NameDialogState();
|
||||
@@ -56,7 +60,7 @@ class _NameDialogState extends State<_NameDialog> {
|
||||
|
||||
void _save() {
|
||||
final name = _controller.text.trim();
|
||||
if (name.isEmpty) {
|
||||
if (name.isEmpty && !widget.allowEmpty) {
|
||||
setState(() => _error = 'Enter a name.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/// Bottom sheet for picking a shopping list to add a product to.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/shopping_list.dart';
|
||||
import '../services/product_repository.dart';
|
||||
import 'name_dialog.dart';
|
||||
|
||||
Future<int?> showPickShoppingListSheet(
|
||||
BuildContext context, {
|
||||
required ProductRepository repository,
|
||||
}) async {
|
||||
final lists = await repository.getShoppingLists();
|
||||
if (!context.mounted) return null;
|
||||
|
||||
if (lists.isEmpty) {
|
||||
final name = await showNameDialog(
|
||||
context,
|
||||
title: 'New shopping list',
|
||||
label: 'List name',
|
||||
confirmLabel: 'Create',
|
||||
);
|
||||
if (name == null || !context.mounted) return null;
|
||||
return repository.createShoppingList(name);
|
||||
}
|
||||
|
||||
return showModalBottomSheet<int>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) {
|
||||
return _PickShoppingListSheet(repository: repository, lists: lists);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _PickShoppingListSheet extends StatelessWidget {
|
||||
const _PickShoppingListSheet({required this.repository, required this.lists});
|
||||
|
||||
final ProductRepository repository;
|
||||
final List<ShoppingList> lists;
|
||||
|
||||
Future<void> _create(BuildContext context) async {
|
||||
final name = await showNameDialog(
|
||||
context,
|
||||
title: 'New shopping list',
|
||||
label: 'List name',
|
||||
confirmLabel: 'Create',
|
||||
);
|
||||
if (name == null || !context.mounted) return;
|
||||
final id = await repository.createShoppingList(name);
|
||||
if (context.mounted) Navigator.of(context).pop(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Text('Add to list', style: theme.textTheme.titleLarge),
|
||||
),
|
||||
for (final list in lists)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.checklist_outlined),
|
||||
title: Text(list.name),
|
||||
subtitle: Text(
|
||||
list.itemCount == 0
|
||||
? 'Empty'
|
||||
: list.itemCount == 1
|
||||
? '1 item'
|
||||
: '${list.itemCount} items',
|
||||
),
|
||||
onTap: () => Navigator.of(context).pop(list.id),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add),
|
||||
title: const Text('Create new list'),
|
||||
onTap: () => _create(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,7 @@ class PriceChart extends StatelessWidget {
|
||||
height: 160,
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/// GitHub-style contribution heatmap for one calendar month of shopping trips.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../utils/dates.dart';
|
||||
import '../utils/money.dart';
|
||||
import '../utils/shop_calendar.dart';
|
||||
|
||||
class ShopHeatmap extends StatelessWidget {
|
||||
const ShopHeatmap({
|
||||
super.key,
|
||||
required this.month,
|
||||
required this.counts,
|
||||
required this.monthTotal,
|
||||
required this.monthTripCount,
|
||||
required this.canGoForward,
|
||||
this.selectedDay,
|
||||
required this.onPreviousMonth,
|
||||
required this.onNextMonth,
|
||||
required this.onSelectDay,
|
||||
});
|
||||
|
||||
final DateTime month;
|
||||
final Map<DateTime, int> counts;
|
||||
final double monthTotal;
|
||||
final int monthTripCount;
|
||||
final bool canGoForward;
|
||||
final DateTime? selectedDay;
|
||||
final VoidCallback onPreviousMonth;
|
||||
final VoidCallback onNextMonth;
|
||||
final ValueChanged<DateTime?> onSelectDay;
|
||||
|
||||
static const _weekdays = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final cells = monthGrid(month);
|
||||
final today = dateOnly(DateTime.now());
|
||||
final weeks = cells.length ~/ 7;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Previous month',
|
||||
onPressed: onPreviousMonth,
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
formatMonthYear(month),
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Next month',
|
||||
onPressed: canGoForward ? onNextMonth : null,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
monthTripCount == 0
|
||||
? 'No trips this month'
|
||||
: '${monthTripCount == 1 ? '1 trip' : '$monthTripCount trips'} · ${formatEuro(monthTotal)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
for (final label in _weekdays)
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
for (var week = 0; week < weeks; week++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var weekday = 0; weekday < 7; weekday++)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: _HeatCell(
|
||||
date: cells[week * 7 + weekday],
|
||||
count: cells[week * 7 + weekday] == null
|
||||
? 0
|
||||
: counts[cells[week * 7 + weekday]] ?? 0,
|
||||
today: today,
|
||||
selectedDay: selectedDay,
|
||||
onSelect: onSelectDay,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text('Less', style: theme.textTheme.labelMedium),
|
||||
const SizedBox(width: 6),
|
||||
for (var level = 0; level <= 4; level++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 3),
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: _fillForLevel(scheme, level),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text('More', style: theme.textTheme.labelMedium),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeatCell extends StatelessWidget {
|
||||
const _HeatCell({
|
||||
required this.date,
|
||||
required this.count,
|
||||
required this.today,
|
||||
required this.selectedDay,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final DateTime? date;
|
||||
final int count;
|
||||
final DateTime today;
|
||||
final DateTime? selectedDay;
|
||||
final ValueChanged<DateTime?> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (date == null) {
|
||||
return const SizedBox(height: 36);
|
||||
}
|
||||
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final level = heatmapLevel(count);
|
||||
final selected = selectedDay != null && isSameDay(date!, selectedDay!);
|
||||
final isToday = isSameDay(date!, today);
|
||||
final fill = _fillForLevel(scheme, level);
|
||||
final textColor = level >= 3
|
||||
? scheme.onPrimary
|
||||
: scheme.onSurface.withValues(alpha: count == 0 ? 0.45 : 1);
|
||||
|
||||
return Tooltip(
|
||||
message: count == 0
|
||||
? formatDate(date!)
|
||||
: '${formatDate(date!)} · ${count == 1 ? '1 trip' : '$count trips'}',
|
||||
child: InkWell(
|
||||
onTap: () => onSelect(selected ? null : date),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: fill,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? scheme.primary
|
||||
: isToday
|
||||
? scheme.outline
|
||||
: Colors.transparent,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${date!.day}',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: textColor,
|
||||
fontWeight: isToday || selected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Color _fillForLevel(ColorScheme scheme, int level) {
|
||||
if (level <= 0) return scheme.surfaceContainerHighest;
|
||||
final t = (level / 4).clamp(0.28, 1.0);
|
||||
return Color.lerp(scheme.primary.withValues(alpha: 0.22), scheme.primary, t)!;
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/// Chip row and optional editor for shopping-trip tags.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TagChips extends StatelessWidget {
|
||||
const TagChips({
|
||||
super.key,
|
||||
required this.tags,
|
||||
this.onTap,
|
||||
this.selected,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final List<String> tags;
|
||||
final ValueChanged<String>? onTap;
|
||||
final String? selected;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (tags.isEmpty) return const SizedBox.shrink();
|
||||
return Wrap(
|
||||
spacing: compact ? 4 : 6,
|
||||
runSpacing: compact ? 4 : 6,
|
||||
children: [
|
||||
for (final tag in tags)
|
||||
_TagChip(
|
||||
label: tag,
|
||||
selected:
|
||||
selected != null &&
|
||||
selected!.toLowerCase() == tag.toLowerCase(),
|
||||
compact: compact,
|
||||
onPressed: onTap == null ? null : () => onTap!(tag),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TagEditor extends StatefulWidget {
|
||||
const TagEditor({
|
||||
super.key,
|
||||
required this.tags,
|
||||
required this.onChanged,
|
||||
this.suggestions = const [],
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final List<String> tags;
|
||||
final List<String> suggestions;
|
||||
final ValueChanged<List<String>> onChanged;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
State<TagEditor> createState() => _TagEditorState();
|
||||
}
|
||||
|
||||
class _TagEditorState extends State<TagEditor> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _add(String raw) {
|
||||
final label = raw.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
if (label.isEmpty) return;
|
||||
final exists = widget.tags.any(
|
||||
(tag) => tag.toLowerCase() == label.toLowerCase(),
|
||||
);
|
||||
if (exists) {
|
||||
_controller.clear();
|
||||
return;
|
||||
}
|
||||
widget.onChanged([...widget.tags, label]);
|
||||
_controller.clear();
|
||||
}
|
||||
|
||||
void _remove(String tag) {
|
||||
widget.onChanged([
|
||||
for (final item in widget.tags)
|
||||
if (item.toLowerCase() != tag.toLowerCase()) item,
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final unused = [
|
||||
for (final tag in widget.suggestions)
|
||||
if (!widget.tags.any((item) => item.toLowerCase() == tag.toLowerCase()))
|
||||
tag,
|
||||
];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.tags.isNotEmpty) ...[
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final tag in widget.tags)
|
||||
InputChip(
|
||||
label: Text(tag),
|
||||
onDeleted: widget.enabled ? () => _remove(tag) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
TextField(
|
||||
controller: _controller,
|
||||
enabled: widget.enabled,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Add tag',
|
||||
hintText: 'weekly, bulk, work…',
|
||||
),
|
||||
onSubmitted: widget.enabled ? _add : null,
|
||||
),
|
||||
if (widget.enabled && unused.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final tag in unused.take(12))
|
||||
ActionChip(label: Text(tag), onPressed: () => _add(tag)),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TagChip extends StatelessWidget {
|
||||
const _TagChip({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.compact,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final bool compact;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final bg = selected ? scheme.primary : scheme.secondaryContainer;
|
||||
final fg = selected ? scheme.onPrimary : scheme.onSecondaryContainer;
|
||||
return Material(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 6 : 8,
|
||||
vertical: compact ? 2 : 4,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: fg,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: compact ? 11 : 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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', () {
|
||||
const original = BackupDocument(
|
||||
version: 2,
|
||||
version: 3,
|
||||
exportedAt: '2026-08-29T12:00:00.000',
|
||||
darkMode: true,
|
||||
supermarkets: [
|
||||
@@ -29,6 +29,7 @@ void main() {
|
||||
{
|
||||
'id': 8,
|
||||
'supermarket': 'Jumbo',
|
||||
'name': 'Weekly shop',
|
||||
'shopped_at': '2026-08-29T12:00:00.000',
|
||||
'receipt_image_path': 'receipts/shop_8.jpg',
|
||||
'total': 1.5,
|
||||
@@ -46,6 +47,9 @@ void main() {
|
||||
'unit_price': 1.5,
|
||||
},
|
||||
],
|
||||
shopTags: [
|
||||
{'id': 1, 'shop_id': 8, 'tag': 'weekly', 'normalized_tag': 'weekly'},
|
||||
],
|
||||
shoppingLists: [
|
||||
{
|
||||
'id': 1,
|
||||
@@ -71,13 +75,16 @@ void main() {
|
||||
expect(parsed.darkMode, isTrue);
|
||||
expect(parsed.products.single['name'], 'Milk');
|
||||
expect(parsed.shops.single['id'], 8);
|
||||
expect(parsed.shops.single['name'], 'Weekly shop');
|
||||
expect(parsed.shopItems.single['product_id'], 1);
|
||||
expect(parsed.shopTags.single['tag'], 'weekly');
|
||||
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",
|
||||
@@ -95,9 +102,10 @@ void main() {
|
||||
expect(parsed.version, 1);
|
||||
expect(parsed.shoppingLists, isEmpty);
|
||||
expect(parsed.shoppingListItems, isEmpty);
|
||||
expect(parsed.shopTags, isEmpty);
|
||||
});
|
||||
|
||||
test('rejects a file that is not a Receipity backup', () {
|
||||
test('rejects a file that is not a Groci backup', () {
|
||||
expect(
|
||||
() => BackupDocument.parse('{"hello":"world"}'),
|
||||
throwsFormatException,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:groci/models/shop.dart';
|
||||
import 'package:groci/utils/shop_calendar.dart';
|
||||
import 'package:groci/utils/shop_log_filter.dart';
|
||||
|
||||
Shop _shop({
|
||||
required int id,
|
||||
required DateTime at,
|
||||
String supermarket = 'Jumbo',
|
||||
String? name,
|
||||
List<String> tags = const [],
|
||||
}) {
|
||||
return Shop(
|
||||
id: id,
|
||||
supermarket: supermarket,
|
||||
name: name,
|
||||
shoppedAt: at,
|
||||
total: 10,
|
||||
itemCount: 1,
|
||||
tags: tags,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('monthGrid', () {
|
||||
test('pads August 2026 from Saturday with Monday-first weeks', () {
|
||||
final cells = monthGrid(DateTime(2026, 8));
|
||||
expect(cells.length % 7, 0);
|
||||
expect(cells[0], isNull);
|
||||
expect(cells[1], isNull);
|
||||
expect(cells[2], isNull);
|
||||
expect(cells[3], isNull);
|
||||
expect(cells[4], isNull);
|
||||
expect(cells[5], DateTime(2026, 8, 1));
|
||||
expect(cells[35], DateTime(2026, 8, 31));
|
||||
expect(cells.last, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('tripCountsByDay groups multiple trips on the same day', () {
|
||||
final counts = tripCountsByDay([
|
||||
DateTime(2026, 8, 12, 9),
|
||||
DateTime(2026, 8, 12, 18),
|
||||
DateTime(2026, 8, 13),
|
||||
]);
|
||||
expect(counts[DateTime(2026, 8, 12)], 2);
|
||||
expect(counts[DateTime(2026, 8, 13)], 1);
|
||||
expect(heatmapLevel(0), 0);
|
||||
expect(heatmapLevel(1), 1);
|
||||
expect(heatmapLevel(4), 4);
|
||||
expect(heatmapLevel(9), 4);
|
||||
});
|
||||
|
||||
group('filterShops', () {
|
||||
final shops = [
|
||||
_shop(id: 1, at: DateTime(2026, 8, 12), tags: const ['weekly']),
|
||||
_shop(
|
||||
id: 2,
|
||||
at: DateTime(2026, 8, 13),
|
||||
name: 'Work lunch',
|
||||
tags: const ['work'],
|
||||
),
|
||||
];
|
||||
const names = {
|
||||
1: ['Milk', 'Bread'],
|
||||
2: ['Coffee'],
|
||||
};
|
||||
|
||||
test('filters by product name', () {
|
||||
final found = filterShops(
|
||||
shops: shops,
|
||||
productNames: names,
|
||||
query: 'milk',
|
||||
);
|
||||
expect(found.map((shop) => shop.id), [1]);
|
||||
});
|
||||
|
||||
test('filters by selected day', () {
|
||||
final found = filterShops(
|
||||
shops: shops,
|
||||
productNames: names,
|
||||
selectedDay: DateTime(2026, 8, 13),
|
||||
);
|
||||
expect(found.single.id, 2);
|
||||
});
|
||||
|
||||
test('filters by tag', () {
|
||||
final found = filterShops(
|
||||
shops: shops,
|
||||
productNames: names,
|
||||
tag: 'weekly',
|
||||
);
|
||||
expect(found.single.id, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:groci/services/product_repository.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
var _dbSeq = 0;
|
||||
var _sqliteReady = false;
|
||||
|
||||
void ensureTestSqlite() {
|
||||
if (_sqliteReady) return;
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
_sqliteReady = true;
|
||||
}
|
||||
|
||||
ProductRepository memoryRepository() {
|
||||
ensureTestSqlite();
|
||||
final dir = Directory(p.join('.dart_tool', 'groci_test_db'))
|
||||
..createSync(recursive: true);
|
||||
final name =
|
||||
'db_${DateTime.now().microsecondsSinceEpoch}_${_dbSeq++}_${Random().nextInt(1 << 32)}.sqlite';
|
||||
return ProductRepository(databasePath: p.join(dir.path, name));
|
||||
}
|
||||
|
||||
Future<void> pumpUntilFound(
|
||||
WidgetTester tester,
|
||||
Finder finder, {
|
||||
int attempts = 40,
|
||||
}) async {
|
||||
for (var i = 0; i < attempts; i++) {
|
||||
await tester.pump();
|
||||
if (finder.evaluate().isNotEmpty) return;
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 20)),
|
||||
);
|
||||
}
|
||||
throw TestFailure('Timed out waiting for $finder');
|
||||
}
|
||||
|
||||
const _seedTs = '2026-08-01T00:00:00.000';
|
||||
|
||||
Future<void> seedCaseConflictCatalog(ProductRepository repository) async {
|
||||
await repository.replaceAllData(
|
||||
darkMode: false,
|
||||
supermarkets: [
|
||||
{'id': 1, 'name': 'Jumbo', 'color': 0xFFEEC21B, 'sort_order': 0},
|
||||
],
|
||||
products: [
|
||||
{
|
||||
'id': 1,
|
||||
'name': 'Broccoli',
|
||||
'normalized_name': 'BROCCOLI_A',
|
||||
'last_price': 1.5,
|
||||
'times_seen': 1,
|
||||
'favourite': 0,
|
||||
'supermarket': 'Jumbo',
|
||||
'created_at': _seedTs,
|
||||
'updated_at': _seedTs,
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'name': 'BROCCOLI',
|
||||
'normalized_name': 'BROCCOLI_B',
|
||||
'last_price': 1.8,
|
||||
'times_seen': 1,
|
||||
'favourite': 0,
|
||||
'supermarket': 'Jumbo',
|
||||
'created_at': _seedTs,
|
||||
'updated_at': _seedTs,
|
||||
},
|
||||
],
|
||||
shops: [
|
||||
{
|
||||
'id': 1,
|
||||
'supermarket': 'Jumbo',
|
||||
'shopped_at': '2026-08-01T00:00:00.000',
|
||||
'total': 1.5,
|
||||
'created_at': _seedTs,
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'supermarket': 'Jumbo',
|
||||
'shopped_at': '2026-08-02T00:00:00.000',
|
||||
'total': 1.8,
|
||||
'created_at': _seedTs,
|
||||
},
|
||||
],
|
||||
shopItems: [
|
||||
{
|
||||
'id': 1,
|
||||
'shop_id': 1,
|
||||
'product_id': 1,
|
||||
'name': 'Broccoli',
|
||||
'price': 1.5,
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'shop_id': 2,
|
||||
'product_id': 2,
|
||||
'name': 'BROCCOLI',
|
||||
'price': 1.8,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
+278
-17
@@ -1,17 +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: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);
|
||||
@@ -20,22 +41,149 @@ 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.text('Add'), findsOneWidget);
|
||||
expect(find.text('Shop log'), findsOneWidget);
|
||||
});
|
||||
|
||||
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 pumpUntilFound(
|
||||
tester,
|
||||
find.text('Scan a receipt or enter a trip to log it.'),
|
||||
);
|
||||
|
||||
expect(find.byTooltip('Edit'), findsNothing);
|
||||
expect(find.byTooltip('Search products'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<IconButton>(
|
||||
find.ancestor(
|
||||
of: find.byIcon(Icons.search),
|
||||
matching: find.byType(IconButton),
|
||||
),
|
||||
)
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('deleting a trip removes it without leaving the screen', (
|
||||
tester,
|
||||
) async {
|
||||
final repository = memoryRepository();
|
||||
await tester.runAsync(() async {
|
||||
await repository.saveShop(
|
||||
supermarket: 'Jumbo',
|
||||
shoppedAt: DateTime.now(),
|
||||
name: 'Weekly shop',
|
||||
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
|
||||
);
|
||||
});
|
||||
|
||||
await tester.pumpWidget(GrociApp(repository: repository));
|
||||
await pumpUntilFound(tester, find.text('Overview'));
|
||||
await tester.tap(find.text('Shop log'));
|
||||
await pumpUntilFound(tester, find.text('Weekly shop'));
|
||||
|
||||
await tester.tap(find.byTooltip('Edit'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Remove'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pump();
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Weekly shop'), findsNothing);
|
||||
expect(
|
||||
find.text('Scan a receipt or enter a trip to log it.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byTooltip('Edit'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('products edit mode shows delete, star, and wipe all', (
|
||||
tester,
|
||||
) async {
|
||||
final repository = memoryRepository();
|
||||
await tester.runAsync(() async {
|
||||
await repository.saveShop(
|
||||
supermarket: 'Jumbo',
|
||||
shoppedAt: DateTime.now(),
|
||||
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
|
||||
);
|
||||
});
|
||||
|
||||
await tester.pumpWidget(GrociApp(repository: repository));
|
||||
await pumpUntilFound(tester, find.text('Overview'));
|
||||
await tester.tap(find.text('Products').last);
|
||||
await pumpUntilFound(tester, find.text('Milk'));
|
||||
|
||||
await tester.tap(find.byTooltip('Edit'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byTooltip('Delete'), findsOneWidget);
|
||||
expect(find.byTooltip('Favourite'), findsOneWidget);
|
||||
expect(find.byTooltip('Delete all products'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Delete all products'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pump();
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Milk'), findsNothing);
|
||||
expect(find.byTooltip('Edit'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('profile screen is a placeholder', (tester) async {
|
||||
await tester.pumpWidget(GrociApp(repository: memoryRepository()));
|
||||
await pumpUntilFound(tester, find.text('Overview'));
|
||||
await tester.tap(find.byTooltip('Profile'));
|
||||
await pumpUntilFound(tester, find.text('Guest'));
|
||||
});
|
||||
|
||||
testWidgets('shop heatmap renders the visible month', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ShopHeatmap(
|
||||
month: DateTime(2026, 8),
|
||||
counts: const {},
|
||||
monthTotal: 0,
|
||||
monthTripCount: 0,
|
||||
canGoForward: false,
|
||||
onPreviousMonth: () {},
|
||||
onNextMonth: () {},
|
||||
onSelectDay: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('August 2026'), findsOneWidget);
|
||||
expect(find.text('No trips this month'), findsOneWidget);
|
||||
expect(find.text('Less'), findsOneWidget);
|
||||
expect(find.text('More'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('enter manually opens a blank trip', (tester) async {
|
||||
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);
|
||||
@@ -50,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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -60,6 +208,8 @@ void main() {
|
||||
expect(find.text('Add item'), findsOneWidget);
|
||||
expect(find.textContaining('Save trip'), findsOneWidget);
|
||||
expect(find.text('Supermarket'), findsOneWidget);
|
||||
expect(find.text('Trip name'), findsOneWidget);
|
||||
expect(find.text('Add tag'), findsOneWidget);
|
||||
|
||||
await tester.drag(find.text('Milk'), const Offset(-500, 0));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -75,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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user