- Replace 10-chip grid + hidden Custom mode with 4 shortcut chips (Täglich, Wöchentlich, Alle 2 Wochen, Monatlich) - Always-visible freeform 'Alle [N] [Tage/Wochen/Monate]' picker row below chips - Bidirectional sync: tapping chip populates picker; editing picker recalculates chip highlight - _resolveFrequency() now reads exclusively from picker (single source of truth) - Edit mode correctly loads all 8 IntervalType values including quarterly and yearly - Add l10n keys frequencyShortcutDaily/Weekly/Biweekly/Monthly to app_de.arb
536 lines
17 KiB
Dart
536 lines
17 KiB
Dart
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';
|
|
|
|
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_history_sheet.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: '1');
|
|
|
|
_ShortcutFrequency? _activeShortcut;
|
|
_CustomUnit _customUnit = _CustomUnit.weeks;
|
|
EffortLevel _effortLevel = EffortLevel.medium;
|
|
DateTime _dueDate = DateTime.now();
|
|
Task? _existingTask;
|
|
bool _isLoading = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_activeShortcut = _ShortcutFrequency.weekly;
|
|
_customIntervalController.text = '1';
|
|
_customUnit = _CustomUnit.weeks;
|
|
_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;
|
|
|
|
// Populate picker from stored interval
|
|
switch (task.intervalType) {
|
|
case IntervalType.daily:
|
|
_customUnit = _CustomUnit.days;
|
|
_customIntervalController.text = '1';
|
|
case IntervalType.everyNDays:
|
|
// Check if it's a clean week multiple
|
|
if (task.intervalDays % 7 == 0) {
|
|
_customUnit = _CustomUnit.weeks;
|
|
_customIntervalController.text = (task.intervalDays ~/ 7).toString();
|
|
} else {
|
|
_customUnit = _CustomUnit.days;
|
|
_customIntervalController.text = task.intervalDays.toString();
|
|
}
|
|
case IntervalType.weekly:
|
|
_customUnit = _CustomUnit.weeks;
|
|
_customIntervalController.text = '1';
|
|
case IntervalType.biweekly:
|
|
_customUnit = _CustomUnit.weeks;
|
|
_customIntervalController.text = '2';
|
|
case IntervalType.monthly:
|
|
_customUnit = _CustomUnit.months;
|
|
_customIntervalController.text = '1';
|
|
case IntervalType.everyNMonths:
|
|
_customUnit = _CustomUnit.months;
|
|
_customIntervalController.text = task.intervalDays.toString();
|
|
case IntervalType.quarterly:
|
|
_customUnit = _CustomUnit.months;
|
|
_customIntervalController.text = '3';
|
|
case IntervalType.yearly:
|
|
_customUnit = _CustomUnit.months;
|
|
_customIntervalController.text = '12';
|
|
}
|
|
|
|
// Detect matching shortcut chip
|
|
_activeShortcut = _ShortcutFrequency.fromPickerValues(
|
|
int.tryParse(_customIntervalController.text) ?? 1,
|
|
_customUnit,
|
|
);
|
|
});
|
|
}
|
|
|
|
@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: 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),
|
|
|
|
// History section (edit mode only)
|
|
if (widget.isEditing) ...[
|
|
const SizedBox(height: 24),
|
|
const Divider(),
|
|
ListTile(
|
|
leading: const Icon(Icons.history),
|
|
title: Text(l10n.taskHistoryTitle),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => showTaskHistorySheet(
|
|
context: context,
|
|
taskId: widget.taskId!,
|
|
),
|
|
),
|
|
// DELETE BUTTON
|
|
const Divider(),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton.icon(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: theme.colorScheme.error,
|
|
foregroundColor: theme.colorScheme.onError,
|
|
),
|
|
onPressed: _isLoading ? null : _onDelete,
|
|
icon: const Icon(Icons.delete_outline),
|
|
label: Text(l10n.taskDeleteConfirmAction),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFrequencySelector(AppLocalizations l10n, ThemeData theme) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Shortcut chips row (always visible)
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 4,
|
|
children: [
|
|
for (final shortcut in _ShortcutFrequency.values)
|
|
ChoiceChip(
|
|
label: Text(_shortcutLabel(shortcut, l10n)),
|
|
selected: _activeShortcut == shortcut,
|
|
onSelected: (selected) {
|
|
if (selected) {
|
|
final values = shortcut.toPickerValues();
|
|
setState(() {
|
|
_activeShortcut = shortcut;
|
|
_customIntervalController.text = values.number.toString();
|
|
_customUnit = values.unit;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
// Freeform picker row (ALWAYS visible — not conditional)
|
|
_buildFrequencyPickerRow(l10n, theme),
|
|
],
|
|
);
|
|
}
|
|
|
|
String _shortcutLabel(_ShortcutFrequency shortcut, AppLocalizations l10n) {
|
|
switch (shortcut) {
|
|
case _ShortcutFrequency.daily:
|
|
return l10n.frequencyShortcutDaily;
|
|
case _ShortcutFrequency.weekly:
|
|
return l10n.frequencyShortcutWeekly;
|
|
case _ShortcutFrequency.biweekly:
|
|
return l10n.frequencyShortcutBiweekly;
|
|
case _ShortcutFrequency.monthly:
|
|
return l10n.frequencyShortcutMonthly;
|
|
}
|
|
}
|
|
|
|
Widget _buildFrequencyPickerRow(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),
|
|
),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_activeShortcut = _ShortcutFrequency.fromPickerValues(
|
|
int.tryParse(value) ?? 1,
|
|
_customUnit,
|
|
);
|
|
});
|
|
},
|
|
),
|
|
),
|
|
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) {
|
|
final newUnit = newSelection.first;
|
|
setState(() {
|
|
_customUnit = newUnit;
|
|
_activeShortcut = _ShortcutFrequency.fromPickerValues(
|
|
int.tryParse(_customIntervalController.text) ?? 1,
|
|
newUnit,
|
|
);
|
|
});
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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 the freeform picker (single source of truth).
|
|
/// The picker is always the source of truth; shortcut chips just populate it.
|
|
({IntervalType type, int days, int? anchorDay}) _resolveFrequency() {
|
|
final number = int.tryParse(_customIntervalController.text) ?? 1;
|
|
switch (_customUnit) {
|
|
case _CustomUnit.days:
|
|
if (number == 1) {
|
|
return (type: IntervalType.daily, days: 1, anchorDay: null);
|
|
}
|
|
return (type: IntervalType.everyNDays, days: number, anchorDay: null);
|
|
case _CustomUnit.weeks:
|
|
if (number == 1) {
|
|
return (type: IntervalType.weekly, days: 1, anchorDay: null);
|
|
}
|
|
if (number == 2) {
|
|
return (type: IntervalType.biweekly, days: 14, anchorDay: null);
|
|
}
|
|
return (type: IntervalType.everyNDays, days: number * 7, anchorDay: null);
|
|
case _CustomUnit.months:
|
|
if (number == 1) {
|
|
return (type: IntervalType.monthly, days: 1, anchorDay: _dueDate.day);
|
|
}
|
|
return (type: IntervalType.everyNMonths, days: number, anchorDay: _dueDate.day);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _onDelete() async {
|
|
final l10n = AppLocalizations.of(context);
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(l10n.taskDeleteConfirmTitle),
|
|
content: Text(l10n.taskDeleteConfirmMessage),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(l10n.cancel),
|
|
),
|
|
FilledButton(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
|
),
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(l10n.taskDeleteConfirmAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed != true || !mounted) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
try {
|
|
await ref.read(taskActionsProvider.notifier).smartDeleteTask(widget.taskId!);
|
|
if (mounted) {
|
|
context.pop();
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Shortcut frequency options for quick selection chips.
|
|
enum _ShortcutFrequency {
|
|
daily,
|
|
weekly,
|
|
biweekly,
|
|
monthly;
|
|
|
|
/// Returns the picker values (number + unit) that this shortcut represents.
|
|
({int number, _CustomUnit unit}) toPickerValues() {
|
|
switch (this) {
|
|
case _ShortcutFrequency.daily:
|
|
return (number: 1, unit: _CustomUnit.days);
|
|
case _ShortcutFrequency.weekly:
|
|
return (number: 1, unit: _CustomUnit.weeks);
|
|
case _ShortcutFrequency.biweekly:
|
|
return (number: 2, unit: _CustomUnit.weeks);
|
|
case _ShortcutFrequency.monthly:
|
|
return (number: 1, unit: _CustomUnit.months);
|
|
}
|
|
}
|
|
|
|
/// Returns the matching shortcut for given picker values, or null if no match.
|
|
static _ShortcutFrequency? fromPickerValues(int number, _CustomUnit unit) {
|
|
if (number == 1 && unit == _CustomUnit.days) return _ShortcutFrequency.daily;
|
|
if (number == 1 && unit == _CustomUnit.weeks) return _ShortcutFrequency.weekly;
|
|
if (number == 2 && unit == _CustomUnit.weeks) return _ShortcutFrequency.biweekly;
|
|
if (number == 1 && unit == _CustomUnit.months) return _ShortcutFrequency.monthly;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Unit options for freeform frequency picker.
|
|
enum _CustomUnit { days, weeks, months }
|