added categories, import export, settings
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user