488 lines
16 KiB
Dart
488 lines
16 KiB
Dart
/// 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'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|