added categories, import export, settings

This commit is contained in:
davmar
2026-08-29 16:45:27 +02:00
parent 2a5a922aab
commit 732a0dc14a
30 changed files with 3700 additions and 167 deletions
+108
View File
@@ -0,0 +1,108 @@
/// Bottom navigation: products, scan, and shop log.
library;
import 'package:flutter/material.dart';
import 'products_screen.dart';
import 'scan_screen.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'shop_log_screen.dart';
class HomeShell extends StatefulWidget {
const HomeShell({super.key, required this.repository});
final ProductRepository repository;
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
static const _scanIndex = 1;
static const _shopLogIndex = 2;
int _index = _scanIndex;
int _revision = 0;
AppSettings? _settings;
int _seenGeneration = 0;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final settings = SettingsScope.maybeOf(context);
if (settings != _settings) {
_settings?.removeListener(_onSettings);
_settings = settings;
_seenGeneration = settings?.catalogGeneration ?? 0;
_settings?.addListener(_onSettings);
}
}
@override
void dispose() {
_settings?.removeListener(_onSettings);
super.dispose();
}
void _onSettings() {
final generation = _settings?.catalogGeneration ?? 0;
if (generation == _seenGeneration) return;
_seenGeneration = generation;
if (mounted) setState(() => _revision++);
}
void _onTripSaved() {
setState(() {
_index = _shopLogIndex;
_revision++;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Shopping trip saved.')),
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: switch (_index) {
0 => ProductsScreen(
key: ValueKey('products-$_revision'),
repository: widget.repository,
),
1 => ScanScreen(
repository: widget.repository,
onTripSaved: _onTripSaved,
),
_ => ShopLogScreen(
key: ValueKey('shops-$_revision'),
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.photo_camera_outlined),
selectedIcon: Icon(Icons.photo_camera),
label: 'Scan',
),
NavigationDestination(
icon: Icon(Icons.receipt_long_outlined),
selectedIcon: Icon(Icons.receipt_long),
label: 'Shop log',
),
],
),
);
}
}
+33 -4
View File
@@ -3,7 +3,8 @@ library;
import 'package:flutter/material.dart';
import 'scan_screen.dart';
import 'home_shell.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'utils/app_theme.dart';
import 'utils/constants.dart';
@@ -13,20 +14,48 @@ void main() {
runApp(ReceipityApp());
}
class ReceipityApp extends StatelessWidget {
class ReceipityApp extends StatefulWidget {
ReceipityApp({super.key, ProductRepository? repository})
: repository = repository ?? ProductRepository();
final ProductRepository repository;
@override
State<ReceipityApp> createState() => _ReceipityAppState();
}
class _ReceipityAppState extends State<ReceipityApp> {
late final AppSettings _settings;
@override
void initState() {
super.initState();
_settings = AppSettings(widget.repository);
_settings.addListener(_onSettings);
_settings.load();
}
@override
void dispose() {
_settings.removeListener(_onSettings);
_settings.dispose();
super.dispose();
}
void _onSettings() => setState(() {});
@override
Widget build(BuildContext context) {
return MaterialApp(
return SettingsScope(
settings: _settings,
child: MaterialApp(
title: kAppName,
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
home: ScanScreen(repository: repository),
themeMode: _settings.darkMode ? ThemeMode.dark : ThemeMode.light,
home: HomeShell(repository: widget.repository),
),
);
}
}
+48 -1
View File
@@ -8,6 +8,11 @@ class Product {
required this.lastPrice,
required this.timesSeen,
required this.updatedAt,
this.favourite = false,
this.imagePath,
this.supermarket,
this.notes,
this.category,
});
final int id;
@@ -15,14 +20,56 @@ class Product {
final double lastPrice;
final int timesSeen;
final DateTime updatedAt;
final bool favourite;
final String? imagePath;
final String? supermarket;
final String? notes;
final String? category;
factory Product.fromMap(Map<String, Object?> map) {
return Product(
id: map['id']! as int,
name: map['name']! as String,
lastPrice: (map['last_price']! as num).toDouble(),
lastPrice: (map['display_price'] as num? ?? map['last_price']! as num)
.toDouble(),
timesSeen: map['times_seen']! as int,
updatedAt: DateTime.parse(map['updated_at']! as String),
favourite: (map['favourite'] as int? ?? 0) == 1,
imagePath: map['image_path'] as String?,
supermarket: map['supermarket'] as String?,
notes: map['notes'] as String?,
category: map['category'] as String?,
);
}
Product copyWith({
String? name,
double? lastPrice,
int? timesSeen,
DateTime? updatedAt,
bool? favourite,
String? imagePath,
String? supermarket,
String? notes,
String? category,
bool clearImage = false,
bool clearSupermarket = false,
bool clearNotes = false,
bool clearCategory = false,
}) {
return Product(
id: id,
name: name ?? this.name,
lastPrice: lastPrice ?? this.lastPrice,
timesSeen: timesSeen ?? this.timesSeen,
updatedAt: updatedAt ?? this.updatedAt,
favourite: favourite ?? this.favourite,
imagePath: clearImage ? null : (imagePath ?? this.imagePath),
supermarket: clearSupermarket ? null : (supermarket ?? this.supermarket),
notes: clearNotes ? null : (notes ?? this.notes),
category: clearCategory ? null : (category ?? this.category),
);
}
}
enum ProductSort { name, preferred }
+36
View File
@@ -0,0 +1,36 @@
/// One time a product was bought, used for price and purchase history.
library;
class Purchase {
const Purchase({
required this.shopId,
required this.boughtAt,
required this.supermarket,
required this.price,
this.quantity,
this.unitPrice,
this.receiptImagePath,
});
final int shopId;
final DateTime boughtAt;
final String supermarket;
final double price;
final int? quantity;
final double? unitPrice;
final String? receiptImagePath;
double get unitOrLinePrice => unitPrice ?? price;
factory Purchase.fromMap(Map<String, Object?> map) {
return Purchase(
shopId: map['shop_id']! as int,
boughtAt: DateTime.parse(map['shopped_at']! as String),
supermarket: map['supermarket']! as String,
price: (map['price']! as num).toDouble(),
quantity: map['quantity'] as int?,
unitPrice: (map['unit_price'] as num?)?.toDouble(),
receiptImagePath: map['receipt_image_path'] as String?,
);
}
}
+63
View File
@@ -0,0 +1,63 @@
/// A confirmed shopping trip saved from a receipt.
library;
class Shop {
const Shop({
required this.id,
required this.supermarket,
required this.shoppedAt,
required this.total,
required this.itemCount,
this.receiptImagePath,
});
final int id;
final String supermarket;
final DateTime shoppedAt;
final double total;
final int itemCount;
final String? receiptImagePath;
factory Shop.fromMap(Map<String, Object?> map) {
return Shop(
id: map['id']! as int,
supermarket: map['supermarket']! as String,
shoppedAt: DateTime.parse(map['shopped_at']! as String),
total: (map['total']! as num).toDouble(),
itemCount: (map['item_count'] as num?)?.toInt() ?? 0,
receiptImagePath: map['receipt_image_path'] as String?,
);
}
}
class ShopItem {
const ShopItem({
required this.id,
required this.shopId,
required this.productId,
required this.name,
required this.price,
this.quantity,
this.unitPrice,
});
final int id;
final int shopId;
final int productId;
final String name;
final double price;
final int? quantity;
final double? unitPrice;
factory ShopItem.fromMap(Map<String, Object?> map) {
return ShopItem(
id: map['id']! as int,
shopId: map['shop_id']! as int,
productId: map['product_id']! as int,
name: map['name']! as String,
price: (map['price']! as num).toDouble(),
quantity: map['quantity'] as int?,
unitPrice: (map['unit_price'] as num?)?.toDouble(),
);
}
}
+59
View File
@@ -0,0 +1,59 @@
/// A user-defined supermarket with a display color.
library;
import 'package:flutter/material.dart';
class Supermarket {
const Supermarket({
this.id,
required this.name,
required this.colorValue,
this.sortOrder = 0,
});
final int? id;
final String name;
final int colorValue;
final int sortOrder;
Color get color => Color(colorValue);
factory Supermarket.fromMap(Map<String, Object?> map) {
return Supermarket(
id: map['id'] as int?,
name: map['name']! as String,
colorValue: map['color']! as int,
sortOrder: map['sort_order'] as int? ?? 0,
);
}
Supermarket copyWith({
int? id,
String? name,
int? colorValue,
int? sortOrder,
}) {
return Supermarket(
id: id ?? this.id,
name: name ?? this.name,
colorValue: colorValue ?? this.colorValue,
sortOrder: sortOrder ?? this.sortOrder,
);
}
}
/// Palette offered when the user picks a store color.
const List<int> kStoreColorPalette = [
0xFFEEC21B, // Jumbo yellow
0xFF0050AA, // Lidl blue
0xFF00A0E2, // AH blue
0xFF00205B, // Aldi navy
0xFF6EC31E, // Plus green
0xFFE30613, // Dirk / Coop red
0xFF009640, // SPAR green
0xFFE87722, // orange
0xFFE31C5F, // Picnic pink
0xFF7B1FA2, // purple
0xFF00897B, // teal
0xFF78909C, // grey
];
+487
View File
@@ -0,0 +1,487 @@
/// Product: photo, name, favourite, price history chart, and notes.
library;
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'models/product.dart';
import 'models/purchase.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'shop_detail_screen.dart';
import 'utils/categories.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'utils/supermarkets.dart';
import 'widgets/price_chart.dart';
import 'widgets/product_image.dart';
import 'widgets/store_tag.dart';
class ProductDetailScreen extends StatefulWidget {
const ProductDetailScreen({
super.key,
required this.repository,
required this.productId,
});
final ProductRepository repository;
final int productId;
@override
State<ProductDetailScreen> createState() => _ProductDetailScreenState();
}
class _ProductDetailScreenState extends State<ProductDetailScreen> {
late Future<({Product product, List<Purchase> purchases})> _future;
late final TextEditingController _nameController;
late final TextEditingController _notesController;
final ImagePicker _picker = ImagePicker();
bool _ready = false;
@override
void initState() {
super.initState();
_nameController = TextEditingController();
_notesController = TextEditingController();
_future = _load();
}
@override
void dispose() {
_saveNotes();
_nameController.dispose();
_notesController.dispose();
super.dispose();
}
Future<({Product product, List<Purchase> purchases})> _load() async {
final product = await widget.repository.getProduct(widget.productId);
if (product == null) {
throw StateError('Product not found');
}
final purchases = await widget.repository.getPurchases(widget.productId);
if (!_ready) {
_nameController.text = product.name;
_notesController.text = product.notes ?? '';
_ready = true;
}
return (product: product, purchases: purchases);
}
void _reload() {
setState(() => _future = _load());
}
Future<void> _saveName() async {
final name = _nameController.text.trim();
if (name.isEmpty) return;
await widget.repository.updateProduct(id: widget.productId, name: name);
if (mounted) _reload();
}
Future<void> _saveNotes() async {
await widget.repository.updateProduct(
id: widget.productId,
notes: _notesController.text.trim(),
);
}
Future<void> _toggleFavourite(Product product) async {
await widget.repository.setFavourite(product.id, !product.favourite);
if (mounted) _reload();
}
Future<void> _setSupermarket(String? value) async {
if (value == null) return;
await widget.repository.updateProduct(
id: widget.productId,
supermarket: value,
);
if (mounted) _reload();
}
Future<void> _pickImage(ImageSource source) async {
final file = await _picker.pickImage(source: source, imageQuality: 85);
if (file == null) return;
await widget.repository.setProductImage(widget.productId, file.path);
if (mounted) _reload();
}
Future<void> _chooseImageSource() 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) return;
await _pickImage(source);
}
Future<void> _pickStore() async {
final settings = SettingsScope.maybeOf(context);
final stores = settings?.supermarkets ?? kDefaultSupermarkets;
final chosen = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: ListView(
shrinkWrap: true,
children: [
for (final store in stores)
ListTile(
leading: StoreTag(name: store.name, stores: stores),
title: Text(store.name),
onTap: () => Navigator.pop(context, store.name),
),
],
),
);
},
);
if (chosen != null) await _setSupermarket(chosen);
}
Future<void> _setCategory(String? value, {bool clear = false}) async {
await widget.repository.updateProduct(
id: widget.productId,
category: clear ? null : value,
clearCategory: clear,
);
if (mounted) _reload();
}
Future<void> _pickCategory(Product product) async {
final used = await widget.repository.getUsedCategories();
if (!mounted) return;
final categories = mergeCategories([
...used,
if (product.category != null) product.category!,
]);
final chosen = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) {
return SafeArea(
child: ListView(
shrinkWrap: true,
children: [
ListTile(
leading: const Icon(Icons.clear),
title: const Text('No category'),
onTap: () => Navigator.pop(context, ''),
),
for (final name in categories)
ListTile(
leading: Icon(
name == product.category
? Icons.check_circle
: Icons.category_outlined,
),
title: Text(name),
onTap: () => Navigator.pop(context, name),
),
ListTile(
leading: const Icon(Icons.add),
title: const Text('Custom category'),
onTap: () => Navigator.pop(context, '__custom__'),
),
],
),
);
},
);
if (!mounted || chosen == null) return;
if (chosen.isEmpty) {
await _setCategory(null, clear: true);
return;
}
if (chosen == '__custom__') {
final custom = await _askCustomCategory(product.category);
if (custom == null || !mounted) return;
if (custom.isEmpty) {
await _setCategory(null, clear: true);
} else {
await _setCategory(custom);
}
return;
}
await _setCategory(chosen);
}
Future<String?> _askCustomCategory(String? current) async {
return showDialog<String>(
context: context,
builder: (context) => _CategoryNameDialog(initial: current),
);
}
Future<void> _openShop(int shopId) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => ShopDetailScreen(
repository: widget.repository,
shopId: shopId,
),
),
);
if (mounted) _reload();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
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());
}
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,
),
),
),
),
],
),
),
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,
),
),
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(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(),
),
],
);
},
),
);
}
}
class _CategoryNameDialog extends StatefulWidget {
const _CategoryNameDialog({this.initial});
final String? initial;
@override
State<_CategoryNameDialog> createState() => _CategoryNameDialogState();
}
class _CategoryNameDialogState extends State<_CategoryNameDialog> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initial ?? '');
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _save() => Navigator.pop(context, _controller.text.trim());
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Category'),
content: TextField(
controller: _controller,
textCapitalization: TextCapitalization.sentences,
autofocus: true,
decoration: const InputDecoration(labelText: 'Category'),
onSubmitted: (_) => _save(),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _save,
child: const Text('Save'),
),
],
);
}
}
+183 -29
View File
@@ -1,57 +1,170 @@
/// Confirmed products stored on device.
/// Catalog of products from confirmed receipts.
library;
import 'package:flutter/material.dart';
import 'models/product.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 'widgets/product_image.dart';
import 'widgets/settings_button.dart';
import 'widgets/store_tag.dart';
class ProductsScreen extends StatefulWidget {
const ProductsScreen({
super.key,
required this.repository,
this.savedCount,
});
const ProductsScreen({super.key, required this.repository});
final ProductRepository repository;
final int? savedCount;
@override
State<ProductsScreen> createState() => _ProductsScreenState();
}
class _ProductsScreenState extends State<ProductsScreen> {
ProductSort _sort = ProductSort.preferred;
bool _favouritesOnly = false;
bool _searching = false;
final TextEditingController _search = TextEditingController();
late Future<List<Product>> _future;
@override
void initState() {
super.initState();
_future = widget.repository.getAll();
final saved = widget.savedCount;
if (saved != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
saved == 1
? 'Saved 1 product.'
: 'Saved $saved products.',
_future = _load();
_search.addListener(() => setState(() {}));
}
@override
void dispose() {
_search.dispose();
super.dispose();
}
Future<List<Product>> _load() {
return widget.repository.getAll(
sort: _sort,
favouritesOnly: _favouritesOnly,
);
}
void _reload() {
setState(() => _future = _load());
}
List<Product> _filter(List<Product> products) {
final query = _search.text.trim();
if (query.isEmpty) return products;
final ranked = <({Product product, int score})>[];
for (final product in products) {
final haystack = [
product.name,
if (product.supermarket != null) product.supermarket!,
if (product.category != null) product.category!,
if (product.notes != null) product.notes!,
].join(' ');
final score = fuzzyScore(query, haystack);
if (score != null) {
ranked.add((product: product, score: score));
}
}
ranked.sort((a, b) => b.score.compareTo(a.score));
return [for (final row in ranked) row.product];
}
Future<void> _openProduct(Product product) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => ProductDetailScreen(
repository: widget.repository,
productId: product.id,
),
),
);
});
}
if (mounted) _reload();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: FutureBuilder<List<Product>>(
appBar: AppBar(
title: _searching
? TextField(
controller: _search,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search products',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
),
)
: 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(),
],
),
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('AZ'),
),
],
selected: {_sort},
onSelectionChanged: (selected) {
setState(() {
_sort = selected.first;
_future = _load();
});
},
),
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: 'Favourites',
isSelected: _favouritesOnly,
onPressed: () {
setState(() {
_favouritesOnly = !_favouritesOnly;
_future = _load();
});
},
icon: Icon(
_favouritesOnly ? Icons.star : Icons.star_outline,
),
),
],
),
),
Expanded(
child: FutureBuilder<List<Product>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
@@ -66,11 +179,22 @@ class _ProductsScreenState extends State<ProductsScreen> {
return const Center(child: CircularProgressIndicator());
}
final products = snapshot.data!;
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(
'Confirmed receipt items will show up here.',
'No products match this search.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
@@ -78,24 +202,54 @@ class _ProductsScreenState extends State<ProductsScreen> {
}
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.fromLTRB(8, 0, 8, 16),
itemCount: products.length,
separatorBuilder: (_, _) => const Divider(indent: 16, endIndent: 16),
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final product = products[index];
final seen = product.timesSeen;
return ListTile(
title: Text(product.name),
subtitle: Text(seen == 1 ? 'Seen once' : 'Seen $seen times'),
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),
);
},
);
},
),
),
],
),
);
}
}
+124 -14
View File
@@ -1,12 +1,15 @@
/// Editable list of parsed receipt lines before they are saved as products.
/// Editable list of parsed receipt lines before they are saved as a shop trip.
library;
import 'package:flutter/material.dart';
import 'models/line_item.dart';
import 'products_screen.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'utils/supermarkets.dart';
import 'widgets/store_tag.dart';
class ReviewScreen extends StatefulWidget {
const ReviewScreen({
@@ -14,11 +17,15 @@ class ReviewScreen extends StatefulWidget {
required this.items,
required this.rawText,
required this.repository,
this.imagePath,
this.supermarketNames = kSupermarkets,
});
final List<LineItem> items;
final String rawText;
final ProductRepository repository;
final String? imagePath;
final List<String> supermarketNames;
@override
State<ReviewScreen> createState() => _ReviewScreenState();
@@ -26,12 +33,33 @@ class ReviewScreen extends StatefulWidget {
class _ReviewScreenState extends State<ReviewScreen> {
late List<LineItem> _items;
late DateTime _shoppedAt;
String? _supermarket;
String? _customStore;
bool _saving = false;
@override
void initState() {
super.initState();
_items = List<LineItem>.from(widget.items);
_shoppedAt = DateTime.now();
final detected = detectSupermarket(
widget.rawText,
names: widget.supermarketNames,
);
if (detected != null) {
_supermarket = detected;
}
}
String? get _resolvedSupermarket {
if (_supermarket == null) return null;
if (_supermarket == kOtherSupermarket) {
final custom = _customStore?.trim();
if (custom == null || custom.isEmpty) return null;
return custom;
}
return _supermarket;
}
Future<void> _edit({LineItem? existing}) async {
@@ -55,25 +83,47 @@ class _ReviewScreenState extends State<ReviewScreen> {
});
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _shoppedAt,
firstDate: DateTime(2018),
lastDate: DateTime.now().add(const Duration(days: 1)),
);
if (picked == null || !mounted) return;
setState(() => _shoppedAt = picked);
}
Future<void> _confirm() async {
final supermarket = _resolvedSupermarket;
if (supermarket == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Select a supermarket.')),
);
return;
}
if (_items.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Add at least one product.')),
);
return;
}
setState(() => _saving = true);
try {
await widget.repository.upsertItems(_items);
if (!mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute<void>(
builder: (_) => ProductsScreen(
repository: widget.repository,
savedCount: _items.length,
),
),
(route) => route.isFirst,
await widget.repository.saveShop(
supermarket: supermarket,
shoppedAt: _shoppedAt,
items: _items,
receiptImagePath: widget.imagePath,
);
if (!mounted) return;
Navigator.of(context).pop(true);
} catch (_) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not save products.')),
const SnackBar(content: Text('Could not save this shopping trip.')),
);
}
}
@@ -81,11 +131,69 @@ class _ReviewScreenState extends State<ReviewScreen> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final total = _items.fold<double>(0, (sum, item) => sum + item.price);
final settings = SettingsScope.maybeOf(context);
final stores = settings?.supermarkets ?? kDefaultSupermarkets;
final names = [
...{...widget.supermarketNames, ...stores.map((s) => s.name)},
];
if (_supermarket != null && !names.contains(_supermarket)) {
names.add(_supermarket!);
}
return Scaffold(
appBar: AppBar(title: const Text('Review items')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
key: ValueKey(_supermarket),
initialValue: _supermarket,
hint: const Text('Select supermarket'),
decoration: const InputDecoration(
labelText: 'Supermarket',
),
items: [
for (final store in names)
DropdownMenuItem(
value: store,
child: StoreTag(
name: store,
stores: stores,
compact: true,
),
),
],
onChanged: _saving
? null
: (value) => setState(() => _supermarket = value),
),
if (_supermarket == kOtherSupermarket) ...[
const SizedBox(height: 12),
TextField(
enabled: !_saving,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Store name',
),
onChanged: (value) => _customStore = value,
),
],
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.calendar_today_outlined),
title: Text(formatDate(_shoppedAt)),
subtitle: const Text('Shopping date'),
onTap: _saving ? null : _pickDate,
),
],
),
),
Expanded(
child: _items.isEmpty
? Center(
@@ -166,7 +274,9 @@ class _ReviewScreenState extends State<ReviewScreen> {
FilledButton(
onPressed: _saving ? null : _confirm,
child: Text(
_saving ? 'Saving…' : 'Confirm',
_saving
? 'Saving…'
: 'Save trip · ${formatEuro(total)}',
),
),
],
+23 -20
View File
@@ -1,4 +1,4 @@
/// Single screen: capture or pick a receipt photo, then show raw OCR text.
/// Capture or pick a receipt photo, then review parsed line items.
library;
import 'dart:io';
@@ -8,16 +8,23 @@ import 'package:flutter/services.dart';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import 'package:image_picker/image_picker.dart';
import 'products_screen.dart';
import 'review_screen.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'services/receipt_parser.dart';
import 'utils/constants.dart';
import 'utils/supermarkets.dart';
import 'widgets/settings_button.dart';
class ScanScreen extends StatefulWidget {
const ScanScreen({super.key, required this.repository});
const ScanScreen({
super.key,
required this.repository,
this.onTripSaved,
});
final ProductRepository repository;
final VoidCallback? onTripSaved;
@override
State<ScanScreen> createState() => _ScanScreenState();
@@ -27,6 +34,7 @@ class _ScanScreenState extends State<ScanScreen> {
final ImagePicker _picker = ImagePicker();
String _extractedText = '';
String? _imagePath;
bool _busy = false;
String? _error;
@@ -81,6 +89,7 @@ class _ScanScreenState extends State<ScanScreen> {
_busy = true;
_error = null;
_extractedText = '';
_imagePath = imagePath;
});
final recognizer = TextRecognizer(script: TextRecognitionScript.latin);
@@ -114,23 +123,23 @@ class _ScanScreenState extends State<ScanScreen> {
Future<void> _openReview(String rawText) async {
final items = ReceiptParser.parse(rawText);
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
final names =
SettingsScope.maybeOf(context)?.supermarkets.map((s) => s.name) ??
kSupermarkets;
final saved = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => ReviewScreen(
items: items,
rawText: rawText,
repository: widget.repository,
imagePath: _imagePath,
supermarketNames: names.toList(),
),
),
);
if (saved == true && mounted) {
widget.onTripSaved?.call();
}
void _openProducts() {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ProductsScreen(repository: widget.repository),
),
);
}
@override
@@ -140,13 +149,7 @@ class _ScanScreenState extends State<ScanScreen> {
return Scaffold(
appBar: AppBar(
title: const Text(kAppName),
actions: [
IconButton(
tooltip: 'Products',
onPressed: _openProducts,
icon: const Icon(Icons.inventory_2_outlined),
),
],
actions: const [SettingsButton()],
),
body: SafeArea(
child: Padding(
@@ -220,7 +223,7 @@ class _ResultPane extends StatelessWidget {
if (text.isEmpty) {
return Center(
child: Text(
'Take a photo of a shopping receipt, or pick one from the gallery.\n\nExtracted text will show up here.',
'Take a photo of a shopping receipt, or pick one from the gallery.\n\nConfirmed trips show up in Shop log.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
+89
View File
@@ -0,0 +1,89 @@
/// App-wide settings: dark mode and the user supermarket list.
library;
import 'package:flutter/material.dart';
import '../models/supermarket.dart';
import '../utils/supermarkets.dart';
import 'product_repository.dart';
class AppSettings extends ChangeNotifier {
AppSettings(this.repository);
final ProductRepository repository;
bool darkMode = false;
List<Supermarket> supermarkets = List<Supermarket>.from(kDefaultSupermarkets);
int catalogGeneration = 0;
Future<void> load() async {
try {
darkMode = await repository.getDarkMode();
supermarkets = await repository.getSupermarkets();
notifyListeners();
} catch (_) {
// Widget tests and first-run without a plugin keep the defaults.
}
}
Future<void> setDarkMode(bool enabled) async {
darkMode = enabled;
notifyListeners();
try {
await repository.setDarkMode(enabled);
} catch (_) {}
}
Future<void> addSupermarket(String name, int colorValue) async {
final trimmed = name.trim();
if (trimmed.isEmpty) return;
final store = await repository.addSupermarket(
name: trimmed,
colorValue: colorValue,
);
supermarkets = [...supermarkets, store];
notifyListeners();
}
Future<void> updateSupermarket(Supermarket store) async {
await repository.updateSupermarket(store);
supermarkets = [
for (final item in supermarkets)
if (item.id == store.id) store else item,
];
notifyListeners();
}
Future<void> deleteSupermarket(int id) async {
await repository.deleteSupermarket(id);
supermarkets = [
for (final item in supermarkets)
if (item.id != id) item,
];
notifyListeners();
}
Future<void> reload() async {
await load();
catalogGeneration++;
notifyListeners();
}
}
class SettingsScope extends InheritedNotifier<AppSettings> {
const SettingsScope({
super.key,
required AppSettings settings,
required super.child,
}) : super(notifier: settings);
static AppSettings of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<SettingsScope>();
assert(scope != null, 'SettingsScope not found');
return scope!.notifier!;
}
static AppSettings? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<SettingsScope>()?.notifier;
}
}
+99
View File
@@ -0,0 +1,99 @@
/// JSON backup document: products, trips, settings, and embedded photos.
library;
import 'dart:convert';
const int kBackupVersion = 1;
class BackupDocument {
const BackupDocument({
required this.version,
required this.exportedAt,
required this.darkMode,
required this.supermarkets,
required this.products,
required this.shops,
required this.shopItems,
required this.images,
});
final int version;
final String exportedAt;
final bool darkMode;
final List<Map<String, Object?>> supermarkets;
final List<Map<String, Object?>> products;
final List<Map<String, Object?>> shops;
final List<Map<String, Object?>> shopItems;
final Map<String, String> images;
Map<String, Object?> toJson() => {
'format': 'receipity-backup',
'version': version,
'exportedAt': exportedAt,
'darkMode': darkMode,
'supermarkets': supermarkets,
'products': products,
'shops': shops,
'shopItems': shopItems,
'images': images,
};
String encode() => const JsonEncoder.withIndent(' ').convert(toJson());
static BackupDocument parse(String json) {
final decoded = jsonDecode(json);
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 version = decoded['version'];
if (version is! int || version < 1 || version > kBackupVersion) {
throw const FormatException('Unsupported backup version.');
}
return BackupDocument(
version: version,
exportedAt: decoded['exportedAt'] as String? ?? '',
darkMode: decoded['darkMode'] == true,
supermarkets: _maps(decoded['supermarkets']),
products: _maps(decoded['products']),
shops: _maps(decoded['shops']),
shopItems: _maps(decoded['shopItems']),
images: _strings(decoded['images']),
);
}
static List<Map<String, Object?>> _maps(Object? value) {
if (value == null) return [];
if (value is! List) {
throw const FormatException('Backup tables must be lists.');
}
return [
for (final item in value)
if (item is Map)
Map<String, Object?>.from(
item.map((key, val) => MapEntry(key.toString(), val)),
),
];
}
static Map<String, String> _strings(Object? value) {
if (value == null) return {};
if (value is! Map) return {};
return {
for (final entry in value.entries)
if (entry.value is String) entry.key.toString(): entry.value as String,
};
}
}
String? backupImageKey(String? absolutePath) {
if (absolutePath == null || absolutePath.isEmpty) return null;
final normalized = absolutePath.replaceAll('\\', '/');
final products = normalized.split('/products/');
if (products.length == 2) return 'products/${products.last}';
final receipts = normalized.split('/receipts/');
if (receipts.length == 2) return 'receipts/${receipts.last}';
return null;
}
+161
View File
@@ -0,0 +1,161 @@
/// Build, share, and restore a Receipity JSON backup.
library;
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'backup_format.dart';
import 'product_repository.dart';
class BackupService {
BackupService(this.repository);
final ProductRepository repository;
Future<BackupDocument> buildBackup() async {
final products = await repository.dumpTable('products');
final shops = await repository.dumpTable('shops');
final images = <String, String>{};
Future<void> addImage(String? path) async {
final key = backupImageKey(path);
if (key == null || images.containsKey(key)) return;
final file = File(path!);
if (!file.existsSync()) return;
images[key] = base64Encode(await file.readAsBytes());
}
final rewrittenProducts = <Map<String, Object?>>[];
for (final row in products) {
final copy = Map<String, Object?>.from(row);
final path = copy['image_path'] as String?;
await addImage(path);
copy['image_path'] = backupImageKey(path);
rewrittenProducts.add(copy);
}
final rewrittenShops = <Map<String, Object?>>[];
for (final row in shops) {
final copy = Map<String, Object?>.from(row);
final path = copy['receipt_image_path'] as String?;
await addImage(path);
copy['receipt_image_path'] = backupImageKey(path);
rewrittenShops.add(copy);
}
return BackupDocument(
version: kBackupVersion,
exportedAt: DateTime.now().toIso8601String(),
darkMode: await repository.getDarkMode(),
supermarkets: await repository.dumpTable('supermarkets'),
products: rewrittenProducts,
shops: rewrittenShops,
shopItems: await repository.dumpTable('shop_items'),
images: images,
);
}
Future<bool> exportBackup() async {
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';
try {
final saved = await FilePicker.saveFile(
dialogTitle: 'Save Receipity backup',
fileName: fileName,
bytes: bytes,
mimeType: 'application/json',
type: FileType.custom,
allowedExtensions: const ['json'],
);
return saved != null;
} catch (_) {
// Fall through to the share sheet if save-as is unavailable.
}
final dir = await getTemporaryDirectory();
final file = File(p.join(dir.path, fileName));
await file.writeAsBytes(bytes, flush: true);
final result = await SharePlus.instance.share(
ShareParams(
files: [XFile(file.path, mimeType: 'application/json')],
subject: 'Receipity backup',
text: 'Receipity backup $stamp',
),
);
return result.status != ShareResultStatus.dismissed;
}
Future<bool> importBackup() async {
final picked = await FilePicker.pickFile(
dialogTitle: 'Import Receipity backup',
type: FileType.custom,
allowedExtensions: const ['json'],
);
if (picked == null) return false;
final bytes = await picked.readAsBytes();
await restoreBackup(utf8.decode(bytes));
return true;
}
Future<void> restoreBackup(String json) async {
final document = BackupDocument.parse(json);
await repository.clearImageFolders();
final products = <Map<String, Object?>>[];
final now = DateTime.now().toIso8601String();
for (final row in document.products) {
final copy = Map<String, Object?>.from(row);
copy['image_path'] = await _restoreImage(copy['image_path'], document.images);
copy['created_at'] ??= now;
copy['updated_at'] ??= copy['created_at'];
if (copy['normalized_name'] == null && copy['name'] is String) {
copy['normalized_name'] = ProductRepository.normalizeName(
copy['name']! as String,
);
}
products.add(copy);
}
final shops = <Map<String, Object?>>[];
for (final row in document.shops) {
final copy = Map<String, Object?>.from(row);
copy['receipt_image_path'] = await _restoreImage(
copy['receipt_image_path'],
document.images,
);
copy['created_at'] ??= now;
shops.add(copy);
}
await repository.replaceAllData(
supermarkets: document.supermarkets,
products: products,
shops: shops,
shopItems: document.shopItems,
darkMode: document.darkMode,
);
}
Future<String?> _restoreImage(
Object? key,
Map<String, String> images,
) async {
if (key is! String || key.isEmpty) return null;
final encoded = images[key];
if (encoded == null) return null;
try {
return await repository.writeImageBytes(key, base64Decode(encoded));
} catch (_) {
return null;
}
}
}
+621 -24
View File
@@ -1,14 +1,24 @@
/// Local SQLite store of confirmed products.
/// Local SQLite store of products and shopping trips.
library;
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import '../models/line_item.dart';
import '../models/product.dart';
import '../models/purchase.dart';
import '../models/shop.dart';
import '../models/supermarket.dart';
import '../utils/money.dart';
import '../utils/supermarkets.dart';
class ProductRepository {
ProductRepository({this.databasePath});
final String? databasePath;
Database? _db;
Future<Database> get _database async {
@@ -17,11 +27,39 @@ class ProductRepository {
}
Future<Database> _open() async {
final dir = await getApplicationDocumentsDirectory();
final path = databasePath ??
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
return openDatabase(
p.join(dir.path, 'receipity.db'),
version: 1,
path,
version: 4,
onCreate: (db, version) async {
await _createProducts(db);
await _createShopTables(db);
await _createSettingsTables(db);
await _seedSupermarkets(db);
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
await db.execute(
'ALTER TABLE products ADD COLUMN favourite INTEGER NOT NULL DEFAULT 0',
);
await db.execute('ALTER TABLE products ADD COLUMN image_path TEXT');
await db.execute('ALTER TABLE products ADD COLUMN supermarket TEXT');
await _createShopTables(db);
}
if (oldVersion < 3) {
await db.execute('ALTER TABLE products ADD COLUMN notes TEXT');
await _createSettingsTables(db);
await _seedSupermarkets(db);
}
if (oldVersion < 4) {
await db.execute('ALTER TABLE products ADD COLUMN category TEXT');
}
},
);
}
Future<void> _createProducts(Database db) async {
await db.execute('''
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -29,26 +67,153 @@ class ProductRepository {
normalized_name TEXT NOT NULL UNIQUE,
last_price REAL NOT NULL,
times_seen INTEGER NOT NULL DEFAULT 1,
favourite INTEGER NOT NULL DEFAULT 0,
image_path TEXT,
supermarket TEXT,
notes TEXT,
category TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
},
);
}
Future<void> _createSettingsTables(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE IF NOT EXISTS supermarkets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color INTEGER NOT NULL,
sort_order INTEGER NOT NULL
)
''');
}
Future<void> _seedSupermarkets(DatabaseExecutor db) async {
final existing = await db.query('supermarkets', limit: 1);
if (existing.isNotEmpty) return;
for (final store in kDefaultSupermarkets) {
await db.insert('supermarkets', {
'name': store.name,
'color': store.colorValue,
'sort_order': store.sortOrder,
});
}
}
Future<void> _createShopTables(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE shops (
id INTEGER PRIMARY KEY AUTOINCREMENT,
supermarket TEXT NOT NULL,
shopped_at TEXT NOT NULL,
receipt_image_path TEXT,
total REAL NOT NULL,
created_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE shop_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
shop_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
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)
)
''');
}
static String normalizeName(String name) =>
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
Future<void> upsertItems(List<LineItem> items) async {
Future<String> _persistImage(String sourcePath, String relativePath) async {
final dir = await getApplicationDocumentsDirectory();
final dest = File(p.join(dir.path, relativePath));
await dest.parent.create(recursive: true);
if (p.normalize(sourcePath) == p.normalize(dest.path)) {
return dest.path;
}
await File(sourcePath).copy(dest.path);
return dest.path;
}
/// Saves a shopping trip and upserts each line into the product catalog.
Future<int> saveShop({
required String supermarket,
required DateTime shoppedAt,
required List<LineItem> items,
String? receiptImagePath,
}) async {
final db = await _database;
final now = DateTime.now().toIso8601String();
final total = roundMoney(
items.fold<double>(0, (sum, item) => sum + item.price),
);
final shopId = await db.transaction((txn) async {
final id = await txn.insert('shops', {
'supermarket': supermarket,
'shopped_at': shoppedAt.toIso8601String(),
'total': total,
'created_at': now,
});
await db.transaction((txn) async {
for (final item in items) {
final key = normalizeName(item.name);
if (key.isEmpty) continue;
final productId = await _upsertProduct(
txn,
item: item,
supermarket: supermarket,
now: now,
);
await txn.insert('shop_items', {
'shop_id': id,
'product_id': productId,
'name': item.name.trim(),
'price': item.price,
'quantity': item.quantity,
'unit_price': item.unitPrice,
});
}
return id;
});
if (receiptImagePath != null && receiptImagePath.isNotEmpty) {
try {
final stored = await _persistImage(
receiptImagePath,
'receipts/shop_$shopId.jpg',
);
await db.update(
'shops',
{'receipt_image_path': stored},
where: 'id = ?',
whereArgs: [shopId],
);
} catch (_) {
// Trip is still saved if the photo cannot be copied.
}
}
return shopId;
}
Future<int> _upsertProduct(
Transaction txn, {
required LineItem item,
required String supermarket,
required String now,
}) async {
final key = normalizeName(item.name);
final existing = await txn.query(
'products',
where: 'normalized_name = ?',
@@ -57,37 +222,469 @@ class ProductRepository {
);
if (existing.isEmpty) {
await txn.insert('products', {
return txn.insert('products', {
'name': item.name.trim(),
'normalized_name': key,
'last_price': item.price,
'last_price': item.unitPrice ?? item.price,
'times_seen': 1,
'favourite': 0,
'supermarket': supermarket,
'created_at': now,
'updated_at': now,
});
} else {
}
final row = existing.first;
final values = <String, Object?>{
'last_price': item.unitPrice ?? item.price,
'times_seen': (row['times_seen']! as int) + 1,
'updated_at': now,
};
if ((row['supermarket'] as String?) == null ||
(row['supermarket'] as String).isEmpty) {
values['supermarket'] = supermarket;
}
await txn.update(
'products',
values,
where: 'id = ?',
whereArgs: [row['id']],
);
return row['id']! as int;
}
/// Kept for tests and older callers; saves items without a shop trip.
Future<void> upsertItems(List<LineItem> items) async {
await saveShop(
supermarket: 'Other',
shoppedAt: DateTime.now(),
items: items,
);
}
Future<List<Product>> getAll({
ProductSort sort = ProductSort.name,
bool favouritesOnly = false,
}) async {
final db = await _database;
final orderBy = switch (sort) {
ProductSort.name => 'name COLLATE NOCASE ASC',
ProductSort.preferred => 'times_seen DESC, name COLLATE NOCASE ASC',
};
final rows = await db.rawQuery('''
SELECT
products.*,
(
SELECT COALESCE(shop_items.unit_price, shop_items.price)
FROM shop_items
JOIN shops ON shops.id = shop_items.shop_id
WHERE shop_items.product_id = products.id
ORDER BY shops.shopped_at DESC, shop_items.id DESC
LIMIT 1
) AS display_price
FROM products
${favouritesOnly ? 'WHERE favourite = 1' : ''}
ORDER BY $orderBy
''');
return rows.map(Product.fromMap).toList();
}
Future<Product?> getProduct(int id) async {
final db = await _database;
final rows = await db.rawQuery(
'''
SELECT
products.*,
(
SELECT COALESCE(shop_items.unit_price, shop_items.price)
FROM shop_items
JOIN shops ON shops.id = shop_items.shop_id
WHERE shop_items.product_id = products.id
ORDER BY shops.shopped_at DESC, shop_items.id DESC
LIMIT 1
) AS display_price
FROM products
WHERE products.id = ?
LIMIT 1
''',
[id],
);
if (rows.isEmpty) return null;
return Product.fromMap(rows.first);
}
Future<List<String>> getUsedCategories() async {
final db = await _database;
final rows = await db.rawQuery(
'''
SELECT DISTINCT category FROM products
WHERE category IS NOT NULL AND TRIM(category) != ''
ORDER BY category COLLATE NOCASE ASC
''',
);
return [
for (final row in rows)
if (row['category'] is String) row['category']! as String,
];
}
Future<void> updateProduct({
required int id,
String? name,
bool? favourite,
String? supermarket,
String? imagePath,
String? notes,
String? category,
bool clearImage = false,
bool clearSupermarket = false,
bool clearNotes = false,
bool clearCategory = false,
}) async {
final db = await _database;
final values = <String, Object?>{
'updated_at': DateTime.now().toIso8601String(),
};
if (name != null) {
values['name'] = name.trim();
values['normalized_name'] = normalizeName(name);
}
if (favourite != null) values['favourite'] = favourite ? 1 : 0;
if (clearSupermarket) {
values['supermarket'] = null;
} else if (supermarket != null) {
values['supermarket'] = supermarket;
}
if (clearImage) {
values['image_path'] = null;
} else if (imagePath != null) {
values['image_path'] = imagePath;
}
if (clearNotes) {
values['notes'] = null;
} else if (notes != null) {
values['notes'] = notes;
}
if (clearCategory) {
values['category'] = null;
} else if (category != null) {
values['category'] = category.trim();
}
await db.update('products', values, where: 'id = ?', whereArgs: [id]);
}
Future<void> setFavourite(int id, bool favourite) =>
updateProduct(id: id, favourite: favourite);
Future<String?> setProductImage(int id, String sourcePath) async {
final stored = await _persistImage(sourcePath, 'products/product_$id.jpg');
await updateProduct(id: id, imagePath: stored);
return stored;
}
Future<List<Purchase>> getPurchases(int productId) async {
final db = await _database;
final rows = await db.rawQuery(
'''
SELECT
shop_items.shop_id,
shop_items.price,
shop_items.quantity,
shop_items.unit_price,
shops.shopped_at,
shops.supermarket,
shops.receipt_image_path
FROM shop_items
JOIN shops ON shops.id = shop_items.shop_id
WHERE shop_items.product_id = ?
ORDER BY shops.shopped_at DESC, shop_items.id DESC
''',
[productId],
);
return rows.map(Purchase.fromMap).toList();
}
Future<List<Shop>> getShops() async {
final db = await _database;
final rows = await db.rawQuery('''
SELECT
shops.id,
shops.supermarket,
shops.shopped_at,
shops.receipt_image_path,
shops.total,
COUNT(shop_items.id) AS item_count
FROM shops
LEFT JOIN shop_items ON shop_items.shop_id = shops.id
GROUP BY shops.id
ORDER BY shops.shopped_at DESC, shops.id DESC
''');
return rows.map(Shop.fromMap).toList();
}
Future<Shop?> getShop(int id) async {
final db = await _database;
final rows = await db.rawQuery(
'''
SELECT
shops.id,
shops.supermarket,
shops.shopped_at,
shops.receipt_image_path,
shops.total,
COUNT(shop_items.id) AS item_count
FROM shops
LEFT JOIN shop_items ON shop_items.shop_id = shops.id
WHERE shops.id = ?
GROUP BY shops.id
''',
[id],
);
if (rows.isEmpty) return null;
return Shop.fromMap(rows.first);
}
Future<List<ShopItem>> getShopItems(int shopId) async {
final db = await _database;
final rows = await db.query(
'shop_items',
where: 'shop_id = ?',
whereArgs: [shopId],
orderBy: 'id ASC',
);
return rows.map(ShopItem.fromMap).toList();
}
Future<bool> getDarkMode() async {
final db = await _database;
final rows = await db.query(
'app_settings',
where: 'key = ?',
whereArgs: ['dark_mode'],
limit: 1,
);
if (rows.isEmpty) return false;
return rows.first['value'] == '1';
}
Future<void> setDarkMode(bool enabled) async {
final db = await _database;
await db.insert(
'app_settings',
{'key': 'dark_mode', 'value': enabled ? '1' : '0'},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<List<Supermarket>> getSupermarkets() async {
final db = await _database;
final rows = await db.query('supermarkets', orderBy: 'sort_order ASC, id ASC');
if (rows.isEmpty) {
await _seedSupermarkets(db);
return getSupermarkets();
}
return rows.map(Supermarket.fromMap).toList();
}
Future<Supermarket> addSupermarket({
required String name,
required int colorValue,
}) async {
final db = await _database;
final maxOrder = Sqflite.firstIntValue(
await db.rawQuery('SELECT MAX(sort_order) FROM supermarkets'),
);
final id = await db.insert('supermarkets', {
'name': name.trim(),
'color': colorValue,
'sort_order': (maxOrder ?? -1) + 1,
});
return Supermarket(
id: id,
name: name.trim(),
colorValue: colorValue,
sortOrder: (maxOrder ?? -1) + 1,
);
}
Future<void> updateSupermarket(Supermarket store) async {
final id = store.id;
if (id == null) return;
final db = await _database;
final existing = await db.query(
'supermarkets',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (existing.isEmpty) return;
final oldName = existing.first['name']! as String;
await db.update(
'supermarkets',
{
'name': item.name.trim(),
'last_price': item.price,
'times_seen': (existing.first['times_seen']! as int) + 1,
'updated_at': now,
'name': store.name.trim(),
'color': store.colorValue,
'sort_order': store.sortOrder,
},
where: 'id = ?',
whereArgs: [existing.first['id']],
whereArgs: [id],
);
if (oldName != store.name.trim()) {
await db.update(
'products',
{'supermarket': store.name.trim()},
where: 'supermarket = ?',
whereArgs: [oldName],
);
await db.update(
'shops',
{'supermarket': store.name.trim()},
where: 'supermarket = ?',
whereArgs: [oldName],
);
}
}
Future<void> deleteSupermarket(int id) async {
final db = await _database;
await db.delete('supermarkets', where: 'id = ?', whereArgs: [id]);
}
Future<List<Map<String, Object?>>> dumpTable(String table) async {
final db = await _database;
return db.query(table, orderBy: 'id ASC');
}
Future<String> writeImageBytes(String relativePath, List<int> bytes) async {
final dir = await getApplicationDocumentsDirectory();
final dest = File(p.join(dir.path, relativePath));
await dest.parent.create(recursive: true);
await dest.writeAsBytes(bytes, flush: true);
return dest.path;
}
Future<void> clearImageFolders() async {
final dir = await getApplicationDocumentsDirectory();
for (final name in ['receipts', 'products']) {
final folder = Directory(p.join(dir.path, name));
if (await folder.exists()) {
await folder.delete(recursive: true);
}
}
}
Future<void> replaceAllData({
required List<Map<String, Object?>> supermarkets,
required List<Map<String, Object?>> products,
required List<Map<String, Object?>> shops,
required List<Map<String, Object?>> shopItems,
required bool darkMode,
}) async {
final db = await _database;
await db.transaction((txn) async {
await txn.delete('shop_items');
await txn.delete('shops');
await txn.delete('products');
await txn.delete('supermarkets');
await txn.delete('app_settings');
for (final row in supermarkets) {
await txn.insert('supermarkets', _sqlRow(row, _supermarketColumns));
}
for (final row in products) {
await txn.insert('products', _sqlRow(row, _productColumns));
}
for (final row in shops) {
await txn.insert('shops', _sqlRow(row, _shopColumns));
}
for (final row in shopItems) {
await txn.insert('shop_items', _sqlRow(row, _shopItemColumns));
}
await txn.insert('app_settings', {
'key': 'dark_mode',
'value': darkMode ? '1' : '0',
});
await _resetSequence(txn, 'supermarkets');
await _resetSequence(txn, 'products');
await _resetSequence(txn, 'shops');
await _resetSequence(txn, 'shop_items');
});
}
Future<List<Product>> getAll() async {
final db = await _database;
final rows = await db.query(
'products',
orderBy: 'name COLLATE NOCASE ASC',
static const _supermarketColumns = {
'id',
'name',
'color',
'sort_order',
};
static const _productColumns = {
'id',
'name',
'normalized_name',
'last_price',
'times_seen',
'favourite',
'image_path',
'supermarket',
'notes',
'category',
'created_at',
'updated_at',
};
static const _shopColumns = {
'id',
'supermarket',
'shopped_at',
'receipt_image_path',
'total',
'created_at',
};
static const _shopItemColumns = {
'id',
'shop_id',
'product_id',
'name',
'price',
'quantity',
'unit_price',
};
Map<String, Object?> _sqlRow(
Map<String, Object?> source,
Set<String> columns,
) {
final row = <String, Object?>{};
for (final column in columns) {
if (!source.containsKey(column)) continue;
row[column] = _sqlValue(source[column]);
}
return row;
}
Object? _sqlValue(Object? value) {
if (value == null) return null;
if (value is bool) return value ? 1 : 0;
if (value is int) return value;
if (value is double) {
if (value == value.roundToDouble()) return value.toInt();
return value;
}
if (value is num) return value.toDouble();
return value.toString();
}
Future<void> _resetSequence(DatabaseExecutor db, String table) async {
try {
final max = Sqflite.firstIntValue(
await db.rawQuery('SELECT MAX(id) FROM $table'),
);
return rows.map(Product.fromMap).toList();
await db.delete('sqlite_sequence', where: 'name = ?', whereArgs: [table]);
if (max != null) {
await db.insert('sqlite_sequence', {'name': table, 'seq': max});
}
} catch (_) {
// sqlite_sequence is missing on some empty databases.
}
}
}
+158
View File
@@ -0,0 +1,158 @@
/// One shopping trip: receipt photo, supermarket, products and costs.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'models/shop.dart';
import 'product_detail_screen.dart';
import 'services/product_repository.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
class ShopDetailScreen extends StatefulWidget {
const ShopDetailScreen({
super.key,
required this.repository,
required this.shopId,
});
final ProductRepository repository;
final int shopId;
@override
State<ShopDetailScreen> createState() => _ShopDetailScreenState();
}
class _ShopDetailScreenState extends State<ShopDetailScreen> {
late Future<({Shop shop, List<ShopItem> items})> _future;
@override
void initState() {
super.initState();
_future = _load();
}
Future<({Shop shop, List<ShopItem> items})> _load() async {
final shop = await widget.repository.getShop(widget.shopId);
if (shop == null) {
throw StateError('Shop not found');
}
final items = await widget.repository.getShopItems(widget.shopId);
return (shop: shop, items: items);
}
Future<void> _openReceipt(File file) async {
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> _openProduct(int productId) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => ProductDetailScreen(
repository: widget.repository,
productId: productId,
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Shopping trip')),
body: FutureBuilder(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text(
'Could not load this trip.',
style: theme.textTheme.bodyLarge,
),
);
}
if (!snapshot.hasData) {
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),
children: [
Text(shop.supermarket, style: theme.textTheme.headlineMedium),
const SizedBox(height: 4),
Text(
formatDate(shop.shoppedAt),
style: theme.textTheme.bodyLarge,
),
if (hasReceipt) ...[
const SizedBox(height: 16),
GestureDetector(
onTap: () => _openReceipt(receiptFile),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.file(
receiptFile,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
cacheWidth: 900,
),
),
),
],
const SizedBox(height: 24),
Text('Products', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
for (final item in items)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(item.name),
subtitle: item.quantity == null
? null
: Text(
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
),
trailing: Text(
formatEuro(item.price),
style: theme.textTheme.titleMedium,
),
onTap: () => _openProduct(item.productId),
),
const Divider(),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Total', style: theme.textTheme.titleMedium),
trailing: Text(
formatEuro(shop.total),
style: theme.textTheme.titleLarge,
),
),
],
);
},
),
);
}
}
+159
View File
@@ -0,0 +1,159 @@
/// Log of saved shopping trips.
library;
import 'dart:io';
import 'package:flutter/material.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/settings_button.dart';
import 'widgets/store_tag.dart';
class ShopLogScreen extends StatefulWidget {
const ShopLogScreen({super.key, required this.repository});
final ProductRepository repository;
@override
State<ShopLogScreen> createState() => _ShopLogScreenState();
}
class _ShopLogScreenState extends State<ShopLogScreen> {
late Future<List<Shop>> _future;
@override
void initState() {
super.initState();
_future = widget.repository.getShops();
}
void _reload() {
setState(() => _future = widget.repository.getShops());
}
Future<void> _openShop(Shop shop) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => ShopDetailScreen(
repository: widget.repository,
shopId: shop.id,
),
),
);
if (mounted) _reload();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
return Scaffold(
appBar: AppBar(
title: const Text('Shop log'),
actions: 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 to log a shopping trip.',
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: Text(
formatEuro(shop.total),
style: theme.textTheme.titleMedium,
),
onTap: () => _openShop(shop),
);
},
);
},
),
);
}
}
class _ReceiptThumb extends StatelessWidget {
const _ReceiptThumb({this.path});
final String? path;
@override
Widget build(BuildContext context) {
final file = path == null ? null : File(path!);
final hasFile = file != null && file.existsSync();
final scheme = Theme.of(context).colorScheme;
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,
),
),
)
: ColoredBox(
color: scheme.secondaryContainer,
child: Icon(
Icons.receipt_long_outlined,
color: scheme.onSecondaryContainer,
),
),
),
);
}
}
+29
View File
@@ -0,0 +1,29 @@
/// Grocery categories a product can be assigned to.
library;
const List<String> kDefaultCategories = [
'Fruit & vegetables',
'Dairy & eggs',
'Meat & fish',
'Bread & bakery',
'Pantry',
'Drinks',
'Frozen',
'Snacks',
'Household',
'Personal care',
];
List<String> mergeCategories(Iterable<String> used) {
final seen = <String>{};
final result = <String>[];
for (final name in [...kDefaultCategories, ...used]) {
final trimmed = name.trim();
if (trimmed.isEmpty) continue;
final key = trimmed.toLowerCase();
if (seen.contains(key)) continue;
seen.add(key);
result.add(trimmed);
}
return result;
}
+20
View File
@@ -0,0 +1,20 @@
/// Date formatting for shop log and product history.
library;
const _months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
String formatDate(DateTime date) =>
'${date.day} ${_months[date.month - 1]} ${date.year}';
+40
View File
@@ -0,0 +1,40 @@
/// Lightweight fuzzy matching for product search.
library;
String _normalize(String value) =>
value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
/// True when [query] fuzzily matches [text].
bool fuzzyMatch(String query, String text) {
final q = _normalize(query);
if (q.isEmpty) return true;
final t = _normalize(text);
if (t.contains(q)) return true;
for (final word in q.split(' ')) {
if (word.isEmpty) continue;
if (!t.contains(word) && !_subsequence(word, t)) return false;
}
if (q.contains(' ')) return true;
return _subsequence(q, t);
}
/// Higher is better. Null when [query] does not match.
int? fuzzyScore(String query, String text) {
final q = _normalize(query);
if (q.isEmpty) return 0;
final t = _normalize(text);
if (t == q) return 1000;
if (t.startsWith(q)) return 800;
if (t.contains(q)) return 600 - t.indexOf(q);
if (!fuzzyMatch(q, t)) return null;
return 200 - (t.length - q.length).abs().clamp(0, 150);
}
bool _subsequence(String query, String text) {
var i = 0;
for (var c = 0; c < text.length && i < query.length; c++) {
if (text.codeUnitAt(c) == query.codeUnitAt(i)) i++;
}
return i == query.length;
}
+99
View File
@@ -0,0 +1,99 @@
/// Known Dutch supermarket names, default colors, and OCR detection.
library;
import '../models/supermarket.dart';
const String kOtherSupermarket = 'Other';
final List<Supermarket> kDefaultSupermarkets = [
const Supermarket(name: 'Albert Heijn', colorValue: 0xFF00A0E2, sortOrder: 0),
const Supermarket(name: 'Jumbo', colorValue: 0xFFEEC21B, sortOrder: 1),
const Supermarket(name: 'Lidl', colorValue: 0xFF0050AA, sortOrder: 2),
const Supermarket(name: 'Aldi', colorValue: 0xFF00205B, sortOrder: 3),
const Supermarket(name: 'Plus', colorValue: 0xFF6EC31E, sortOrder: 4),
const Supermarket(name: 'Dirk', colorValue: 0xFFE30613, sortOrder: 5),
const Supermarket(name: 'Coop', colorValue: 0xFFE30613, sortOrder: 6),
const Supermarket(name: 'SPAR', colorValue: 0xFF009640, sortOrder: 7),
const Supermarket(name: 'Nettorama', colorValue: 0xFFE87722, sortOrder: 8),
const Supermarket(name: 'Hoogvliet', colorValue: 0xFFD32F2F, sortOrder: 9),
const Supermarket(name: 'Picnic', colorValue: 0xFFE31C5F, sortOrder: 10),
const Supermarket(name: kOtherSupermarket, colorValue: 0xFF78909C, sortOrder: 11),
];
const Map<String, List<String>> kSupermarketAliases = {
'Albert Heijn': ['ALBERT HEIJN', 'AH TO GO', 'AH XL'],
'Jumbo': ['JUMBO'],
'Lidl': ['LIDL'],
'Aldi': ['ALDI'],
'Plus': ['PLUS'],
'Dirk': ['DIRK'],
'Coop': ['COOP'],
'SPAR': ['SPAR'],
'Nettorama': ['NETTORAMA'],
'Hoogvliet': ['HOOGVLIET'],
'Picnic': ['PICNIC'],
};
/// Names used before settings were user-editable.
const List<String> kSupermarkets = [
'Albert Heijn',
'Jumbo',
'Lidl',
'Aldi',
'Plus',
'Dirk',
'Coop',
'SPAR',
'Nettorama',
'Hoogvliet',
'Picnic',
'Other',
];
/// Returns a supermarket name if it appears in [rawText].
String? detectSupermarket(
String rawText, {
Iterable<String> names = const [],
}) {
final upper = rawText.toUpperCase();
final extra = names
.map((name) => name.trim())
.where((name) => name.isNotEmpty && name != kOtherSupermarket)
.toList()
..sort((a, b) => b.length.compareTo(a.length));
for (final name in extra) {
if (RegExp('\\b${RegExp.escape(name)}\\b', caseSensitive: false)
.hasMatch(rawText)) {
return name;
}
}
for (final entry in kSupermarketAliases.entries) {
for (final alias in entry.value) {
if (RegExp('\\b${RegExp.escape(alias)}\\b').hasMatch(upper)) {
return _canonicalName(entry.key, extra);
}
}
}
if (RegExp(r'\bAH\b').hasMatch(upper)) {
return _canonicalName('Albert Heijn', extra);
}
return null;
}
String _canonicalName(String fallback, List<String> names) {
for (final name in names) {
if (name.toUpperCase() == fallback.toUpperCase()) return name;
}
return fallback;
}
Supermarket? supermarketByName(Iterable<Supermarket> stores, String? name) {
if (name == null || name.isEmpty) return null;
for (final store in stores) {
if (store.name.toLowerCase() == name.toLowerCase()) return store;
}
return null;
}
+159
View File
@@ -0,0 +1,159 @@
/// Simple line chart of unit prices over time.
library;
import 'package:flutter/material.dart';
import '../models/purchase.dart';
import '../utils/dates.dart';
import '../utils/money.dart';
class PriceChart extends StatelessWidget {
const PriceChart({super.key, required this.purchases});
final List<Purchase> purchases;
@override
Widget build(BuildContext context) {
if (purchases.isEmpty) {
return const SizedBox.shrink();
}
final points = purchases.reversed.toList();
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 160,
child: CustomPaint(
painter: _PriceChartPainter(
prices: [
for (final purchase in points) purchase.unitOrLinePrice,
],
lineColor: theme.colorScheme.primary,
fillColor: theme.colorScheme.primary.withValues(alpha: 0.12),
gridColor: theme.colorScheme.outlineVariant,
labelColor: theme.colorScheme.onSurfaceVariant,
),
child: const SizedBox.expand(),
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
formatDate(points.first.boughtAt),
style: theme.textTheme.labelMedium,
),
Text(
formatDate(points.last.boughtAt),
style: theme.textTheme.labelMedium,
),
],
),
const SizedBox(height: 4),
Text(
'Latest ${formatEuro(points.last.unitOrLinePrice)}',
style: theme.textTheme.labelMedium,
),
],
);
}
}
class _PriceChartPainter extends CustomPainter {
_PriceChartPainter({
required this.prices,
required this.lineColor,
required this.fillColor,
required this.gridColor,
required this.labelColor,
});
final List<double> prices;
final Color lineColor;
final Color fillColor;
final Color gridColor;
final Color labelColor;
@override
void paint(Canvas canvas, Size size) {
final minPrice = prices.reduce((a, b) => a < b ? a : b);
final maxPrice = prices.reduce((a, b) => a > b ? a : b);
final span = (maxPrice - minPrice).abs() < 0.01 ? 1.0 : maxPrice - minPrice;
const left = 36.0;
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 gridPaint = Paint()
..color = gridColor
..strokeWidth = 1;
for (var i = 0; i < 3; i++) {
final y = chart.top + chart.height * i / 2;
canvas.drawLine(Offset(chart.left, y), Offset(chart.right, y), gridPaint);
}
final path = Path();
final fill = Path();
Offset? last;
for (var i = 0; i < prices.length; i++) {
final x = prices.length == 1
? chart.center.dx
: chart.left + chart.width * i / (prices.length - 1);
final y = chart.bottom - chart.height * ((prices[i] - minPrice) / span);
final point = Offset(x, y);
last = point;
if (i == 0) {
path.moveTo(x, y);
fill.moveTo(x, chart.bottom);
fill.lineTo(x, y);
} else {
path.lineTo(x, y);
fill.lineTo(x, y);
}
}
if (last != null) {
fill.lineTo(last.dx, chart.bottom);
fill.close();
}
canvas.drawPath(fill, Paint()..color = fillColor);
canvas.drawPath(
path,
Paint()
..color = lineColor
..style = PaintingStyle.stroke
..strokeWidth = 2.5
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round,
);
final dot = Paint()..color = lineColor;
for (var i = 0; i < prices.length; i++) {
final x = prices.length == 1
? chart.center.dx
: chart.left + chart.width * i / (prices.length - 1);
final y = chart.bottom - chart.height * ((prices[i] - minPrice) / span);
canvas.drawCircle(Offset(x, y), 3.5, dot);
}
final labels = [maxPrice, (minPrice + maxPrice) / 2, minPrice];
final textStyle = TextStyle(color: labelColor, fontSize: 10);
for (var i = 0; i < labels.length; i++) {
final tp = TextPainter(
text: TextSpan(text: labels[i].toStringAsFixed(2), style: textStyle),
textDirection: TextDirection.ltr,
)..layout(maxWidth: left - 4);
final y = chart.top + chart.height * i / 2 - tp.height / 2;
tp.paint(canvas, Offset(0, y));
}
}
@override
bool shouldRepaint(covariant _PriceChartPainter oldDelegate) =>
oldDelegate.prices != prices || oldDelegate.lineColor != lineColor;
}
+80
View File
@@ -0,0 +1,80 @@
/// Thumbnail or letter avatar for a product.
library;
import 'dart:io';
import 'package:flutter/material.dart';
class ProductImage extends StatelessWidget {
const ProductImage({
super.key,
required this.name,
this.path,
this.size = 48,
});
final String name;
final String? path;
final double size;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final file = path == null ? null : File(path!);
final hasFile = file != null && file.existsSync();
return ClipRRect(
borderRadius: BorderRadius.circular(size / 4),
child: SizedBox(
width: size,
height: size,
child: hasFile
? Image.file(
file,
fit: BoxFit.cover,
cacheWidth: (size * 3).round(),
errorBuilder: (_, _, _) => _LetterAvatar(
name: name,
size: size,
colorScheme: theme.colorScheme,
),
)
: _LetterAvatar(
name: name,
size: size,
colorScheme: theme.colorScheme,
),
),
);
}
}
class _LetterAvatar extends StatelessWidget {
const _LetterAvatar({
required this.name,
required this.size,
required this.colorScheme,
});
final String name;
final double size;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
final trimmed = name.trim();
final letter = trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase();
return ColoredBox(
color: colorScheme.secondaryContainer,
child: Center(
child: Text(
letter,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontSize: size * 0.4,
),
),
),
);
}
}
+372
View File
@@ -0,0 +1,372 @@
/// Gear that opens the settings sheet.
library;
import 'package:flutter/material.dart';
import '../models/supermarket.dart';
import '../services/app_settings.dart';
import '../services/backup_service.dart';
class SettingsButton extends StatelessWidget {
const SettingsButton({super.key});
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: 'Settings',
onPressed: () => showSettingsSheet(context),
icon: const Icon(Icons.settings_outlined),
);
}
}
Future<void> showSettingsSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return const _SettingsSheet();
},
);
}
class _SettingsSheet extends StatefulWidget {
const _SettingsSheet();
@override
State<_SettingsSheet> createState() => _SettingsSheetState();
}
class _SettingsSheetState extends State<_SettingsSheet> {
bool _busy = false;
Future<void> _export(AppSettings settings) async {
setState(() => _busy = true);
try {
final shared = await BackupService(settings.repository).exportBackup();
if (!mounted) return;
if (shared) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Backup ready.')),
);
}
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not export the backup.')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _import(AppSettings settings) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Import backup?'),
content: const Text(
'This replaces all products, shopping trips, photos, and settings on this device.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Import'),
),
],
);
},
);
if (confirmed != true || !mounted) return;
setState(() => _busy = true);
try {
final imported = await BackupService(settings.repository).importBackup();
if (!mounted) return;
if (imported) {
await settings.reload();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Backup imported.')),
);
}
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not import this backup.')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final settings = SettingsScope.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
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,
),
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'),
),
),
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(height: 8),
OutlinedButton.icon(
onPressed: _busy
? null
: () => _editStore(context, settings, null),
icon: const Icon(Icons.add),
label: const Text('Add supermarket'),
),
],
),
),
);
},
);
}
Future<void> _editStore(
BuildContext context,
AppSettings settings,
Supermarket? existing,
) async {
final result = await showDialog<Supermarket>(
context: context,
builder: (context) => _StoreEditDialog(store: existing),
);
if (result == null) return;
if (existing == null) {
try {
await settings.addSupermarket(result.name, result.colorValue);
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('That supermarket already exists.')),
);
}
}
} else {
await settings.updateSupermarket(
existing.copyWith(name: result.name, colorValue: result.colorValue),
);
}
}
}
class _StoreRow extends StatelessWidget {
const _StoreRow({
required this.store,
required this.onEdit,
this.onDelete,
});
final Supermarket store;
final VoidCallback onEdit;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final onColor = store.color.computeLuminance() > 0.55
? Colors.black87
: Colors.white;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: store.color,
child: Text(
store.name.isEmpty ? '?' : store.name.substring(0, 1),
style: TextStyle(color: onColor, fontWeight: FontWeight.w700),
),
),
title: Text(store.name),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Delete',
onPressed: onDelete,
icon: const Icon(Icons.delete_outline),
),
],
),
);
}
}
class _StoreEditDialog extends StatefulWidget {
const _StoreEditDialog({this.store});
final Supermarket? store;
@override
State<_StoreEditDialog> createState() => _StoreEditDialogState();
}
class _StoreEditDialogState extends State<_StoreEditDialog> {
late final TextEditingController _name;
late int _color;
@override
void initState() {
super.initState();
_name = TextEditingController(text: widget.store?.name ?? '');
_color = widget.store?.colorValue ?? kStoreColorPalette.first;
}
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.store == null ? 'Add supermarket' : 'Edit supermarket'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _name,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: widget.store == null,
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: Text('Color', style: Theme.of(context).textTheme.titleMedium),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final value in kStoreColorPalette)
GestureDetector(
onTap: () => setState(() => _color = value),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Color(value),
shape: BoxShape.circle,
border: Border.all(
color: _color == value
? Theme.of(context).colorScheme.onSurface
: Colors.transparent,
width: 2,
),
),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = _name.text.trim();
if (name.isEmpty) return;
Navigator.pop(
context,
Supermarket(
id: widget.store?.id,
name: name,
colorValue: _color,
sortOrder: widget.store?.sortOrder ?? 0,
),
);
},
child: const Text('Save'),
),
],
);
}
}
+67
View File
@@ -0,0 +1,67 @@
/// Colored supermarket chip used in lists and on product pages.
library;
import 'package:flutter/material.dart';
import '../models/supermarket.dart';
import '../utils/supermarkets.dart';
class StoreTag extends StatelessWidget {
const StoreTag({
super.key,
required this.name,
this.stores = const [],
this.compact = false,
});
final String? name;
final List<Supermarket> stores;
final bool compact;
@override
Widget build(BuildContext context) {
final storeName = name?.trim();
if (storeName == null || storeName.isEmpty) {
return const SizedBox.shrink();
}
final store = supermarketByName(stores, storeName);
final color = store?.color ?? const Color(0xFF78909C);
final onColor = color.computeLuminance() > 0.55
? Colors.black87
: Colors.white;
return Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : 8,
vertical: compact ? 2 : 4,
),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: compact ? 6 : 8,
height: compact ? 6 : 8,
decoration: BoxDecoration(
color: onColor.withValues(alpha: 0.85),
shape: BoxShape.circle,
),
),
SizedBox(width: compact ? 4 : 6),
Text(
storeName,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: onColor,
fontWeight: FontWeight.w600,
fontSize: compact ? 11 : 12,
),
),
],
),
);
}
}
+168
View File
@@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
android_file_picker:
dependency: transitive
description:
name: android_file_picker
sha256: "1f111ed6bb33724ba782cc86357e3fd97f57051d55fc61adf33dcfc5049a1581"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
args:
dependency: transitive
description:
@@ -81,6 +89,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
url: "https://pub.dev"
source: hosted
version: "0.7.15"
fake_async:
dependency: transitive
description:
@@ -97,6 +113,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: b7acb5d123cb398f6b4a8a38e43777545254f8fc1fabef3ee11cbbb56aece9c8
url: "https://pub.dev"
source: hosted
version: "12.1.2"
file_picker_darwin:
dependency: transitive
description:
name: file_picker_darwin
sha256: "6e7cae501d48fd57a27911608db718ebd898e6ccf934bf09e33a8c0d0365bec0"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
file_picker_linux:
dependency: transitive
description:
name: file_picker_linux
sha256: f0f01ed42967b7355f6f25c8b121ea531d1948e2a9b4b44f4b4de8489d7b04ae
url: "https://pub.dev"
source: hosted
version: "1.0.2"
file_picker_platform_interface:
dependency: transitive
description:
name: file_picker_platform_interface
sha256: "11ef1b5c14d9186b4788cc273dd8d5cb81bca847145060991a28234ff7916fc1"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
file_picker_web:
dependency: transitive
description:
name: file_picker_web
sha256: df472142f63c4557fdfbb375c81454ca1c251491069eefcdc6a866bc12f8750b
url: "https://pub.dev"
source: hosted
version: "3.0.3"
file_selector_linux:
dependency: transitive
description:
@@ -129,6 +201,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.3+6"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@@ -432,6 +512,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@@ -464,6 +552,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.0"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c"
url: "https://pub.dev"
source: hosted
version: "13.3.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
sky_engine:
dependency: transitive
description: flutter
@@ -573,6 +677,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0"
url: "https://pub.dev"
source: hosted
version: "3.2.3"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
uuid:
dependency: transitive
description:
name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_math:
dependency: transitive
description:
@@ -597,6 +741,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
url: "https://pub.dev"
source: hosted
version: "6.4.0"
windows_file_picker:
dependency: transitive
description:
name: windows_file_picker
sha256: "225f58e64c15c2d7b34fb8faf3ca188831967f26b5a723d7c3a7969e41c9b5a5"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xdg_directories:
dependency: transitive
description:
@@ -605,6 +765,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
yaml:
dependency: transitive
description:
+2
View File
@@ -16,6 +16,8 @@ dependencies:
path: ^1.9.1
path_provider: ^2.1.5
sqflite: ^2.4.2
file_picker: ^12.1.2
share_plus: ^13.3.0
dev_dependencies:
flutter_test:
+78
View File
@@ -0,0 +1,78 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/services/backup_format.dart';
void main() {
test('round-trips a backup document', () {
final original = const BackupDocument(
version: 1,
exportedAt: '2026-08-29T12:00:00.000',
darkMode: true,
supermarkets: [
{'id': 1, 'name': 'Jumbo', 'color': 0xFFEEC21B, 'sort_order': 0},
],
products: [
{
'id': 1,
'name': 'Milk',
'normalized_name': 'MILK',
'last_price': 1.5,
'times_seen': 2,
'favourite': 1,
'image_path': 'products/product_1.jpg',
'supermarket': 'Jumbo',
'notes': 'Organic',
'created_at': '2026-08-29T12:00:00.000',
'updated_at': '2026-08-29T12:00:00.000',
},
],
shops: [
{
'id': 8,
'supermarket': 'Jumbo',
'shopped_at': '2026-08-29T12:00:00.000',
'receipt_image_path': 'receipts/shop_8.jpg',
'total': 1.5,
'created_at': '2026-08-29T12:00:00.000',
},
],
shopItems: [
{
'id': 1,
'shop_id': 8,
'product_id': 1,
'name': 'Milk',
'price': 1.5,
'quantity': 1,
'unit_price': 1.5,
},
],
images: {'products/product_1.jpg': 'abc123'},
);
final parsed = BackupDocument.parse(original.encode());
expect(parsed.darkMode, isTrue);
expect(parsed.products.single['name'], 'Milk');
expect(parsed.shops.single['id'], 8);
expect(parsed.shopItems.single['product_id'], 1);
expect(parsed.images['products/product_1.jpg'], 'abc123');
});
test('rejects a file that is not a Receipity backup', () {
expect(
() => BackupDocument.parse('{"hello":"world"}'),
throwsFormatException,
);
});
test('maps stored photo paths to backup keys', () {
expect(
backupImageKey('/data/app_flutter/products/product_12.jpg'),
'products/product_12.jpg',
);
expect(
backupImageKey('/data/app_flutter/receipts/shop_3.jpg'),
'receipts/shop_3.jpg',
);
expect(backupImageKey(null), isNull);
});
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/utils/fuzzy.dart';
void main() {
test('matches substrings and character subsequences', () {
expect(fuzzyMatch('melk', 'JUMBO BIO VOLLE MELK'), isTrue);
expect(fuzzyMatch('jb melk', 'JUMBO BIO VOLLE MELK'), isTrue);
expect(fuzzyMatch('aard', 'AARDBEIEN'), isTrue);
expect(fuzzyMatch('xyz', 'AARDBEIEN'), isFalse);
});
test('ranks closer matches higher', () {
expect(fuzzyScore('melk', 'MELK')! > fuzzyScore('melk', 'JUMBO BIO VOLLE MELK')!, isTrue);
});
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:receipity/utils/supermarkets.dart';
void main() {
test('detects Jumbo and Lidl from receipt text', () {
expect(detectSupermarket('Jumbo\nMelk 1,50'), 'Jumbo');
expect(
detectSupermarket('Dekamarkt\nMelk 1,50', names: ['Dekamarkt']),
'Dekamarkt',
);
expect(detectSupermarket('LIDL SUPERMARKT\nBananas 1.20'), 'Lidl');
});
test('detects Albert Heijn aliases', () {
expect(detectSupermarket('Albert Heijn\nKaas 2,00'), 'Albert Heijn');
expect(detectSupermarket('AH TO GO\nCoffee 1,80'), 'Albert Heijn');
expect(detectSupermarket('AH\nMilk 1.00'), 'Albert Heijn');
});
test('returns null when no supermarket is present', () {
expect(detectSupermarket('Milk 1.50\nBread 2.00'), isNull);
});
}
+25 -3
View File
@@ -6,7 +6,9 @@ import 'package:receipity/review_screen.dart';
import 'package:receipity/services/product_repository.dart';
void main() {
testWidgets('scan screen shows capture actions', (tester) async {
testWidgets('scan screen shows capture actions and bottom navigation', (
tester,
) async {
await tester.pumpWidget(ReceipityApp());
expect(find.text('Receipity'), findsOneWidget);
@@ -17,6 +19,9 @@ void main() {
find.textContaining('Take a photo of a shopping receipt'),
findsOneWidget,
);
expect(find.text('Products'), findsOneWidget);
expect(find.text('Scan'), findsOneWidget);
expect(find.text('Shop log'), findsOneWidget);
});
testWidgets('review screen lists items and can delete one', (tester) async {
@@ -28,7 +33,7 @@ void main() {
LineItem(id: '2', name: 'Bread', price: 2),
],
rawText: 'Milk 1.50\nBread 2.00',
repository: ProductRepository(),
repository: ProductRepository(databasePath: ':memory:'),
),
),
);
@@ -36,11 +41,28 @@ void main() {
expect(find.text('Milk'), findsOneWidget);
expect(find.text('Bread'), findsOneWidget);
expect(find.text('Add item'), findsOneWidget);
expect(find.text('Confirm'), findsOneWidget);
expect(find.textContaining('Save trip'), findsOneWidget);
expect(find.text('Supermarket'), findsOneWidget);
await tester.drag(find.text('Milk'), const Offset(-500, 0));
await tester.pumpAndSettle();
expect(find.text('Milk'), findsNothing);
expect(find.text('Bread'), findsOneWidget);
});
testWidgets('review screen preselects a supermarket from OCR text', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: ReviewScreen(
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
rawText: 'Jumbo\nMilk 1.50',
repository: ProductRepository(databasePath: ':memory:'),
),
),
);
expect(find.text('Jumbo'), findsWidgets);
});
}