- Add totalTaskCount field to CalendarDayState to distinguish first-run from celebration - Add getTaskCount() to CalendarDao (SELECT COUNT from tasks) - CalendarStrip: 181-day horizontal scroll with German abbreviations, today highlighting, month boundary labels, scroll-to-today controller - CalendarTaskRow: task name + room tag chip + checkbox, no relative date, isOverdue coral styling - CalendarDayList: loading/error/first-run-empty/empty-day/celebration/has-tasks states, overdue section (today only), slide-out completion animation - Update home_screen_test.dart and app_shell_test.dart to test new calendar providers instead of dailyPlanProvider
311 lines
9.4 KiB
Dart
311 lines
9.4 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
|
import 'package:household_keeper/features/home/domain/calendar_models.dart';
|
|
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
|
|
import 'package:household_keeper/features/home/presentation/calendar_task_row.dart';
|
|
import 'package:household_keeper/features/tasks/presentation/task_providers.dart';
|
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
|
|
|
/// Warm coral/terracotta color for overdue section header.
|
|
const _overdueColor = Color(0xFFE07A5F);
|
|
|
|
/// Shows the task list for the selected calendar day.
|
|
///
|
|
/// Watches [calendarDayProvider] and renders one of several states:
|
|
/// - Loading spinner while data loads
|
|
/// - Error text on failure
|
|
/// - First-run empty state (no rooms/tasks at all) — prompts to create a room
|
|
/// - Empty day state (tasks exist elsewhere but not this day)
|
|
/// - Celebration state (today is selected and all tasks are done)
|
|
/// - Has-tasks state with optional overdue section (today only) and checkboxes
|
|
class CalendarDayList extends ConsumerStatefulWidget {
|
|
const CalendarDayList({super.key});
|
|
|
|
@override
|
|
ConsumerState<CalendarDayList> createState() => _CalendarDayListState();
|
|
}
|
|
|
|
class _CalendarDayListState extends ConsumerState<CalendarDayList> {
|
|
/// Task IDs currently animating out after completion.
|
|
final Set<int> _completingTaskIds = {};
|
|
|
|
void _onTaskCompleted(int taskId) {
|
|
setState(() {
|
|
_completingTaskIds.add(taskId);
|
|
});
|
|
ref.read(taskActionsProvider.notifier).completeTask(taskId);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final theme = Theme.of(context);
|
|
final dayState = ref.watch(calendarDayProvider);
|
|
|
|
return dayState.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (error, _) => Center(child: Text(error.toString())),
|
|
data: (state) {
|
|
// Clean up animation IDs for tasks that are no longer in the data.
|
|
_completingTaskIds.removeWhere((id) =>
|
|
!state.overdueTasks.any((t) => t.task.id == id) &&
|
|
!state.dayTasks.any((t) => t.task.id == id));
|
|
|
|
return _buildContent(context, state, l10n, theme);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildContent(
|
|
BuildContext context,
|
|
CalendarDayState state,
|
|
AppLocalizations l10n,
|
|
ThemeData theme,
|
|
) {
|
|
final now = DateTime.now();
|
|
final today = DateTime(now.year, now.month, now.day);
|
|
final isToday = state.selectedDate == today;
|
|
|
|
// State (a): First-run empty — no tasks exist at all in the database.
|
|
if (state.isEmpty && state.totalTaskCount == 0) {
|
|
return _buildFirstRunEmpty(context, l10n, theme);
|
|
}
|
|
|
|
// State (e): Celebration — today is selected and all tasks are done
|
|
// (totalTaskCount > 0 so at least some task exists somewhere, but today
|
|
// has none remaining after completion).
|
|
if (isToday && state.dayTasks.isEmpty && state.overdueTasks.isEmpty && state.totalTaskCount > 0) {
|
|
return _buildCelebration(l10n, theme);
|
|
}
|
|
|
|
// State (d): Empty day — tasks exist elsewhere but not this day.
|
|
if (state.isEmpty) {
|
|
return _buildEmptyDay(theme);
|
|
}
|
|
|
|
// State (f): Has tasks — render overdue section (today only) + day tasks.
|
|
return _buildTaskList(state, l10n, theme);
|
|
}
|
|
|
|
/// First-run: no rooms/tasks created yet.
|
|
Widget _buildFirstRunEmpty(
|
|
BuildContext context,
|
|
AppLocalizations l10n,
|
|
ThemeData theme,
|
|
) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.checklist_rounded,
|
|
size: 80,
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Text(
|
|
l10n.dailyPlanNoTasks,
|
|
style: theme.textTheme.headlineSmall,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.homeEmptyMessage,
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 24),
|
|
FilledButton.tonal(
|
|
onPressed: () => context.go('/rooms'),
|
|
child: Text(l10n.homeEmptyAction),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Celebration state: today is selected and all tasks are done.
|
|
Widget _buildCelebration(AppLocalizations l10n, ThemeData theme) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.celebration_outlined,
|
|
size: 80,
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Text(
|
|
l10n.dailyPlanAllClearTitle,
|
|
style: theme.textTheme.headlineSmall,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.dailyPlanAllClearMessage,
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Empty day: tasks exist elsewhere but nothing scheduled for this day.
|
|
Widget _buildEmptyDay(ThemeData theme) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.event_available,
|
|
size: 48,
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.3),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'Keine Aufgaben',
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Task list with optional overdue section.
|
|
Widget _buildTaskList(
|
|
CalendarDayState state,
|
|
AppLocalizations l10n,
|
|
ThemeData theme,
|
|
) {
|
|
final items = <Widget>[];
|
|
|
|
// Overdue section (today only, when overdue tasks exist).
|
|
if (state.overdueTasks.isNotEmpty) {
|
|
items.add(_buildSectionHeader(l10n.dailyPlanSectionOverdue, theme,
|
|
color: _overdueColor));
|
|
for (final tw in state.overdueTasks) {
|
|
items.add(_buildAnimatedTaskRow(tw, isOverdue: true));
|
|
}
|
|
}
|
|
|
|
// Day tasks section.
|
|
for (final tw in state.dayTasks) {
|
|
items.add(_buildAnimatedTaskRow(tw, isOverdue: false));
|
|
}
|
|
|
|
return ListView(children: items);
|
|
}
|
|
|
|
Widget _buildSectionHeader(
|
|
String title,
|
|
ThemeData theme, {
|
|
required Color color,
|
|
}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Text(
|
|
title,
|
|
style: theme.textTheme.titleMedium?.copyWith(color: color),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAnimatedTaskRow(TaskWithRoom tw, {required bool isOverdue}) {
|
|
final isCompleting = _completingTaskIds.contains(tw.task.id);
|
|
|
|
if (isCompleting) {
|
|
return _CompletingTaskRow(
|
|
key: ValueKey('completing-${tw.task.id}'),
|
|
taskWithRoom: tw,
|
|
isOverdue: isOverdue,
|
|
);
|
|
}
|
|
|
|
return CalendarTaskRow(
|
|
key: ValueKey('task-${tw.task.id}'),
|
|
taskWithRoom: tw,
|
|
isOverdue: isOverdue,
|
|
onCompleted: () => _onTaskCompleted(tw.task.id),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A task row that animates (slide + size) to zero height on completion.
|
|
class _CompletingTaskRow extends StatefulWidget {
|
|
const _CompletingTaskRow({
|
|
super.key,
|
|
required this.taskWithRoom,
|
|
required this.isOverdue,
|
|
});
|
|
|
|
final TaskWithRoom taskWithRoom;
|
|
final bool isOverdue;
|
|
|
|
@override
|
|
State<_CompletingTaskRow> createState() => _CompletingTaskRowState();
|
|
}
|
|
|
|
class _CompletingTaskRowState extends State<_CompletingTaskRow>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AnimationController _controller;
|
|
late final Animation<double> _sizeAnimation;
|
|
late final Animation<Offset> _slideAnimation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
duration: const Duration(milliseconds: 300),
|
|
vsync: this,
|
|
);
|
|
_sizeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
|
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
|
);
|
|
_slideAnimation = Tween<Offset>(
|
|
begin: Offset.zero,
|
|
end: const Offset(1.0, 0.0),
|
|
).animate(
|
|
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
|
);
|
|
_controller.forward();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizeTransition(
|
|
sizeFactor: _sizeAnimation,
|
|
child: SlideTransition(
|
|
position: _slideAnimation,
|
|
child: CalendarTaskRow(
|
|
taskWithRoom: widget.taskWithRoom,
|
|
isOverdue: widget.isOverdue,
|
|
onCompleted: () {}, // Already completing — ignore repeat taps.
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|