625 lines
19 KiB
Dart
625 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../models/journal_entry.dart';
|
|
import '../models/drink.dart';
|
|
import '../models/bean.dart';
|
|
import '../models/machine.dart';
|
|
import '../models/recipe.dart';
|
|
import '../providers/app_state.dart';
|
|
import 'searchable_selection.dart';
|
|
import 'bean_dialog.dart';
|
|
import 'machine_dialog.dart';
|
|
import 'recipe_dialog.dart';
|
|
|
|
class JournalEntryDialog extends StatefulWidget {
|
|
final JournalEntry? journalEntry; // null for add, non-null for edit
|
|
|
|
const JournalEntryDialog({super.key, this.journalEntry});
|
|
|
|
@override
|
|
State<JournalEntryDialog> createState() => _JournalEntryDialogState();
|
|
}
|
|
|
|
class _JournalEntryDialogState extends State<JournalEntryDialog> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
late final TextEditingController _drinkNameController;
|
|
late final TextEditingController _drinkDetailsController;
|
|
late final TextEditingController _drinkNotesController;
|
|
late final TextEditingController _drinkSizeController;
|
|
late final TextEditingController _journalNotesController;
|
|
late final TextEditingController _moodController;
|
|
late final TextEditingController _weatherController;
|
|
|
|
late DateTime _selectedDate;
|
|
late double _rating;
|
|
late bool _isPreferred;
|
|
Bean? _selectedBean;
|
|
Machine? _selectedMachine;
|
|
Recipe? _selectedRecipe;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
final journalEntry = widget.journalEntry;
|
|
final drink = journalEntry?.drink;
|
|
|
|
_drinkNameController = TextEditingController(text: drink?.name ?? '');
|
|
_drinkDetailsController = TextEditingController(text: drink?.details ?? '');
|
|
_drinkNotesController = TextEditingController(text: drink?.notes ?? '');
|
|
_drinkSizeController = TextEditingController(text: drink?.size ?? '');
|
|
_journalNotesController = TextEditingController(text: journalEntry?.notes ?? '');
|
|
_moodController = TextEditingController(text: journalEntry?.mood ?? '');
|
|
_weatherController = TextEditingController(text: journalEntry?.weather ?? '');
|
|
|
|
_selectedDate = journalEntry?.date ?? DateTime.now();
|
|
_rating = drink?.rating ?? 3.0;
|
|
_isPreferred = drink?.preferred ?? false;
|
|
_selectedBean = drink?.bean;
|
|
_selectedMachine = drink?.machine;
|
|
_selectedRecipe = drink?.recipe;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_drinkNameController.dispose();
|
|
_drinkDetailsController.dispose();
|
|
_drinkNotesController.dispose();
|
|
_drinkSizeController.dispose();
|
|
_journalNotesController.dispose();
|
|
_moodController.dispose();
|
|
_weatherController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Dialog(
|
|
child: Container(
|
|
width: MediaQuery.of(context).size.width > 600 ? 600 : double.infinity,
|
|
height: MediaQuery.of(context).size.height * 0.9,
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
children: [
|
|
// Header
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.book,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
size: 28,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
widget.journalEntry == null ? 'Add Journal Entry' : 'Edit Journal Entry',
|
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
),
|
|
const Divider(),
|
|
// Form
|
|
Expanded(
|
|
child: Form(
|
|
key: _formKey,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildDateSection(),
|
|
const SizedBox(height: 24),
|
|
_buildDrinkInfoSection(),
|
|
const SizedBox(height: 24),
|
|
_buildRatingSection(),
|
|
const SizedBox(height: 24),
|
|
_buildReferencesSection(),
|
|
const SizedBox(height: 24),
|
|
_buildJournalSection(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Actions
|
|
const Divider(),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
const SizedBox(width: 12),
|
|
ElevatedButton(
|
|
onPressed: _saveJournalEntry,
|
|
child: Text(widget.journalEntry == null ? 'Add Entry' : 'Update Entry'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDateSection() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Entry Date',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
InkWell(
|
|
onTap: _selectDate,
|
|
child: InputDecorator(
|
|
decoration: const InputDecoration(
|
|
labelText: 'Date',
|
|
border: OutlineInputBorder(),
|
|
suffixIcon: Icon(Icons.calendar_today),
|
|
),
|
|
child: Text(_formatDate(_selectedDate)),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildDrinkInfoSection() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Drink Information',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _drinkNameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Drink Name *',
|
|
hintText: 'e.g., Morning Latte, Afternoon Espresso',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Drink name is required';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _drinkSizeController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Size',
|
|
hintText: 'e.g., 8oz, 12oz, Large',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: SwitchListTile(
|
|
title: const Text('Preferred'),
|
|
value: _isPreferred,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_isPreferred = value;
|
|
});
|
|
},
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _drinkDetailsController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Details',
|
|
hintText: 'Additional details about the drink',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
maxLines: 2,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _drinkNotesController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Drink Notes',
|
|
hintText: 'Notes about the drink preparation or taste',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
maxLines: 2,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildRatingSection() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Rating',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
const Text('Rating:'),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Slider(
|
|
value: _rating,
|
|
min: 1.0,
|
|
max: 5.0,
|
|
divisions: 8,
|
|
label: '${_rating.toStringAsFixed(1)} stars',
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_rating = value;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Row(
|
|
children: List.generate(5, (index) {
|
|
return Icon(
|
|
index < _rating.floor()
|
|
? Icons.star
|
|
: index < _rating
|
|
? Icons.star_half
|
|
: Icons.star_border,
|
|
color: Colors.amber,
|
|
size: 20,
|
|
);
|
|
}),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildReferencesSection() {
|
|
return Consumer<AppState>(
|
|
builder: (context, appState, child) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'References (Optional)',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Bean Selection
|
|
_buildSelectionField(
|
|
label: 'Bean Used',
|
|
value: _selectedBean?.name ?? 'No bean selected',
|
|
onTap: () => _selectBean(context),
|
|
icon: Icons.coffee_outlined,
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Machine Selection
|
|
_buildSelectionField(
|
|
label: 'Machine Used',
|
|
value: _selectedMachine != null
|
|
? '${_selectedMachine!.manufacturer} ${_selectedMachine!.model}'
|
|
: 'No machine selected',
|
|
onTap: () => _selectMachine(context),
|
|
icon: Icons.coffee_maker,
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Recipe Selection
|
|
_buildSelectionField(
|
|
label: 'Recipe Used',
|
|
value: _selectedRecipe?.name ?? 'No recipe selected',
|
|
onTap: () => _selectRecipe(context),
|
|
icon: Icons.menu_book,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildJournalSection() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Journal Notes',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _journalNotesController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Journal Notes',
|
|
hintText: 'Your thoughts, feelings, or observations about this coffee experience...',
|
|
border: OutlineInputBorder(),
|
|
alignLabelWithHint: true,
|
|
),
|
|
maxLines: 4,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _moodController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Mood',
|
|
hintText: 'e.g., Energetic, Relaxed',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _weatherController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Weather',
|
|
hintText: 'e.g., Sunny, Rainy',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
String _formatDate(DateTime date) {
|
|
return '${date.day}/${date.month}/${date.year}';
|
|
}
|
|
|
|
Future<void> _selectDate() async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: _selectedDate,
|
|
firstDate: DateTime.now().subtract(const Duration(days: 365 * 2)),
|
|
lastDate: DateTime.now(),
|
|
);
|
|
if (picked != null) {
|
|
setState(() {
|
|
_selectedDate = picked;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _saveJournalEntry() async {
|
|
if (!_formKey.currentState!.validate()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Create the drink
|
|
final drink = Drink(
|
|
id: widget.journalEntry?.drink.id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
|
name: _drinkNameController.text.trim(),
|
|
details: _drinkDetailsController.text.trim(),
|
|
notes: _drinkNotesController.text.trim(),
|
|
preferred: _isPreferred,
|
|
rating: _rating,
|
|
size: _drinkSizeController.text.trim(),
|
|
bean: _selectedBean,
|
|
machine: _selectedMachine,
|
|
recipe: _selectedRecipe,
|
|
dateCreated: _selectedDate,
|
|
);
|
|
|
|
// Create the journal entry
|
|
final journalEntry = JournalEntry(
|
|
id: widget.journalEntry?.id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
|
date: _selectedDate,
|
|
drink: drink,
|
|
notes: _journalNotesController.text.trim().isEmpty ? null : _journalNotesController.text.trim(),
|
|
mood: _moodController.text.trim().isEmpty ? null : _moodController.text.trim(),
|
|
weather: _weatherController.text.trim().isEmpty ? null : _weatherController.text.trim(),
|
|
);
|
|
|
|
final appState = Provider.of<AppState>(context, listen: false);
|
|
|
|
// Save or update the drink first
|
|
if (widget.journalEntry == null) {
|
|
await appState.addDrink(drink);
|
|
await appState.addJournalEntry(journalEntry);
|
|
} else {
|
|
await appState.updateDrink(drink);
|
|
await appState.updateJournalEntry(journalEntry);
|
|
}
|
|
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(widget.journalEntry == null
|
|
? 'Journal entry added successfully!'
|
|
: 'Journal entry updated successfully!'),
|
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Error saving journal entry: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Widget _buildSelectionField({
|
|
required String label,
|
|
required String value,
|
|
required VoidCallback onTap,
|
|
required IconData icon,
|
|
}) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.grey),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
padding: const EdgeInsets.all(12),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, size: 20),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
value,
|
|
style: const TextStyle(fontSize: 16),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(Icons.arrow_forward_ios, size: 16),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _selectBean(BuildContext context) {
|
|
final appState = Provider.of<AppState>(context, listen: false);
|
|
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => SearchableSelection<Bean>(
|
|
items: appState.beans,
|
|
title: 'Select Bean',
|
|
searchHint: 'Search beans...',
|
|
displayText: (bean) => bean.name,
|
|
onItemSelected: (bean) {
|
|
setState(() {
|
|
_selectedBean = bean;
|
|
});
|
|
Navigator.of(context).pop();
|
|
},
|
|
onAddCustom: () {
|
|
Navigator.of(context).pop();
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => const BeanDialog(),
|
|
).then((_) {
|
|
// Refresh the state after adding a new bean
|
|
if (mounted) {
|
|
setState(() {});
|
|
}
|
|
});
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _selectMachine(BuildContext context) {
|
|
final appState = Provider.of<AppState>(context, listen: false);
|
|
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => SearchableSelection<Machine>(
|
|
items: appState.machines,
|
|
title: 'Select Machine',
|
|
searchHint: 'Search machines...',
|
|
displayText: (machine) => '${machine.manufacturer} ${machine.model}',
|
|
onItemSelected: (machine) {
|
|
setState(() {
|
|
_selectedMachine = machine;
|
|
});
|
|
Navigator.of(context).pop();
|
|
},
|
|
onAddCustom: () {
|
|
Navigator.of(context).pop();
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => const MachineDialog(),
|
|
).then((_) {
|
|
// Refresh the state after adding a new machine
|
|
if (mounted) {
|
|
setState(() {});
|
|
}
|
|
});
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _selectRecipe(BuildContext context) {
|
|
final appState = Provider.of<AppState>(context, listen: false);
|
|
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => SearchableSelection<Recipe>(
|
|
items: appState.recipes,
|
|
title: 'Select Recipe',
|
|
searchHint: 'Search recipes...',
|
|
displayText: (recipe) => recipe.name,
|
|
onItemSelected: (recipe) {
|
|
setState(() {
|
|
_selectedRecipe = recipe;
|
|
});
|
|
Navigator.of(context).pop();
|
|
},
|
|
onAddCustom: () {
|
|
Navigator.of(context).pop();
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => const RecipeDialog(),
|
|
).then((_) {
|
|
// Refresh the state after adding a new recipe
|
|
if (mounted) {
|
|
setState(() {});
|
|
}
|
|
});
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|