CoffeeAtHome/lib/components/journal_entry_dialog.dart
2026-03-29 08:13:38 -07:00

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(() {});
}
});
},
),
),
);
}
}