added categories, import export, settings
This commit is contained in:
@@ -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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+37
-8
@@ -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(
|
||||
title: kAppName,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
home: ScanScreen(repository: repository),
|
||||
return SettingsScope(
|
||||
settings: _settings,
|
||||
child: MaterialApp(
|
||||
title: kAppName,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
themeMode: _settings.darkMode ? ThemeMode.dark : ThemeMode.light,
|
||||
home: HomeShell(repository: widget.repository),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+48
-1
@@ -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 }
|
||||
|
||||
@@ -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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+219
-65
@@ -1,100 +1,254 @@
|
||||
/// 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>>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Could not load products.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final products = snapshot.data!;
|
||||
if (products.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Confirmed receipt items will show up here.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: products.length,
|
||||
separatorBuilder: (_, _) => const Divider(indent: 16, endIndent: 16),
|
||||
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'),
|
||||
trailing: Text(
|
||||
formatEuro(product.lastPrice),
|
||||
style: theme.textTheme.titleMedium,
|
||||
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('A–Z'),
|
||||
),
|
||||
],
|
||||
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) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Could not load products.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final products = _filter(snapshot.data!);
|
||||
if (snapshot.data!.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
_favouritesOnly
|
||||
? 'Star products to keep them as favourites.'
|
||||
: 'Confirmed receipt items will show up here.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (products.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No products match this search.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 16),
|
||||
itemCount: products.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final product = products[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 0,
|
||||
),
|
||||
leading: ProductImage(
|
||||
name: product.name,
|
||||
path: product.imagePath,
|
||||
size: 36,
|
||||
),
|
||||
title: Text(
|
||||
product.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: product.supermarket == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: StoreTag(
|
||||
name: product.supermarket,
|
||||
stores: stores,
|
||||
compact: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
formatEuro(product.lastPrice),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
onTap: () => _openProduct(product),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+124
-14
@@ -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)}',
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+24
-21
@@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openProducts() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ProductsScreen(repository: widget.repository),
|
||||
),
|
||||
);
|
||||
if (saved == true && mounted) {
|
||||
widget.onTripSaved?.call();
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,77 +27,664 @@ 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 db.execute('''
|
||||
CREATE TABLE products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
normalized_name TEXT NOT NULL UNIQUE,
|
||||
last_price REAL NOT NULL,
|
||||
times_seen INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
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,
|
||||
name TEXT NOT NULL,
|
||||
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 existing = await txn.query(
|
||||
'products',
|
||||
where: 'normalized_name = ?',
|
||||
whereArgs: [key],
|
||||
limit: 1,
|
||||
final productId = await _upsertProduct(
|
||||
txn,
|
||||
item: item,
|
||||
supermarket: supermarket,
|
||||
now: now,
|
||||
);
|
||||
|
||||
if (existing.isEmpty) {
|
||||
await txn.insert('products', {
|
||||
'name': item.name.trim(),
|
||||
'normalized_name': key,
|
||||
'last_price': item.price,
|
||||
'times_seen': 1,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
});
|
||||
} else {
|
||||
await txn.update(
|
||||
'products',
|
||||
{
|
||||
'name': item.name.trim(),
|
||||
'last_price': item.price,
|
||||
'times_seen': (existing.first['times_seen']! as int) + 1,
|
||||
'updated_at': now,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [existing.first['id']],
|
||||
);
|
||||
}
|
||||
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 = ?',
|
||||
whereArgs: [key],
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
if (existing.isEmpty) {
|
||||
return txn.insert('products', {
|
||||
'name': item.name.trim(),
|
||||
'normalized_name': key,
|
||||
'last_price': item.unitPrice ?? item.price,
|
||||
'times_seen': 1,
|
||||
'favourite': 0,
|
||||
'supermarket': supermarket,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
});
|
||||
}
|
||||
|
||||
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': store.name.trim(),
|
||||
'color': store.colorValue,
|
||||
'sort_order': store.sortOrder,
|
||||
},
|
||||
where: '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',
|
||||
);
|
||||
return rows.map(Product.fromMap).toList();
|
||||
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'),
|
||||
);
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user