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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'scan_screen.dart';
|
import 'home_shell.dart';
|
||||||
|
import 'services/app_settings.dart';
|
||||||
import 'services/product_repository.dart';
|
import 'services/product_repository.dart';
|
||||||
import 'utils/app_theme.dart';
|
import 'utils/app_theme.dart';
|
||||||
import 'utils/constants.dart';
|
import 'utils/constants.dart';
|
||||||
@@ -13,20 +14,48 @@ void main() {
|
|||||||
runApp(ReceipityApp());
|
runApp(ReceipityApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
class ReceipityApp extends StatelessWidget {
|
class ReceipityApp extends StatefulWidget {
|
||||||
ReceipityApp({super.key, ProductRepository? repository})
|
ReceipityApp({super.key, ProductRepository? repository})
|
||||||
: repository = repository ?? ProductRepository();
|
: repository = repository ?? ProductRepository();
|
||||||
|
|
||||||
final ProductRepository repository;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return SettingsScope(
|
||||||
title: kAppName,
|
settings: _settings,
|
||||||
debugShowCheckedModeBanner: false,
|
child: MaterialApp(
|
||||||
theme: AppTheme.light(),
|
title: kAppName,
|
||||||
darkTheme: AppTheme.dark(),
|
debugShowCheckedModeBanner: false,
|
||||||
home: ScanScreen(repository: repository),
|
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.lastPrice,
|
||||||
required this.timesSeen,
|
required this.timesSeen,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
|
this.favourite = false,
|
||||||
|
this.imagePath,
|
||||||
|
this.supermarket,
|
||||||
|
this.notes,
|
||||||
|
this.category,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int id;
|
final int id;
|
||||||
@@ -15,14 +20,56 @@ class Product {
|
|||||||
final double lastPrice;
|
final double lastPrice;
|
||||||
final int timesSeen;
|
final int timesSeen;
|
||||||
final DateTime updatedAt;
|
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) {
|
factory Product.fromMap(Map<String, Object?> map) {
|
||||||
return Product(
|
return Product(
|
||||||
id: map['id']! as int,
|
id: map['id']! as int,
|
||||||
name: map['name']! as String,
|
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,
|
timesSeen: map['times_seen']! as int,
|
||||||
updatedAt: DateTime.parse(map['updated_at']! as String),
|
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;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'models/product.dart';
|
import 'models/product.dart';
|
||||||
|
import 'product_detail_screen.dart';
|
||||||
|
import 'services/app_settings.dart';
|
||||||
import 'services/product_repository.dart';
|
import 'services/product_repository.dart';
|
||||||
|
import 'utils/fuzzy.dart';
|
||||||
import 'utils/money.dart';
|
import 'utils/money.dart';
|
||||||
|
import 'widgets/product_image.dart';
|
||||||
|
import 'widgets/settings_button.dart';
|
||||||
|
import 'widgets/store_tag.dart';
|
||||||
|
|
||||||
class ProductsScreen extends StatefulWidget {
|
class ProductsScreen extends StatefulWidget {
|
||||||
const ProductsScreen({
|
const ProductsScreen({super.key, required this.repository});
|
||||||
super.key,
|
|
||||||
required this.repository,
|
|
||||||
this.savedCount,
|
|
||||||
});
|
|
||||||
|
|
||||||
final ProductRepository repository;
|
final ProductRepository repository;
|
||||||
final int? savedCount;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ProductsScreen> createState() => _ProductsScreenState();
|
State<ProductsScreen> createState() => _ProductsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProductsScreenState extends State<ProductsScreen> {
|
class _ProductsScreenState extends State<ProductsScreen> {
|
||||||
|
ProductSort _sort = ProductSort.preferred;
|
||||||
|
bool _favouritesOnly = false;
|
||||||
|
bool _searching = false;
|
||||||
|
final TextEditingController _search = TextEditingController();
|
||||||
late Future<List<Product>> _future;
|
late Future<List<Product>> _future;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_future = widget.repository.getAll();
|
_future = _load();
|
||||||
final saved = widget.savedCount;
|
_search.addListener(() => setState(() {}));
|
||||||
if (saved != null) {
|
}
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!mounted) return;
|
@override
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
void dispose() {
|
||||||
SnackBar(
|
_search.dispose();
|
||||||
content: Text(
|
super.dispose();
|
||||||
saved == 1
|
}
|
||||||
? 'Saved 1 product.'
|
|
||||||
: 'Saved $saved products.',
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Products')),
|
appBar: AppBar(
|
||||||
body: FutureBuilder<List<Product>>(
|
title: _searching
|
||||||
future: _future,
|
? TextField(
|
||||||
builder: (context, snapshot) {
|
controller: _search,
|
||||||
if (snapshot.hasError) {
|
autofocus: true,
|
||||||
return Center(
|
decoration: const InputDecoration(
|
||||||
child: Text(
|
hintText: 'Search products',
|
||||||
'Could not load products.',
|
border: InputBorder.none,
|
||||||
style: theme.textTheme.bodyLarge,
|
enabledBorder: InputBorder.none,
|
||||||
),
|
focusedBorder: InputBorder.none,
|
||||||
);
|
filled: false,
|
||||||
}
|
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
|
: 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;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'models/line_item.dart';
|
import 'models/line_item.dart';
|
||||||
import 'products_screen.dart';
|
import 'services/app_settings.dart';
|
||||||
import 'services/product_repository.dart';
|
import 'services/product_repository.dart';
|
||||||
|
import 'utils/dates.dart';
|
||||||
import 'utils/money.dart';
|
import 'utils/money.dart';
|
||||||
|
import 'utils/supermarkets.dart';
|
||||||
|
import 'widgets/store_tag.dart';
|
||||||
|
|
||||||
class ReviewScreen extends StatefulWidget {
|
class ReviewScreen extends StatefulWidget {
|
||||||
const ReviewScreen({
|
const ReviewScreen({
|
||||||
@@ -14,11 +17,15 @@ class ReviewScreen extends StatefulWidget {
|
|||||||
required this.items,
|
required this.items,
|
||||||
required this.rawText,
|
required this.rawText,
|
||||||
required this.repository,
|
required this.repository,
|
||||||
|
this.imagePath,
|
||||||
|
this.supermarketNames = kSupermarkets,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<LineItem> items;
|
final List<LineItem> items;
|
||||||
final String rawText;
|
final String rawText;
|
||||||
final ProductRepository repository;
|
final ProductRepository repository;
|
||||||
|
final String? imagePath;
|
||||||
|
final List<String> supermarketNames;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ReviewScreen> createState() => _ReviewScreenState();
|
State<ReviewScreen> createState() => _ReviewScreenState();
|
||||||
@@ -26,12 +33,33 @@ class ReviewScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _ReviewScreenState extends State<ReviewScreen> {
|
class _ReviewScreenState extends State<ReviewScreen> {
|
||||||
late List<LineItem> _items;
|
late List<LineItem> _items;
|
||||||
|
late DateTime _shoppedAt;
|
||||||
|
String? _supermarket;
|
||||||
|
String? _customStore;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_items = List<LineItem>.from(widget.items);
|
_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 {
|
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 {
|
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);
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
await widget.repository.upsertItems(_items);
|
await widget.repository.saveShop(
|
||||||
if (!mounted) return;
|
supermarket: supermarket,
|
||||||
Navigator.of(context).pushAndRemoveUntil(
|
shoppedAt: _shoppedAt,
|
||||||
MaterialPageRoute<void>(
|
items: _items,
|
||||||
builder: (_) => ProductsScreen(
|
receiptImagePath: widget.imagePath,
|
||||||
repository: widget.repository,
|
|
||||||
savedCount: _items.length,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(route) => route.isFirst,
|
|
||||||
);
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _saving = false);
|
setState(() => _saving = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Review items')),
|
appBar: AppBar(title: const Text('Review items')),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
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(
|
Expanded(
|
||||||
child: _items.isEmpty
|
child: _items.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
@@ -166,7 +274,9 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
|||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _saving ? null : _confirm,
|
onPressed: _saving ? null : _confirm,
|
||||||
child: Text(
|
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;
|
library;
|
||||||
|
|
||||||
import 'dart:io';
|
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:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
import 'products_screen.dart';
|
|
||||||
import 'review_screen.dart';
|
import 'review_screen.dart';
|
||||||
|
import 'services/app_settings.dart';
|
||||||
import 'services/product_repository.dart';
|
import 'services/product_repository.dart';
|
||||||
import 'services/receipt_parser.dart';
|
import 'services/receipt_parser.dart';
|
||||||
import 'utils/constants.dart';
|
import 'utils/constants.dart';
|
||||||
|
import 'utils/supermarkets.dart';
|
||||||
|
import 'widgets/settings_button.dart';
|
||||||
|
|
||||||
class ScanScreen extends StatefulWidget {
|
class ScanScreen extends StatefulWidget {
|
||||||
const ScanScreen({super.key, required this.repository});
|
const ScanScreen({
|
||||||
|
super.key,
|
||||||
|
required this.repository,
|
||||||
|
this.onTripSaved,
|
||||||
|
});
|
||||||
|
|
||||||
final ProductRepository repository;
|
final ProductRepository repository;
|
||||||
|
final VoidCallback? onTripSaved;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ScanScreen> createState() => _ScanScreenState();
|
State<ScanScreen> createState() => _ScanScreenState();
|
||||||
@@ -27,6 +34,7 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
final ImagePicker _picker = ImagePicker();
|
final ImagePicker _picker = ImagePicker();
|
||||||
|
|
||||||
String _extractedText = '';
|
String _extractedText = '';
|
||||||
|
String? _imagePath;
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
@@ -81,6 +89,7 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
_busy = true;
|
_busy = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
_extractedText = '';
|
_extractedText = '';
|
||||||
|
_imagePath = imagePath;
|
||||||
});
|
});
|
||||||
|
|
||||||
final recognizer = TextRecognizer(script: TextRecognitionScript.latin);
|
final recognizer = TextRecognizer(script: TextRecognitionScript.latin);
|
||||||
@@ -114,23 +123,23 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
Future<void> _openReview(String rawText) async {
|
Future<void> _openReview(String rawText) async {
|
||||||
final items = ReceiptParser.parse(rawText);
|
final items = ReceiptParser.parse(rawText);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await Navigator.of(context).push(
|
final names =
|
||||||
MaterialPageRoute<void>(
|
SettingsScope.maybeOf(context)?.supermarkets.map((s) => s.name) ??
|
||||||
|
kSupermarkets;
|
||||||
|
final saved = await Navigator.of(context).push<bool>(
|
||||||
|
MaterialPageRoute(
|
||||||
builder: (_) => ReviewScreen(
|
builder: (_) => ReviewScreen(
|
||||||
items: items,
|
items: items,
|
||||||
rawText: rawText,
|
rawText: rawText,
|
||||||
repository: widget.repository,
|
repository: widget.repository,
|
||||||
|
imagePath: _imagePath,
|
||||||
|
supermarketNames: names.toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
if (saved == true && mounted) {
|
||||||
|
widget.onTripSaved?.call();
|
||||||
void _openProducts() {
|
}
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder: (_) => ProductsScreen(repository: widget.repository),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -140,13 +149,7 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text(kAppName),
|
title: const Text(kAppName),
|
||||||
actions: [
|
actions: const [SettingsButton()],
|
||||||
IconButton(
|
|
||||||
tooltip: 'Products',
|
|
||||||
onPressed: _openProducts,
|
|
||||||
icon: const Icon(Icons.inventory_2_outlined),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -220,7 +223,7 @@ class _ResultPane extends StatelessWidget {
|
|||||||
if (text.isEmpty) {
|
if (text.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
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,
|
textAlign: TextAlign.center,
|
||||||
style: theme.textTheme.bodyLarge,
|
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;
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
|
||||||
import '../models/line_item.dart';
|
import '../models/line_item.dart';
|
||||||
import '../models/product.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 {
|
class ProductRepository {
|
||||||
|
ProductRepository({this.databasePath});
|
||||||
|
|
||||||
|
final String? databasePath;
|
||||||
Database? _db;
|
Database? _db;
|
||||||
|
|
||||||
Future<Database> get _database async {
|
Future<Database> get _database async {
|
||||||
@@ -17,77 +27,664 @@ class ProductRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Database> _open() async {
|
Future<Database> _open() async {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final path = databasePath ??
|
||||||
|
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
|
||||||
return openDatabase(
|
return openDatabase(
|
||||||
p.join(dir.path, 'receipity.db'),
|
path,
|
||||||
version: 1,
|
version: 4,
|
||||||
onCreate: (db, version) async {
|
onCreate: (db, version) async {
|
||||||
await db.execute('''
|
await _createProducts(db);
|
||||||
CREATE TABLE products (
|
await _createShopTables(db);
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
await _createSettingsTables(db);
|
||||||
name TEXT NOT NULL,
|
await _seedSupermarkets(db);
|
||||||
normalized_name TEXT NOT NULL UNIQUE,
|
},
|
||||||
last_price REAL NOT NULL,
|
onUpgrade: (db, oldVersion, newVersion) async {
|
||||||
times_seen INTEGER NOT NULL DEFAULT 1,
|
if (oldVersion < 2) {
|
||||||
created_at TEXT NOT NULL,
|
await db.execute(
|
||||||
updated_at TEXT NOT NULL
|
'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) =>
|
static String normalizeName(String name) =>
|
||||||
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
|
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 db = await _database;
|
||||||
final now = DateTime.now().toIso8601String();
|
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) {
|
for (final item in items) {
|
||||||
final key = normalizeName(item.name);
|
final productId = await _upsertProduct(
|
||||||
if (key.isEmpty) continue;
|
txn,
|
||||||
|
item: item,
|
||||||
final existing = await txn.query(
|
supermarket: supermarket,
|
||||||
'products',
|
now: now,
|
||||||
where: 'normalized_name = ?',
|
|
||||||
whereArgs: [key],
|
|
||||||
limit: 1,
|
|
||||||
);
|
);
|
||||||
|
await txn.insert('shop_items', {
|
||||||
if (existing.isEmpty) {
|
'shop_id': id,
|
||||||
await txn.insert('products', {
|
'product_id': productId,
|
||||||
'name': item.name.trim(),
|
'name': item.name.trim(),
|
||||||
'normalized_name': key,
|
'price': item.price,
|
||||||
'last_price': item.price,
|
'quantity': item.quantity,
|
||||||
'times_seen': 1,
|
'unit_price': item.unitPrice,
|
||||||
'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']],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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 {
|
static const _supermarketColumns = {
|
||||||
final db = await _database;
|
'id',
|
||||||
final rows = await db.query(
|
'name',
|
||||||
'products',
|
'color',
|
||||||
orderBy: 'name COLLATE NOCASE ASC',
|
'sort_order',
|
||||||
);
|
};
|
||||||
return rows.map(Product.fromMap).toList();
|
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
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
android_file_picker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: android_file_picker
|
||||||
|
sha256: "1f111ed6bb33724ba782cc86357e3fd97f57051d55fc61adf33dcfc5049a1581"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -81,6 +89,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.9"
|
version: "1.0.9"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.15"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -97,6 +113,62 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
file_selector_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -129,6 +201,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.9.3+6"
|
version: "0.9.3+6"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -432,6 +512,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -464,6 +552,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.0"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -573,6 +677,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -597,6 +741,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -605,6 +765,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ dependencies:
|
|||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
sqflite: ^2.4.2
|
sqflite: ^2.4.2
|
||||||
|
file_picker: ^12.1.2
|
||||||
|
share_plus: ^13.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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';
|
import 'package:receipity/services/product_repository.dart';
|
||||||
|
|
||||||
void main() {
|
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());
|
await tester.pumpWidget(ReceipityApp());
|
||||||
|
|
||||||
expect(find.text('Receipity'), findsOneWidget);
|
expect(find.text('Receipity'), findsOneWidget);
|
||||||
@@ -17,6 +19,9 @@ void main() {
|
|||||||
find.textContaining('Take a photo of a shopping receipt'),
|
find.textContaining('Take a photo of a shopping receipt'),
|
||||||
findsOneWidget,
|
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 {
|
testWidgets('review screen lists items and can delete one', (tester) async {
|
||||||
@@ -28,7 +33,7 @@ void main() {
|
|||||||
LineItem(id: '2', name: 'Bread', price: 2),
|
LineItem(id: '2', name: 'Bread', price: 2),
|
||||||
],
|
],
|
||||||
rawText: 'Milk 1.50\nBread 2.00',
|
rawText: 'Milk 1.50\nBread 2.00',
|
||||||
repository: ProductRepository(),
|
repository: ProductRepository(databasePath: ':memory:'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -36,11 +41,28 @@ void main() {
|
|||||||
expect(find.text('Milk'), findsOneWidget);
|
expect(find.text('Milk'), findsOneWidget);
|
||||||
expect(find.text('Bread'), findsOneWidget);
|
expect(find.text('Bread'), findsOneWidget);
|
||||||
expect(find.text('Add item'), 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.drag(find.text('Milk'), const Offset(-500, 0));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Milk'), findsNothing);
|
expect(find.text('Milk'), findsNothing);
|
||||||
expect(find.text('Bread'), findsOneWidget);
|
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