feat(02-03): create task providers, form screen with frequency and effort selectors
- TaskActions AsyncNotifier for create, update, delete, complete task mutations - tasksInRoomProvider manual StreamProvider.family wrapping TasksDao.watchTasksInRoom - TaskFormScreen with name, frequency (10 presets + custom), effort (3-way segmented), description, and initial due date picker (German DD.MM.YYYY format) - Custom frequency: number + unit picker (Tage/Wochen/Monate) - Calendar-anchored intervals auto-set anchorDay from due date - Edit mode loads existing task and pre-fills all fields - 19 new German localization keys for task form, delete, and empty state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,442 @@
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Placeholder for the task creation/edit form.
|
||||
/// Will be fully implemented in Plan 03.
|
||||
class TaskFormScreen extends StatelessWidget {
|
||||
const TaskFormScreen({super.key, this.roomId, this.taskId});
|
||||
import '../../../core/database/database.dart';
|
||||
import '../../../core/providers/database_provider.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../domain/effort_level.dart';
|
||||
import '../domain/frequency.dart';
|
||||
import 'task_providers.dart';
|
||||
|
||||
/// Full-screen form for task creation and editing.
|
||||
///
|
||||
/// Pass [roomId] for create mode, or [taskId] for edit mode.
|
||||
class TaskFormScreen extends ConsumerStatefulWidget {
|
||||
final int? roomId;
|
||||
final int? taskId;
|
||||
|
||||
const TaskFormScreen({super.key, this.roomId, this.taskId});
|
||||
|
||||
bool get isEditing => taskId != null;
|
||||
|
||||
@override
|
||||
ConsumerState<TaskFormScreen> createState() => _TaskFormScreenState();
|
||||
}
|
||||
|
||||
class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _customIntervalController = TextEditingController(text: '2');
|
||||
|
||||
FrequencyInterval? _selectedPreset;
|
||||
bool _isCustomFrequency = false;
|
||||
_CustomUnit _customUnit = _CustomUnit.days;
|
||||
EffortLevel _effortLevel = EffortLevel.medium;
|
||||
DateTime _dueDate = DateTime.now();
|
||||
Task? _existingTask;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedPreset = FrequencyInterval.presets[3]; // Default: weekly
|
||||
_dueDate = _dateOnly(DateTime.now());
|
||||
if (widget.isEditing) {
|
||||
_loadExistingTask();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadExistingTask() async {
|
||||
final db = ref.read(appDatabaseProvider);
|
||||
final task = await (db.select(db.tasks)
|
||||
..where((t) => t.id.equals(widget.taskId!)))
|
||||
.getSingle();
|
||||
|
||||
setState(() {
|
||||
_existingTask = task;
|
||||
_nameController.text = task.name;
|
||||
_descriptionController.text = task.description ?? '';
|
||||
_effortLevel = task.effortLevel;
|
||||
_dueDate = task.nextDueDate;
|
||||
|
||||
// Find matching preset
|
||||
_selectedPreset = null;
|
||||
_isCustomFrequency = true;
|
||||
for (final preset in FrequencyInterval.presets) {
|
||||
if (preset.intervalType == task.intervalType &&
|
||||
preset.days == task.intervalDays) {
|
||||
_selectedPreset = preset;
|
||||
_isCustomFrequency = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (_isCustomFrequency) {
|
||||
// Determine custom unit from stored interval
|
||||
switch (task.intervalType) {
|
||||
case IntervalType.everyNMonths:
|
||||
_customUnit = _CustomUnit.months;
|
||||
_customIntervalController.text = task.intervalDays.toString();
|
||||
case IntervalType.monthly:
|
||||
_customUnit = _CustomUnit.months;
|
||||
_customIntervalController.text = '1';
|
||||
case IntervalType.weekly:
|
||||
_customUnit = _CustomUnit.weeks;
|
||||
_customIntervalController.text = '1';
|
||||
case IntervalType.biweekly:
|
||||
_customUnit = _CustomUnit.weeks;
|
||||
_customIntervalController.text = '2';
|
||||
default:
|
||||
_customUnit = _CustomUnit.days;
|
||||
_customIntervalController.text = task.intervalDays.toString();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_customIntervalController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int get _roomId => widget.roomId ?? _existingTask!.roomId;
|
||||
|
||||
static DateTime _dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Aufgabe')),
|
||||
body: const Center(child: Text('Demnächst verfügbar')),
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.isEditing ? l10n.taskFormEditTitle : l10n.taskFormCreateTitle,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _isLoading ? null : _onSave,
|
||||
icon: const Icon(Icons.check),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Name field (required, autofocus)
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
autofocus: !widget.isEditing,
|
||||
maxLength: 200,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.taskFormNameLabel,
|
||||
hintText: l10n.taskFormNameHint,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return l10n.taskFormNameRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Frequency selector
|
||||
Text(
|
||||
l10n.taskFormFrequencyLabel,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildFrequencySelector(l10n, theme),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Effort selector
|
||||
Text(
|
||||
l10n.taskFormEffortLabel,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildEffortSelector(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Description (optional)
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.taskFormDescriptionLabel,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Initial due date
|
||||
Text(
|
||||
l10n.taskFormDueDateLabel,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildDueDatePicker(theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFrequencySelector(AppLocalizations l10n, ThemeData theme) {
|
||||
final items = <Widget>[];
|
||||
|
||||
// Preset intervals
|
||||
for (final preset in FrequencyInterval.presets) {
|
||||
items.add(
|
||||
ChoiceChip(
|
||||
label: Text(preset.label()),
|
||||
selected: !_isCustomFrequency && _selectedPreset == preset,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setState(() {
|
||||
_selectedPreset = preset;
|
||||
_isCustomFrequency = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Custom option
|
||||
items.add(
|
||||
ChoiceChip(
|
||||
label: Text(l10n.taskFormFrequencyCustom),
|
||||
selected: _isCustomFrequency,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setState(() {
|
||||
_isCustomFrequency = true;
|
||||
_selectedPreset = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: items,
|
||||
),
|
||||
if (_isCustomFrequency) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildCustomFrequencyInput(l10n, theme),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCustomFrequencyInput(AppLocalizations l10n, ThemeData theme) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.taskFormFrequencyEvery,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: TextFormField(
|
||||
controller: _customIntervalController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textAlign: TextAlign.center,
|
||||
decoration: const InputDecoration(
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SegmentedButton<_CustomUnit>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: _CustomUnit.days,
|
||||
label: Text(l10n.taskFormFrequencyUnitDays),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _CustomUnit.weeks,
|
||||
label: Text(l10n.taskFormFrequencyUnitWeeks),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _CustomUnit.months,
|
||||
label: Text(l10n.taskFormFrequencyUnitMonths),
|
||||
),
|
||||
],
|
||||
selected: {_customUnit},
|
||||
onSelectionChanged: (newSelection) {
|
||||
setState(() {
|
||||
_customUnit = newSelection.first;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEffortSelector() {
|
||||
return SegmentedButton<EffortLevel>(
|
||||
segments: EffortLevel.values
|
||||
.map((e) => ButtonSegment(
|
||||
value: e,
|
||||
label: Text(e.label()),
|
||||
))
|
||||
.toList(),
|
||||
selected: {_effortLevel},
|
||||
onSelectionChanged: (newSelection) {
|
||||
setState(() {
|
||||
_effortLevel = newSelection.first;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDueDatePicker(ThemeData theme) {
|
||||
final dateFormat = DateFormat('dd.MM.yyyy');
|
||||
return InkWell(
|
||||
onTap: _pickDueDate,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
suffixIcon: Icon(Icons.calendar_today),
|
||||
),
|
||||
child: Text(
|
||||
dateFormat.format(_dueDate),
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDueDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _dueDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2100),
|
||||
locale: const Locale('de'),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_dueDate = _dateOnly(picked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the frequency from either selected preset or custom input.
|
||||
({IntervalType type, int days, int? anchorDay}) _resolveFrequency() {
|
||||
if (!_isCustomFrequency && _selectedPreset != null) {
|
||||
final preset = _selectedPreset!;
|
||||
// For calendar-anchored intervals, set anchorDay to due date's day
|
||||
int? anchorDay;
|
||||
if (_isCalendarAnchored(preset.intervalType)) {
|
||||
anchorDay = _dueDate.day;
|
||||
}
|
||||
return (
|
||||
type: preset.intervalType,
|
||||
days: preset.days,
|
||||
anchorDay: anchorDay,
|
||||
);
|
||||
}
|
||||
|
||||
// Custom frequency
|
||||
final number = int.tryParse(_customIntervalController.text) ?? 1;
|
||||
switch (_customUnit) {
|
||||
case _CustomUnit.days:
|
||||
return (
|
||||
type: IntervalType.everyNDays,
|
||||
days: number,
|
||||
anchorDay: null,
|
||||
);
|
||||
case _CustomUnit.weeks:
|
||||
return (
|
||||
type: IntervalType.everyNDays,
|
||||
days: number * 7,
|
||||
anchorDay: null,
|
||||
);
|
||||
case _CustomUnit.months:
|
||||
return (
|
||||
type: IntervalType.everyNMonths,
|
||||
days: number,
|
||||
anchorDay: _dueDate.day,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCalendarAnchored(IntervalType type) {
|
||||
return type == IntervalType.monthly ||
|
||||
type == IntervalType.everyNMonths ||
|
||||
type == IntervalType.quarterly ||
|
||||
type == IntervalType.yearly;
|
||||
}
|
||||
|
||||
Future<void> _onSave() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final freq = _resolveFrequency();
|
||||
final actions = ref.read(taskActionsProvider.notifier);
|
||||
|
||||
if (widget.isEditing && _existingTask != null) {
|
||||
final updatedTask = _existingTask!.copyWith(
|
||||
name: _nameController.text.trim(),
|
||||
description: Value(_descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: _descriptionController.text.trim()),
|
||||
intervalType: freq.type,
|
||||
intervalDays: freq.days,
|
||||
anchorDay: Value(freq.anchorDay),
|
||||
effortLevel: _effortLevel,
|
||||
nextDueDate: _dueDate,
|
||||
);
|
||||
await actions.updateTask(updatedTask);
|
||||
} else {
|
||||
await actions.createTask(
|
||||
roomId: _roomId,
|
||||
name: _nameController.text.trim(),
|
||||
description: _descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: _descriptionController.text.trim(),
|
||||
intervalType: freq.type,
|
||||
intervalDays: freq.days,
|
||||
anchorDay: freq.anchorDay,
|
||||
effortLevel: _effortLevel,
|
||||
nextDueDate: _dueDate,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
context.pop();
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit options for custom frequency input.
|
||||
enum _CustomUnit { days, weeks, months }
|
||||
|
||||
Reference in New Issue
Block a user