246 lines
7.3 KiB
Dart
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')}',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|