home added

This commit is contained in:
gitea
2026-08-31 12:57:17 +02:00
parent 92581e6d55
commit 7692809219
18 changed files with 1446 additions and 384 deletions
+278
View File
@@ -0,0 +1,278 @@
/// Dashboard of catalog, trips, lists, and this month's spend.
library;
import 'package:flutter/material.dart';
import 'models/app_overview.dart';
import 'models/shop.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;
Object? _error;
int _loadToken = 0;
@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();
if (!mounted || token != _loadToken) return;
setState(() {
_overview = overview;
_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();
}
@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!,
onOpenProducts: widget.onOpenProducts,
onOpenLists: widget.onOpenLists,
onOpenShopLog: widget.onOpenShopLog,
onOpenAdd: widget.onOpenAdd,
onOpenShop: _openShop,
),
);
}
}
class _HomeBody extends StatelessWidget {
const _HomeBody({
required this.overview,
required this.onOpenProducts,
required this.onOpenLists,
required this.onOpenShopLog,
required this.onOpenAdd,
required this.onOpenShop,
});
final AppOverview overview;
final VoidCallback onOpenProducts;
final VoidCallback onOpenLists;
final VoidCallback onOpenShopLog;
final VoidCallback onOpenAdd;
final ValueChanged<Shop> onOpenShop;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
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 (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: stores)
: Text(shop.name!),
subtitle: Text(
[
formatDate(shop.shoppedAt),
formatEuro(shop.total),
].join(' · '),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => onOpenShop(shop),
),
),
],
],
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({
required this.icon,
required this.label,
required this.value,
required this.detail,
required this.onTap,
});
final IconData icon;
final String label;
final String value;
final String detail;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: scheme.primary),
const SizedBox(height: 12),
Text(label, style: theme.textTheme.bodyMedium),
const SizedBox(height: 4),
Text(value, style: theme.textTheme.titleLarge),
const SizedBox(height: 2),
Text(detail, style: theme.textTheme.bodyMedium),
],
),
),
),
);
}
}
+49 -22
View File
@@ -1,8 +1,9 @@
/// Bottom navigation: products, lists, add, and shop log. /// Bottom navigation: home, products, lists, add, and shop log.
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'home_screen.dart';
import 'products_screen.dart'; import 'products_screen.dart';
import 'scan_screen.dart'; import 'scan_screen.dart';
import 'services/app_settings.dart'; import 'services/app_settings.dart';
@@ -20,11 +21,14 @@ class HomeShell extends StatefulWidget {
} }
class _HomeShellState extends State<HomeShell> { class _HomeShellState extends State<HomeShell> {
static const _homeIndex = 0;
static const _productsIndex = 1;
static const _addIndex = 2; static const _addIndex = 2;
static const _shopLogIndex = 3; static const _listsIndex = 3;
static const _shopLogIndex = 4;
static const _fabGap = 56.0; static const _fabGap = 56.0;
int _index = _addIndex; int _index = _homeIndex;
int _revision = 0; int _revision = 0;
AppSettings? _settings; AppSettings? _settings;
int _seenGeneration = 0; int _seenGeneration = 0;
@@ -77,15 +81,23 @@ class _HomeShellState extends State<HomeShell> {
return Scaffold( return Scaffold(
body: switch (_index) { body: switch (_index) {
0 => ProductsScreen( _homeIndex => HomeScreen(
key: ValueKey('home-$_revision'),
repository: widget.repository,
onOpenProducts: () => _select(_productsIndex),
onOpenLists: () => _select(_listsIndex),
onOpenShopLog: () => _select(_shopLogIndex),
onOpenAdd: () => _select(_addIndex),
),
_productsIndex => ProductsScreen(
key: ValueKey('products-$_revision'), key: ValueKey('products-$_revision'),
repository: widget.repository, repository: widget.repository,
), ),
1 => ShoppingListsScreen( _listsIndex => ShoppingListsScreen(
key: ValueKey('lists-$_revision'), key: ValueKey('lists-$_revision'),
repository: widget.repository, repository: widget.repository,
), ),
2 => ScanScreen( _addIndex => ScanScreen(
repository: widget.repository, repository: widget.repository,
onTripSaved: _onTripSaved, onTripSaved: _onTripSaved,
), ),
@@ -118,20 +130,20 @@ class _HomeShellState extends State<HomeShell> {
children: [ children: [
Expanded( Expanded(
child: _NavButton( child: _NavButton(
icon: Icons.inventory_2_outlined, icon: Icons.home_outlined,
selectedIcon: Icons.inventory_2, selectedIcon: Icons.home,
label: 'Products', label: 'Home',
selected: _index == 0, selected: _index == _homeIndex,
onTap: () => _select(0), onTap: () => _select(_homeIndex),
), ),
), ),
Expanded( Expanded(
child: _NavButton( child: _NavButton(
icon: Icons.checklist_outlined, icon: Icons.inventory_2_outlined,
selectedIcon: Icons.checklist, selectedIcon: Icons.inventory_2,
label: 'Lists', label: 'Products',
selected: _index == 1, selected: _index == _productsIndex,
onTap: () => _select(1), onTap: () => _select(_productsIndex),
), ),
), ),
], ],
@@ -139,12 +151,27 @@ class _HomeShellState extends State<HomeShell> {
), ),
const SizedBox(width: _fabGap), const SizedBox(width: _fabGap),
Expanded( Expanded(
child: _NavButton( child: Row(
icon: Icons.receipt_long_outlined, children: [
selectedIcon: Icons.receipt_long, Expanded(
label: 'Shop log', child: _NavButton(
selected: _index == _shopLogIndex, icon: Icons.checklist_outlined,
onTap: () => _select(_shopLogIndex), 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),
),
),
],
), ),
), ),
], ],
+24
View File
@@ -0,0 +1,24 @@
/// Counts and recent trips shown on the home dashboard.
library;
import 'shop.dart';
class AppOverview {
const AppOverview({
required this.productCount,
required this.favouriteCount,
required this.tripCount,
required this.monthTripCount,
required this.monthTotal,
required this.listCount,
required this.recentShops,
});
final int productCount;
final int favouriteCount;
final int tripCount;
final int monthTripCount;
final double monthTotal;
final int listCount;
final List<Shop> recentShops;
}
+3 -3
View File
@@ -59,7 +59,7 @@ class ShopItem {
const ShopItem({ const ShopItem({
required this.id, required this.id,
required this.shopId, required this.shopId,
required this.productId, this.productId,
required this.name, required this.name,
required this.price, required this.price,
this.quantity, this.quantity,
@@ -68,7 +68,7 @@ class ShopItem {
final int id; final int id;
final int shopId; final int shopId;
final int productId; final int? productId;
final String name; final String name;
final double price; final double price;
final int? quantity; final int? quantity;
@@ -78,7 +78,7 @@ class ShopItem {
return ShopItem( return ShopItem(
id: map['id']! as int, id: map['id']! as int,
shopId: map['shop_id']! as int, shopId: map['shop_id']! as int,
productId: map['product_id']! as int, productId: map['product_id'] as int?,
name: map['name']! as String, name: map['name']! as String,
price: (map['price'] as num).toDouble(), price: (map['price'] as num).toDouble(),
quantity: map['quantity'] as int?, quantity: map['quantity'] as int?,
+262 -110
View File
@@ -4,12 +4,15 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'models/product.dart'; import 'models/product.dart';
import 'models/supermarket.dart';
import 'product_detail_screen.dart'; import 'product_detail_screen.dart';
import 'services/app_settings.dart'; import 'services/app_settings.dart';
import 'services/product_repository.dart'; import 'services/product_repository.dart';
import 'utils/fuzzy.dart'; import 'utils/fuzzy.dart';
import 'utils/money.dart'; import 'utils/money.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/product_image.dart'; import 'widgets/product_image.dart';
import 'widgets/profile_button.dart';
import 'widgets/settings_button.dart'; import 'widgets/settings_button.dart';
import 'widgets/store_tag.dart'; import 'widgets/store_tag.dart';
@@ -26,31 +29,52 @@ class _ProductsScreenState extends State<ProductsScreen> {
ProductSort _sort = ProductSort.preferred; ProductSort _sort = ProductSort.preferred;
bool _favouritesOnly = false; bool _favouritesOnly = false;
bool _searching = false; bool _searching = false;
bool _editing = false;
final TextEditingController _search = TextEditingController(); final TextEditingController _search = TextEditingController();
late Future<List<Product>> _future; List<Product>? _products;
Object? _error;
int _loadToken = 0;
bool get _hasProducts => _products != null && _products!.isNotEmpty;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_future = _load(); widget.repository.addListener(_load);
_search.addListener(() => setState(() {})); _search.addListener(() => setState(() {}));
_load();
} }
@override @override
void dispose() { void dispose() {
widget.repository.removeListener(_load);
_search.dispose(); _search.dispose();
super.dispose(); super.dispose();
} }
Future<List<Product>> _load() { Future<void> _load() async {
return widget.repository.getAll( final token = ++_loadToken;
sort: _sort, try {
favouritesOnly: _favouritesOnly, final products = await widget.repository.getAll(
); sort: _sort,
} favouritesOnly: _favouritesOnly,
);
void _reload() { if (!mounted || token != _loadToken) return;
setState(() => _future = _load()); setState(() {
_products = products;
_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) { List<Product> _filter(List<Product> products) {
@@ -82,13 +106,88 @@ 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();
}
}
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 (_favouritesOnly && !next) {
_products = [
for (final item in _products!)
if (item.id != product.id) item,
];
if (_products!.isEmpty) {
_editing = false;
}
}
});
try {
await widget.repository.setFavourite(product.id, next);
} catch (_) {
if (mounted) _load();
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const []; final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
final products = _products == null ? const <Product>[] : _filter(_products!);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -106,17 +205,38 @@ class _ProductsScreenState extends State<ProductsScreen> {
) )
: const Text('Products'), : const Text('Products'),
actions: [ actions: [
IconButton( if (!_editing)
tooltip: _searching ? 'Close search' : 'Search', IconButton(
onPressed: () { tooltip: _searching ? 'Close search' : 'Search',
setState(() { onPressed: _hasProducts
_searching = !_searching; ? () {
if (!_searching) _search.clear(); setState(() {
}); _searching = !_searching;
}, if (!_searching) _search.clear();
icon: Icon(_searching ? Icons.close : Icons.search), });
), }
const SettingsButton(), : null,
icon: Icon(_searching ? Icons.close : Icons.search),
),
if (_hasProducts)
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() {
_editing = !_editing;
if (_editing) {
_searching = false;
_search.clear();
}
}),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
if (_editing)
IconButton(
tooltip: 'Delete all products',
onPressed: _removeAllProducts,
icon: const Icon(Icons.delete_outline),
),
if (!_editing) ...const [ProfileButton(), SettingsButton()],
], ],
), ),
body: Column( body: Column(
@@ -141,8 +261,8 @@ class _ProductsScreenState extends State<ProductsScreen> {
onSelectionChanged: (selected) { onSelectionChanged: (selected) {
setState(() { setState(() {
_sort = selected.first; _sort = selected.first;
_future = _load();
}); });
_load();
}, },
), ),
), ),
@@ -153,8 +273,8 @@ class _ProductsScreenState extends State<ProductsScreen> {
onPressed: () { onPressed: () {
setState(() { setState(() {
_favouritesOnly = !_favouritesOnly; _favouritesOnly = !_favouritesOnly;
_future = _load();
}); });
_load();
}, },
icon: Icon( icon: Icon(
_favouritesOnly ? Icons.star : Icons.star_outline, _favouritesOnly ? Icons.star : Icons.star_outline,
@@ -163,93 +283,125 @@ class _ProductsScreenState extends State<ProductsScreen> {
], ],
), ),
), ),
Expanded( Expanded(child: _buildList(theme, stores, products)),
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),
);
},
);
},
),
),
], ],
), ),
); );
} }
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(
_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,
),
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,
),
),
],
)
: 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: _editing ? null : () => _openProduct(product),
);
},
);
}
} }
+44
View File
@@ -0,0 +1,44 @@
/// User profile placeholder. Sign-in will be added later.
library;
import 'package:flutter/material.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: ListView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 32),
children: [
CircleAvatar(
radius: 40,
backgroundColor: scheme.secondaryContainer,
child: Icon(
Icons.person_outline,
size: 40,
color: scheme.onSecondaryContainer,
),
),
const SizedBox(height: 16),
Text(
'Guest',
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Sign in, cloud sync, and account settings will show up here later.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
],
),
);
}
}
+4 -1
View File
@@ -16,6 +16,7 @@ import 'services/product_repository.dart';
import 'services/receipt_parser.dart'; import 'services/receipt_parser.dart';
import 'utils/constants.dart'; import 'utils/constants.dart';
import 'utils/supermarkets.dart'; import 'utils/supermarkets.dart';
import 'widgets/profile_button.dart';
import 'widgets/settings_button.dart'; import 'widgets/settings_button.dart';
class ScanScreen extends StatefulWidget { class ScanScreen extends StatefulWidget {
@@ -53,6 +54,8 @@ class _ScanScreenState extends State<ScanScreen> {
} }
} on UnimplementedError { } on UnimplementedError {
// Desktop / platforms that do not support lost-data recovery. // Desktop / platforms that do not support lost-data recovery.
} on MissingPluginException {
// Widget tests and hosts without the image_picker plugin.
} }
} }
@@ -182,7 +185,7 @@ class _ScanScreenState extends State<ScanScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text(kAppName), title: const Text(kAppName),
actions: const [SettingsButton()], actions: const [ProfileButton(), SettingsButton()],
), ),
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
+153 -15
View File
@@ -3,10 +3,12 @@ library;
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import '../models/app_overview.dart';
import '../models/line_item.dart'; import '../models/line_item.dart';
import '../models/product.dart'; import '../models/product.dart';
import '../models/purchase.dart'; import '../models/purchase.dart';
@@ -16,15 +18,15 @@ import '../models/supermarket.dart';
import '../utils/money.dart'; import '../utils/money.dart';
import '../utils/supermarkets.dart'; import '../utils/supermarkets.dart';
class ProductRepository { class ProductRepository extends ChangeNotifier {
ProductRepository({this.databasePath}); ProductRepository({this.databasePath});
final String? databasePath; final String? databasePath;
Database? _db; Future<Database>? _opening;
Future<Database> get _database async { Future<Database> get _database {
_db ??= await _open(); _opening ??= _open();
return _db!; return _opening!;
} }
Future<Database> _open() async { Future<Database> _open() async {
@@ -33,7 +35,7 @@ class ProductRepository {
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db'); p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
return openDatabase( return openDatabase(
path, path,
version: 6, version: 7,
onConfigure: (db) async { onConfigure: (db) async {
await db.execute('PRAGMA foreign_keys = ON'); await db.execute('PRAGMA foreign_keys = ON');
}, },
@@ -67,6 +69,9 @@ class ProductRepository {
if (oldVersion < 6) { if (oldVersion < 6) {
await _upgradeShopsToV6(db); await _upgradeShopsToV6(db);
} }
if (oldVersion < 7) {
await _upgradeShopItemsProductFk(db);
}
}, },
); );
} }
@@ -135,13 +140,13 @@ class ProductRepository {
CREATE TABLE shop_items ( CREATE TABLE shop_items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
shop_id INTEGER NOT NULL, shop_id INTEGER NOT NULL,
product_id INTEGER NOT NULL, product_id INTEGER,
name TEXT NOT NULL, name TEXT NOT NULL,
price REAL NOT NULL, price REAL NOT NULL,
quantity INTEGER, quantity INTEGER,
unit_price REAL, unit_price REAL,
FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE, FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id) FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE SET NULL
) )
'''); ''');
await _createShopTagTable(db); await _createShopTagTable(db);
@@ -169,6 +174,31 @@ class ProductRepository {
await _createShopTagTable(db); await _createShopTagTable(db);
} }
Future<void> _upgradeShopItemsProductFk(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE shop_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
shop_id INTEGER NOT NULL,
product_id INTEGER,
name TEXT NOT NULL,
price REAL NOT NULL,
quantity INTEGER,
unit_price REAL,
FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE SET NULL
)
''');
await db.execute('''
INSERT INTO shop_items_new (
id, shop_id, product_id, name, price, quantity, unit_price
)
SELECT id, shop_id, product_id, name, price, quantity, unit_price
FROM shop_items
''');
await db.execute('DROP TABLE shop_items');
await db.execute('ALTER TABLE shop_items_new RENAME TO shop_items');
}
Future<void> _createShoppingListTables(DatabaseExecutor db) async { Future<void> _createShoppingListTables(DatabaseExecutor db) async {
await db.execute(''' await db.execute('''
CREATE TABLE IF NOT EXISTS shopping_lists ( CREATE TABLE IF NOT EXISTS shopping_lists (
@@ -268,6 +298,7 @@ class ProductRepository {
} }
} }
notifyListeners();
return shopId; return shopId;
} }
@@ -353,6 +384,51 @@ class ProductRepository {
return rows.map(Product.fromMap).toList(); return rows.map(Product.fromMap).toList();
} }
Future<AppOverview> getOverview() async {
final db = await _database;
final productCount =
Sqflite.firstIntValue(
await db.rawQuery('SELECT COUNT(*) FROM products'),
) ??
0;
final favouriteCount =
Sqflite.firstIntValue(
await db.rawQuery(
'SELECT COUNT(*) FROM products WHERE favourite = 1',
),
) ??
0;
final tripCount =
Sqflite.firstIntValue(await db.rawQuery('SELECT COUNT(*) FROM shops')) ??
0;
final listCount =
Sqflite.firstIntValue(
await db.rawQuery('SELECT COUNT(*) FROM shopping_lists'),
) ??
0;
final now = DateTime.now();
final monthStart = DateTime(now.year, now.month, 1).toIso8601String();
final monthRows = await db.rawQuery(
'''
SELECT COUNT(*) AS trip_count, COALESCE(SUM(total), 0) AS month_total
FROM shops
WHERE shopped_at >= ?
''',
[monthStart],
);
final month = monthRows.first;
final shops = await getShops();
return AppOverview(
productCount: productCount,
favouriteCount: favouriteCount,
tripCount: tripCount,
monthTripCount: (month['trip_count'] as num?)?.toInt() ?? 0,
monthTotal: (month['month_total'] as num?)?.toDouble() ?? 0,
listCount: listCount,
recentShops: shops.take(3).toList(),
);
}
Future<Product?> getProduct(int id) async { Future<Product?> getProduct(int id) async {
final db = await _database; final db = await _database;
final rows = await db.rawQuery( final rows = await db.rawQuery(
@@ -433,6 +509,7 @@ class ProductRepository {
values['category'] = category.trim(); values['category'] = category.trim();
} }
await db.update('products', values, where: 'id = ?', whereArgs: [id]); await db.update('products', values, where: 'id = ?', whereArgs: [id]);
notifyListeners();
} }
Future<void> setFavourite(int id, bool favourite) => Future<void> setFavourite(int id, bool favourite) =>
@@ -444,6 +521,60 @@ class ProductRepository {
return stored; return stored;
} }
Future<void> deleteProduct(int id) async {
final db = await _database;
final rows = await db.query(
'products',
columns: ['image_path'],
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
await db.transaction((txn) async {
await txn.update(
'shop_items',
{'product_id': null},
where: 'product_id = ?',
whereArgs: [id],
);
await txn.update(
'shopping_list_items',
{'product_id': null},
where: 'product_id = ?',
whereArgs: [id],
);
await txn.delete('products', where: 'id = ?', whereArgs: [id]);
});
if (rows.isNotEmpty) {
await _tryDeleteFile(rows.first['image_path'] as String?);
}
notifyListeners();
}
Future<void> deleteAllProducts() async {
final db = await _database;
final rows = await db.query('products', columns: ['image_path']);
await db.transaction((txn) async {
await txn.update('shop_items', {'product_id': null});
await txn.update('shopping_list_items', {'product_id': null});
await txn.delete('products');
});
for (final row in rows) {
await _tryDeleteFile(row['image_path'] as String?);
}
notifyListeners();
}
Future<void> _tryDeleteFile(String? path) async {
if (path == null || path.isEmpty) return;
try {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
}
Future<List<Purchase>> getPurchases(int productId) async { Future<List<Purchase>> getPurchases(int productId) async {
final db = await _database; final db = await _database;
final rows = await db.rawQuery( final rows = await db.rawQuery(
@@ -576,6 +707,7 @@ class ProductRepository {
await _replaceShopTags(txn, shopId: id, tags: tags); await _replaceShopTags(txn, shopId: id, tags: tags);
} }
}); });
notifyListeners();
} }
Future<void> _replaceShopTags( Future<void> _replaceShopTags(
@@ -624,12 +756,8 @@ class ProductRepository {
await txn.delete('shop_tags', where: 'shop_id = ?', whereArgs: [id]); await txn.delete('shop_tags', where: 'shop_id = ?', whereArgs: [id]);
await txn.delete('shops', where: 'id = ?', whereArgs: [id]); await txn.delete('shops', where: 'id = ?', whereArgs: [id]);
}); });
final path = shop?.receiptImagePath; await _tryDeleteFile(shop?.receiptImagePath);
if (path == null || path.isEmpty) return; notifyListeners();
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} }
/// Removes one trip line. Catalog products are left unchanged. /// Removes one trip line. Catalog products are left unchanged.
@@ -646,6 +774,7 @@ class ProductRepository {
final shopId = rows.first['shop_id']! as int; final shopId = rows.first['shop_id']! as int;
await db.delete('shop_items', where: 'id = ?', whereArgs: [id]); await db.delete('shop_items', where: 'id = ?', whereArgs: [id]);
await _recalculateShopTotal(shopId); await _recalculateShopTotal(shopId);
notifyListeners();
} }
Future<void> updateShopItem({ Future<void> updateShopItem({
@@ -672,6 +801,7 @@ class ProductRepository {
if (values.isEmpty) return; if (values.isEmpty) return;
await db.update('shop_items', values, where: 'id = ?', whereArgs: [id]); await db.update('shop_items', values, where: 'id = ?', whereArgs: [id]);
await _recalculateShopTotal(shopId); await _recalculateShopTotal(shopId);
notifyListeners();
} }
Future<void> _recalculateShopTotal(int shopId) async { Future<void> _recalculateShopTotal(int shopId) async {
@@ -732,11 +862,13 @@ class ProductRepository {
Future<int> createShoppingList(String name) async { Future<int> createShoppingList(String name) async {
final db = await _database; final db = await _database;
final now = DateTime.now().toIso8601String(); final now = DateTime.now().toIso8601String();
return db.insert('shopping_lists', { final id = await db.insert('shopping_lists', {
'name': name.trim(), 'name': name.trim(),
'created_at': now, 'created_at': now,
'updated_at': now, 'updated_at': now,
}); });
notifyListeners();
return id;
} }
Future<void> renameShoppingList(int id, String name) async { Future<void> renameShoppingList(int id, String name) async {
@@ -747,6 +879,7 @@ class ProductRepository {
where: 'id = ?', where: 'id = ?',
whereArgs: [id], whereArgs: [id],
); );
notifyListeners();
} }
Future<void> deleteShoppingList(int id) async { Future<void> deleteShoppingList(int id) async {
@@ -759,6 +892,7 @@ class ProductRepository {
); );
await txn.delete('shopping_lists', where: 'id = ?', whereArgs: [id]); await txn.delete('shopping_lists', where: 'id = ?', whereArgs: [id]);
}); });
notifyListeners();
} }
Future<List<ShoppingListItem>> getShoppingListItems(int listId) async { Future<List<ShoppingListItem>> getShoppingListItems(int listId) async {
@@ -792,6 +926,7 @@ class ProductRepository {
'sort_order': (max ?? -1) + 1, 'sort_order': (max ?? -1) + 1,
}); });
await _touchShoppingList(listId); await _touchShoppingList(listId);
notifyListeners();
return id; return id;
} }
@@ -822,6 +957,7 @@ class ProductRepository {
whereArgs: [id], whereArgs: [id],
); );
await _touchShoppingList(rows.first['list_id']! as int); await _touchShoppingList(rows.first['list_id']! as int);
notifyListeners();
} }
Future<void> deleteShoppingListItem(int id) async { Future<void> deleteShoppingListItem(int id) async {
@@ -836,6 +972,7 @@ class ProductRepository {
if (rows.isEmpty) return; if (rows.isEmpty) return;
await db.delete('shopping_list_items', where: 'id = ?', whereArgs: [id]); await db.delete('shopping_list_items', where: 'id = ?', whereArgs: [id]);
await _touchShoppingList(rows.first['list_id']! as int); await _touchShoppingList(rows.first['list_id']! as int);
notifyListeners();
} }
Future<void> _touchShoppingList(int id) async { Future<void> _touchShoppingList(int id) async {
@@ -1026,6 +1163,7 @@ class ProductRepository {
await _resetSequence(txn, 'shopping_lists'); await _resetSequence(txn, 'shopping_lists');
await _resetSequence(txn, 'shopping_list_items'); await _resetSequence(txn, 'shopping_list_items');
}); });
notifyListeners();
} }
static const _supermarketColumns = {'id', 'name', 'color', 'sort_order'}; static const _supermarketColumns = {'id', 'name', 'color', 'sort_order'};
+11 -1
View File
@@ -39,6 +39,7 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_future = _load(); _future = _load();
widget.repository.addListener(_reload);
widget.repository widget.repository
.getUsedShopTags() .getUsedShopTags()
.then((tags) { .then((tags) {
@@ -47,6 +48,12 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
.catchError((_) {}); .catchError((_) {});
} }
@override
void dispose() {
widget.repository.removeListener(_reload);
super.dispose();
}
Future<({Shop shop, List<ShopItem> items})> _load() async { Future<({Shop shop, List<ShopItem> items})> _load() async {
final shop = await widget.repository.getShop(widget.shopId); final shop = await widget.repository.getShop(widget.shopId);
if (shop == null) { if (shop == null) {
@@ -57,6 +64,7 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
} }
void _reload() { void _reload() {
if (!mounted) return;
setState(() => _future = _load()); setState(() => _future = _load());
} }
@@ -276,7 +284,9 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
formatEuro(item.price), formatEuro(item.price),
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium,
), ),
onTap: _editing ? null : () => _openProduct(item.productId), onTap: _editing || item.productId == null
? null
: () => _openProduct(item.productId!),
), ),
const Divider(), const Divider(),
ListTile( ListTile(
+250 -154
View File
@@ -13,6 +13,7 @@ import 'utils/money.dart';
import 'utils/shop_calendar.dart'; import 'utils/shop_calendar.dart';
import 'utils/shop_log_filter.dart'; import 'utils/shop_log_filter.dart';
import 'widgets/confirm_dialog.dart'; import 'widgets/confirm_dialog.dart';
import 'widgets/profile_button.dart';
import 'widgets/settings_button.dart'; import 'widgets/settings_button.dart';
import 'widgets/shop_heatmap.dart'; import 'widgets/shop_heatmap.dart';
import 'widgets/store_tag.dart'; import 'widgets/store_tag.dart';
@@ -28,8 +29,10 @@ class ShopLogScreen extends StatefulWidget {
} }
class _ShopLogScreenState extends State<ShopLogScreen> { class _ShopLogScreenState extends State<ShopLogScreen> {
late Future<({List<Shop> shops, Map<int, List<String>> productNames})> List<Shop>? _shops;
_future; Map<int, List<String>> _productNames = const {};
Object? _error;
int _loadToken = 0;
final _search = TextEditingController(); final _search = TextEditingController();
bool _editing = false; bool _editing = false;
bool _searching = false; bool _searching = false;
@@ -37,28 +40,45 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
DateTime? _selectedDay; DateTime? _selectedDay;
String? _tagFilter; String? _tagFilter;
bool get _hasTrips => _shops != null && _shops!.isNotEmpty;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_future = _load(); widget.repository.addListener(_load);
_search.addListener(() => setState(() {})); _search.addListener(() => setState(() {}));
_load();
} }
@override @override
void dispose() { void dispose() {
widget.repository.removeListener(_load);
_search.dispose(); _search.dispose();
super.dispose(); super.dispose();
} }
Future<({List<Shop> shops, Map<int, List<String>> productNames})> Future<void> _load() async {
_load() async { final token = ++_loadToken;
final shops = await widget.repository.getShops(); try {
final productNames = await widget.repository.getShopItemNamesByShop(); final shops = await widget.repository.getShops();
return (shops: shops, productNames: productNames); final productNames = await widget.repository.getShopItemNamesByShop();
} if (!mounted || token != _loadToken) return;
setState(() {
void _reload() { _shops = shops;
setState(() => _future = _load()); _productNames = productNames;
_error = null;
if (shops.isEmpty) {
_editing = false;
if (_searching) {
_searching = false;
_search.clear();
}
}
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
} }
Future<void> _openShop(Shop shop) async { Future<void> _openShop(Shop shop) async {
@@ -68,18 +88,31 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
ShopDetailScreen(repository: widget.repository, shopId: shop.id), ShopDetailScreen(repository: widget.repository, shopId: shop.id),
), ),
); );
if (mounted) _reload(); if (mounted) _load();
} }
Future<void> _removeShop(Shop shop) async { Future<void> _removeShop(Shop shop) async {
final confirmed = await confirmAction( final confirmed = await confirmAction(
context, context,
title: 'Remove shopping trip?', title: 'Remove shopping trip?',
message: 'This trip will be removed from the shop log. Products from this trip stay in your catalog.', message:
'This trip will be removed from the shop log. Products from this trip stay in your catalog.',
); );
if (!confirmed || !mounted) return; if (!confirmed || !mounted) return;
await widget.repository.deleteShop(shop.id); setState(() {
if (mounted) _reload(); _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) { void _selectDay(DateTime? day) {
@@ -120,142 +153,205 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
actions: [ actions: [
IconButton( IconButton(
tooltip: _searching ? 'Close search' : 'Search products', tooltip: _searching ? 'Close search' : 'Search products',
onPressed: () { onPressed: _hasTrips
setState(() { ? () {
_searching = !_searching; setState(() {
if (!_searching) _search.clear(); _searching = !_searching;
}); if (!_searching) _search.clear();
}, });
}
: null,
icon: Icon(_searching ? Icons.close : Icons.search), icon: Icon(_searching ? Icons.close : Icons.search),
), ),
IconButton( if (_hasTrips)
tooltip: _editing ? 'Done' : 'Edit', IconButton(
onPressed: () => setState(() => _editing = !_editing), tooltip: _editing ? 'Done' : 'Edit',
icon: Icon(_editing ? Icons.check : Icons.edit_outlined), onPressed: () => setState(() => _editing = !_editing),
), icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
const ProfileButton(),
const SettingsButton(), const SettingsButton(),
], ],
), ),
body: FutureBuilder( body: _ShopLogBody(
future: _future, theme: theme,
builder: (context, snapshot) { stores: stores,
if (snapshot.hasError) { nowMonth: nowMonth,
return Center( shops: _shops,
child: Text( productNames: _productNames,
'Could not load the shop log.', error: _error,
style: theme.textTheme.bodyLarge, month: _month,
), selectedDay: _selectedDay,
); tagFilter: _tagFilter,
} query: _search.text,
if (!snapshot.hasData) { editing: _editing,
return const Center(child: CircularProgressIndicator()); onPreviousMonth: () => _goMonth(-1),
} onNextMonth: () => _goMonth(1),
onSelectDay: _selectDay,
final shops = snapshot.data!.shops; onClearTag: () => setState(() => _tagFilter = null),
final productNames = snapshot.data!.productNames; onTagTap: (tag) {
final counts = tripCountsByDay([ setState(() {
for (final shop in shops) _tagFilter = _tagFilter?.toLowerCase() == tag.toLowerCase()
if (isSameMonth(shop.shoppedAt, _month)) shop.shoppedAt, ? null
]); : tag;
final monthShops = [ });
for (final shop in shops)
if (isSameMonth(shop.shoppedAt, _month)) shop,
];
final monthTotal = monthShops.fold<double>(
0,
(sum, shop) => sum + shop.total,
);
final visible = filterShops(
shops: shops,
productNames: productNames,
selectedDay: _selectedDay,
tag: _tagFilter,
query: _search.text,
);
return ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
ShopHeatmap(
month: _month,
counts: counts,
monthTotal: monthTotal,
monthTripCount: monthShops.length,
canGoForward: _month.isBefore(nowMonth),
selectedDay: _selectedDay,
onPreviousMonth: () => _goMonth(-1),
onNextMonth: () => _goMonth(1),
onSelectDay: _selectDay,
),
if (_selectedDay != null || _tagFilter != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (_selectedDay != null)
InputChip(
label: Text(formatDate(_selectedDay!)),
onDeleted: () => _selectDay(null),
),
if (_tagFilter != null)
InputChip(
label: Text(_tagFilter!),
onDeleted: () => setState(() => _tagFilter = null),
),
],
),
),
if (shops.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Scan a receipt or enter a trip to log it.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
)
else if (visible.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
_search.text.trim().isNotEmpty
? 'No trips contain that product.'
: 'No trips match this filter.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
)
else
for (final shop in visible)
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4),
child: _ShopLogTile(
shop: shop,
stores: stores,
editing: _editing,
selectedTag: _tagFilter,
onOpen: () => _openShop(shop),
onRemove: () => _removeShop(shop),
onTagTap: (tag) {
setState(() {
_tagFilter =
_tagFilter?.toLowerCase() == tag.toLowerCase()
? null
: tag;
});
},
),
),
],
);
}, },
onOpen: _openShop,
onRemove: _removeShop,
), ),
); );
} }
} }
class _ShopLogBody extends StatelessWidget {
const _ShopLogBody({
required this.theme,
required this.stores,
required this.nowMonth,
required this.shops,
required this.productNames,
required this.error,
required this.month,
required this.selectedDay,
required this.tagFilter,
required this.query,
required this.editing,
required this.onPreviousMonth,
required this.onNextMonth,
required this.onSelectDay,
required this.onClearTag,
required this.onTagTap,
required this.onOpen,
required this.onRemove,
});
final ThemeData theme;
final List<Supermarket> stores;
final DateTime nowMonth;
final List<Shop>? shops;
final Map<int, List<String>> productNames;
final Object? error;
final DateTime month;
final DateTime? selectedDay;
final String? tagFilter;
final String query;
final bool editing;
final VoidCallback onPreviousMonth;
final VoidCallback onNextMonth;
final ValueChanged<DateTime?> onSelectDay;
final VoidCallback onClearTag;
final ValueChanged<String> onTagTap;
final ValueChanged<Shop> onOpen;
final ValueChanged<Shop> onRemove;
@override
Widget build(BuildContext context) {
if (error != null && shops == null) {
return Center(
child: Text(
'Could not load the shop log.',
style: theme.textTheme.bodyLarge,
),
);
}
if (shops == null) {
return const Center(child: CircularProgressIndicator());
}
final allShops = shops!;
final counts = tripCountsByDay([
for (final shop in allShops)
if (isSameMonth(shop.shoppedAt, month)) shop.shoppedAt,
]);
final monthShops = [
for (final shop in allShops)
if (isSameMonth(shop.shoppedAt, month)) shop,
];
final monthTotal = monthShops.fold<double>(
0,
(sum, shop) => sum + shop.total,
);
final visible = filterShops(
shops: allShops,
productNames: productNames,
selectedDay: selectedDay,
tag: tagFilter,
query: query,
);
return ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
ShopHeatmap(
month: month,
counts: counts,
monthTotal: monthTotal,
monthTripCount: monthShops.length,
canGoForward: month.isBefore(nowMonth),
selectedDay: selectedDay,
onPreviousMonth: onPreviousMonth,
onNextMonth: onNextMonth,
onSelectDay: onSelectDay,
),
if (selectedDay != null || tagFilter != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (selectedDay != null)
InputChip(
label: Text(formatDate(selectedDay!)),
onDeleted: () => onSelectDay(null),
),
if (tagFilter != null)
InputChip(
label: Text(tagFilter!),
onDeleted: onClearTag,
),
],
),
),
if (allShops.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Scan a receipt or enter a trip to log it.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
)
else if (visible.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
query.trim().isNotEmpty
? 'No trips contain that product.'
: 'No trips match this filter.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
)
else
for (final shop in visible)
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4),
child: _ShopLogTile(
shop: shop,
stores: stores,
editing: editing,
selectedTag: tagFilter,
onOpen: () => onOpen(shop),
onRemove: () => onRemove(shop),
onTagTap: onTagTap,
),
),
],
);
}
}
class _ShopLogTile extends StatelessWidget { class _ShopLogTile extends StatelessWidget {
const _ShopLogTile({ const _ShopLogTile({
required this.shop, required this.shop,
@@ -295,6 +391,12 @@ class _ShopLogTile extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (editing)
IconButton(
tooltip: 'Remove',
onPressed: onRemove,
icon: const Icon(Icons.delete_outline),
),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -326,19 +428,13 @@ class _ShopLogTile extends StatelessWidget {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
editing Padding(
? IconButton( padding: const EdgeInsets.only(top: 8, right: 8),
tooltip: 'Remove', child: Text(
onPressed: onRemove, formatEuro(shop.total),
icon: const Icon(Icons.delete_outline), style: theme.textTheme.titleMedium,
) ),
: Padding( ),
padding: const EdgeInsets.only(top: 8, right: 8),
child: Text(
formatEuro(shop.total),
style: theme.textTheme.titleMedium,
),
),
], ],
), ),
), ),
+8
View File
@@ -33,6 +33,13 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_future = _load(); _future = _load();
widget.repository.addListener(_reload);
}
@override
void dispose() {
widget.repository.removeListener(_reload);
super.dispose();
} }
Future<({ShoppingList list, List<ShoppingListItem> items})> _load() async { Future<({ShoppingList list, List<ShoppingListItem> items})> _load() async {
@@ -45,6 +52,7 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
} }
void _reload() { void _reload() {
if (!mounted) return;
setState(() => _future = _load()); setState(() => _future = _load());
} }
+83 -61
View File
@@ -8,6 +8,7 @@ import 'services/product_repository.dart';
import 'shopping_list_detail_screen.dart'; import 'shopping_list_detail_screen.dart';
import 'widgets/confirm_dialog.dart'; import 'widgets/confirm_dialog.dart';
import 'widgets/name_dialog.dart'; import 'widgets/name_dialog.dart';
import 'widgets/profile_button.dart';
import 'widgets/settings_button.dart'; import 'widgets/settings_button.dart';
class ShoppingListsScreen extends StatefulWidget { class ShoppingListsScreen extends StatefulWidget {
@@ -20,17 +21,40 @@ class ShoppingListsScreen extends StatefulWidget {
} }
class _ShoppingListsScreenState extends State<ShoppingListsScreen> { class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
late Future<List<ShoppingList>> _future; List<ShoppingList>? _lists;
Object? _error;
bool _editing = false; bool _editing = false;
int _loadToken = 0;
bool get _hasLists => _lists != null && _lists!.isNotEmpty;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_future = widget.repository.getShoppingLists(); widget.repository.addListener(_load);
_load();
} }
void _reload() { @override
setState(() => _future = widget.repository.getShoppingLists()); void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final token = ++_loadToken;
try {
final lists = await widget.repository.getShoppingLists();
if (!mounted || token != _loadToken) return;
setState(() {
_lists = lists;
_error = null;
if (lists.isEmpty) _editing = false;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
} }
Future<void> _create() async { Future<void> _create() async {
@@ -53,7 +77,7 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
ShoppingListDetailScreen(repository: widget.repository, listId: id), ShoppingListDetailScreen(repository: widget.repository, listId: id),
), ),
); );
if (mounted) _reload(); if (mounted) _load();
} }
Future<void> _removeList(ShoppingList list) async { Future<void> _removeList(ShoppingList list) async {
@@ -63,8 +87,15 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
message: '"${list.name}" and its items will be deleted.', message: '"${list.name}" and its items will be deleted.',
); );
if (!confirmed || !mounted) return; if (!confirmed || !mounted) return;
await widget.repository.deleteShoppingList(list.id); setState(() {
if (mounted) _reload(); _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 @override
@@ -75,11 +106,13 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('Shopping lists'), title: const Text('Shopping lists'),
actions: [ actions: [
IconButton( if (_hasLists)
tooltip: _editing ? 'Done' : 'Edit', IconButton(
onPressed: () => setState(() => _editing = !_editing), tooltip: _editing ? 'Done' : 'Edit',
icon: Icon(_editing ? Icons.check : Icons.edit_outlined), onPressed: () => setState(() => _editing = !_editing),
), icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
const ProfileButton(),
const SettingsButton(), const SettingsButton(),
], ],
), ),
@@ -88,64 +121,53 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
tooltip: 'New list', tooltip: 'New list',
child: const Icon(Icons.add), child: const Icon(Icons.add),
), ),
body: FutureBuilder<List<ShoppingList>>( body: _error != null && _lists == null
future: _future, ? Center(
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text( child: Text(
'Could not load shopping lists.', 'Could not load shopping lists.',
style: theme.textTheme.bodyLarge, style: theme.textTheme.bodyLarge,
), ),
); )
} : _lists == null
if (!snapshot.hasData) { ? const Center(child: CircularProgressIndicator())
return const Center(child: CircularProgressIndicator()); : _lists!.isEmpty
} ? Center(
final lists = snapshot.data!;
if (lists.isEmpty) {
return Center(
child: Text( child: Text(
'Create a list for your next shop.', 'Create a list for your next shop.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge, style: theme.textTheme.bodyLarge,
), ),
); )
} : ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
return ListView.separated( itemCount: _lists!.length,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88), separatorBuilder: (_, _) => const SizedBox(height: 4),
itemCount: lists.length, itemBuilder: (context, index) {
separatorBuilder: (_, _) => const SizedBox(height: 4), final list = _lists![index];
itemBuilder: (context, index) { final remaining = list.itemCount - list.checkedCount;
final list = lists[index]; return ListTile(
final remaining = list.itemCount - list.checkedCount; leading: CircleAvatar(child: Text('${list.itemCount}')),
return ListTile( title: Text(list.name),
leading: CircleAvatar(child: Text('${list.itemCount}')), subtitle: Text(
title: Text(list.name), list.itemCount == 0
subtitle: Text( ? 'Empty'
list.itemCount == 0 : remaining == 0
? 'Empty' ? 'All done'
: remaining == 0 : remaining == 1
? 'All done' ? '1 item left'
: remaining == 1 : '$remaining items left',
? '1 item left' ),
: '$remaining items left', trailing: _editing
), ? IconButton(
trailing: _editing tooltip: 'Delete',
? IconButton( onPressed: () => _removeList(list),
tooltip: 'Delete', icon: const Icon(Icons.delete_outline),
onPressed: () => _removeList(list), )
icon: const Icon(Icons.delete_outline), : const Icon(Icons.chevron_right),
) onTap: _editing ? null : () => _openList(list.id),
: const Icon(Icons.chevron_right), );
onTap: _editing ? null : () => _openList(list.id), },
); ),
},
);
},
),
); );
} }
} }
+23
View File
@@ -0,0 +1,23 @@
/// Opens the profile screen from an app bar.
library;
import 'package:flutter/material.dart';
import '../profile_screen.dart';
class ProfileButton extends StatelessWidget {
const ProfileButton({super.key});
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: 'Profile',
onPressed: () {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const ProfileScreen()),
);
},
icon: const Icon(Icons.person_outline),
);
}
}
+32
View File
@@ -256,6 +256,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
google_mlkit_commons: google_mlkit_commons:
dependency: transitive dependency: transitive
description: description:
@@ -464,6 +472,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72
url: "https://pub.dev"
source: hosted
version: "0.19.2"
objective_c: objective_c:
dependency: transitive dependency: transitive
description: description:
@@ -637,6 +653,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.11" version: "2.5.11"
sqflite_common_ffi:
dependency: "direct dev"
description:
name: sqflite_common_ffi
sha256: d5564f1308cafbf064be498f2beb1e993e742985a7cca9e2e228d6cbccd84a05
url: "https://pub.dev"
source: hosted
version: "2.4.2+1"
sqflite_darwin: sqflite_darwin:
dependency: transitive dependency: transitive
description: description:
@@ -653,6 +677,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.1"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c"
url: "https://pub.dev"
source: hosted
version: "3.5.2"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -24,6 +24,7 @@ dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
sqflite_common_ffi: ^2.4.0
flutter: flutter:
uses-material-design: true uses-material-design: true
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/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('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);
});
}
+41
View File
@@ -0,0 +1,41 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:receipity/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', 'receipity_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');
}
+133 -17
View File
@@ -3,14 +3,34 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/main.dart'; import 'package:receipity/main.dart';
import 'package:receipity/models/line_item.dart'; import 'package:receipity/models/line_item.dart';
import 'package:receipity/review_screen.dart'; import 'package:receipity/review_screen.dart';
import 'package:receipity/services/product_repository.dart';
import 'package:receipity/widgets/shop_heatmap.dart'; import 'package:receipity/widgets/shop_heatmap.dart';
import 'test_helpers.dart';
void main() { void main() {
testWidgets('scan screen shows capture actions and bottom navigation', ( ensureTestSqlite();
testWidgets('home screen shows overview and bottom navigation', (
tester, tester,
) async { ) async {
await tester.pumpWidget(ReceipityApp()); await tester.pumpWidget(ReceipityApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Nothing logged yet.'));
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(ReceipityApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.byTooltip('Add'));
await tester.pump();
expect(find.text('Receipity'), findsOneWidget); expect(find.text('Receipity'), findsOneWidget);
expect(find.text('Take photo'), findsOneWidget); expect(find.text('Take photo'), findsOneWidget);
@@ -21,18 +41,111 @@ void main() {
find.textContaining('Scan a receipt or enter a trip by hand'), find.textContaining('Scan a receipt or enter a trip by hand'),
findsOneWidget, findsOneWidget,
); );
expect(find.text('Products'), findsOneWidget);
expect(find.text('Lists'), findsOneWidget);
expect(find.byTooltip('Add'), findsOneWidget);
expect(find.byIcon(Icons.add), findsOneWidget);
expect(find.text('Shop log'), findsOneWidget);
}); });
testWidgets('shop log has product search', (tester) async { testWidgets('empty shop log hides edit and disables search', (tester) async {
await tester.pumpWidget(ReceipityApp()); await tester.pumpWidget(ReceipityApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Shop log')); await tester.tap(find.text('Shop log'));
await tester.pump(); await pumpUntilFound(
tester,
find.text('Scan a receipt or enter a trip to log it.'),
);
expect(find.byTooltip('Edit'), findsNothing);
expect(find.byTooltip('Search products'), findsOneWidget); expect(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(ReceipityApp(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(ReceipityApp(repository: repository));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Products').last);
await pumpUntilFound(tester, find.text('Milk'));
await tester.tap(find.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(ReceipityApp(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 { testWidgets('shop heatmap renders the visible month', (tester) async {
@@ -59,15 +172,18 @@ void main() {
}); });
testWidgets('enter manually opens a blank trip', (tester) async { testWidgets('enter manually opens a blank trip', (tester) async {
await tester.pumpWidget(ReceipityApp()); await tester.pumpWidget(ReceipityApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.byTooltip('Add'));
await tester.pump();
await tester.tap(find.text('Enter manually')); await tester.tap(find.text('Enter manually'));
await tester.pumpAndSettle(); await pumpUntilFound(tester, find.text('New trip'));
expect(find.text('New trip'), findsOneWidget);
expect(find.text('Add item'), findsOneWidget); expect(find.text('Add item'), findsOneWidget);
}); });
testWidgets('lists tab shows shopping lists', (tester) async { testWidgets('lists tab shows shopping lists', (tester) async {
await tester.pumpWidget(ReceipityApp()); await tester.pumpWidget(ReceipityApp(repository: memoryRepository()));
await pumpUntilFound(tester, find.text('Overview'));
await tester.tap(find.text('Lists')); await tester.tap(find.text('Lists'));
await tester.pump(); await tester.pump();
expect(find.text('Shopping lists'), findsOneWidget); expect(find.text('Shopping lists'), findsOneWidget);
@@ -82,7 +198,7 @@ void main() {
LineItem(id: '2', name: 'Bread', price: 2), LineItem(id: '2', name: 'Bread', price: 2),
], ],
rawText: 'Milk 1.50\nBread 2.00', rawText: 'Milk 1.50\nBread 2.00',
repository: ProductRepository(databasePath: ':memory:'), repository: memoryRepository(),
), ),
), ),
); );
@@ -109,7 +225,7 @@ void main() {
home: ReviewScreen( home: ReviewScreen(
items: [LineItem(id: '1', name: 'Milk', price: 1.5)], items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
rawText: 'Jumbo\nMilk 1.50', rawText: 'Jumbo\nMilk 1.50',
repository: ProductRepository(databasePath: ':memory:'), repository: memoryRepository(),
), ),
), ),
); );