timetracker/lib/features/entries/presentation/widgets/entry_form_sheet.dart
2026-08-03 21:51:48 +02:00

246 lines
7.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timetrack/features/entries/domain/entries_provider.dart';
import 'package:timetrack/features/entries/domain/time_entry.dart';
import 'package:timetrack/features/projects/domain/project.dart';
import 'package:timetrack/features/projects/domain/projects_provider.dart';
class EntryFormSheet extends ConsumerStatefulWidget {
const EntryFormSheet({super.key, this.existing});
final TimeEntry? existing;
static Future<void> show(BuildContext context, {TimeEntry? existing}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => EntryFormSheet(existing: existing),
);
}
@override
ConsumerState<EntryFormSheet> createState() => _EntryFormSheetState();
}
class _EntryFormSheetState extends ConsumerState<EntryFormSheet> {
Project? _project;
late DateTime _startTime;
late DateTime _endTime;
final _noteController = TextEditingController();
String? _error;
@override
void initState() {
super.initState();
final now = DateTime.now();
_startTime = widget.existing?.startTime ?? now;
_endTime = widget.existing?.endTime ?? now;
_noteController.text = widget.existing?.note ?? '';
}
@override
void dispose() {
_noteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final projectsAsync = ref.watch(activeProjectsProvider);
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
top: 16,
left: 16,
right: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.existing == null ? 'Add Entry' : 'Edit Entry',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
// Project picker
projectsAsync.when(
data: (projects) => DropdownButtonFormField<Project>(
initialValue: _project ??
(widget.existing != null
? projects
.where((p) => p.id == widget.existing!.projectId)
.firstOrNull
: null),
items: projects
.map((p) => DropdownMenuItem(
value: p,
child: Row(
children: [
CircleAvatar(
backgroundColor: Color(p.colorValue),
radius: 8,
),
const SizedBox(width: 8),
Text(p.name),
],
),
))
.toList(),
onChanged: (p) => setState(() => _project = p),
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Project',
),
),
loading: () => const LinearProgressIndicator(),
error: (e, _) => Text('Error: $e'),
),
const SizedBox(height: 12),
// Start / End time
Row(
children: [
Expanded(
child: _TimeField(
label: 'Start',
value: _startTime,
onChanged: (dt) => setState(() => _startTime = dt),
),
),
const SizedBox(width: 12),
Expanded(
child: _TimeField(
label: 'End',
value: _endTime,
onChanged: (dt) => setState(() => _endTime = dt),
),
),
],
),
const SizedBox(height: 12),
// Note
TextField(
controller: _noteController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Note (optional)',
),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _save,
child: const Text('Save'),
),
],
),
],
),
);
}
Future<void> _save() async {
if (_project == null) {
setState(() => _error = 'Please select a project.');
return;
}
if (!_endTime.isAfter(_startTime)) {
setState(() => _error = 'End time must be after start time.');
return;
}
final duration = _endTime.difference(_startTime);
final notifier = ref.read(entriesNotifierProvider.notifier);
final now = DateTime.now();
if (widget.existing == null) {
await notifier.create(TimeEntry(
id: 0,
projectId: _project!.id,
startTime: _startTime,
endTime: _endTime,
durationSeconds: duration.inSeconds,
note: _noteController.text.trim().isEmpty
? null
: _noteController.text.trim(),
createdAt: now,
));
} else {
await notifier.updateEntry(widget.existing!.copyWith(
projectId: _project!.id,
startTime: _startTime,
endTime: _endTime,
durationSeconds: duration.inSeconds,
note: _noteController.text.trim().isEmpty
? null
: _noteController.text.trim(),
));
}
if (mounted) Navigator.pop(context);
}
}
class _TimeField extends StatelessWidget {
const _TimeField({
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final DateTime value;
final ValueChanged<DateTime> onChanged;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: value,
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 1)),
);
if (date == null || !context.mounted) return;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(value),
);
if (time == null) return;
onChanged(DateTime(date.year, date.month, date.day, time.hour, time.minute));
},
child: InputDecorator(
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: label,
),
child: Text(
'${value.day.toString().padLeft(2, '0')}.${value.month.toString().padLeft(2, '0')} '
'${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}',
),
),
);
}
}