Files
receipity/lib/product_detail_screen.dart
T

488 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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'),
),
],
);
}
}