From 444213ece18ccdd70c5a9d4d7cc45e4110ecb26a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 16 Mar 2026 12:39:04 +0100 Subject: [PATCH] feat(03-02): rewrite HomeScreen with daily plan UI, completion animation, empty states, and tests - Complete HomeScreen rewrite: progress card, overdue/today/tomorrow sections - Animated task completion with SizeTransition + SlideTransition on checkbox tap - "All clear" celebration state when all tasks done, "no tasks" state for first-run - Room name tags navigate to room task list via context.go - 6 widget tests covering empty, all-clear, normal state, overdue, tomorrow sections - Fixed app_shell_test to override dailyPlanProvider for new HomeScreen dependency Co-Authored-By: Claude Opus 4.6 --- .../home/presentation/home_screen.dart | 344 +++++++++++++++++- .../home/presentation/home_screen_test.dart | 248 +++++++++++++ test/shell/app_shell_test.dart | 18 +- 3 files changed, 606 insertions(+), 4 deletions(-) create mode 100644 test/features/home/presentation/home_screen_test.dart diff --git a/lib/features/home/presentation/home_screen.dart b/lib/features/home/presentation/home_screen.dart index f4e8688..a59ca2d 100644 --- a/lib/features/home/presentation/home_screen.dart +++ b/lib/features/home/presentation/home_screen.dart @@ -1,16 +1,103 @@ 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/presentation/daily_plan_providers.dart'; +import 'package:household_keeper/features/home/presentation/daily_plan_task_row.dart'; +import 'package:household_keeper/features/home/presentation/progress_card.dart'; +import 'package:household_keeper/features/tasks/presentation/task_providers.dart'; import 'package:household_keeper/l10n/app_localizations.dart'; -class HomeScreen extends StatelessWidget { +/// Warm coral/terracotta color for overdue section header. +const _overdueColor = Color(0xFFE07A5F); + +/// The app's primary screen: daily plan showing what's due today, +/// overdue tasks, and a preview of tomorrow. +/// +/// Replaces the former placeholder with a full daily workflow: +/// see what's due, check it off, feel progress. +class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); + @override + ConsumerState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends ConsumerState { + /// Task IDs currently animating out after completion. + final Set _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 dailyPlan = ref.watch(dailyPlanProvider); + return dailyPlan.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text(error.toString())), + data: (state) { + // Clean up completing IDs that are no longer in the data + _completingTaskIds.removeWhere((id) => + !state.overdueTasks.any((t) => t.task.id == id) && + !state.todayTasks.any((t) => t.task.id == id)); + + return _buildDailyPlan(context, state, l10n, theme); + }, + ); + } + + Widget _buildDailyPlan( + BuildContext context, + DailyPlanState state, + AppLocalizations l10n, + ThemeData theme, + ) { + // Case a: No tasks at all (user hasn't created any rooms/tasks) + if (state.totalTodayCount == 0 && + state.tomorrowTasks.isEmpty && + state.completedTodayCount == 0) { + return _buildNoTasksState(l10n, theme); + } + + // Case b: All clear -- there WERE tasks today but all are done + if (state.overdueTasks.isEmpty && + state.todayTasks.isEmpty && + state.completedTodayCount > 0 && + state.tomorrowTasks.isEmpty) { + return _buildAllClearState(state, l10n, theme); + } + + // Case c: Nothing today, but stuff tomorrow -- show celebration + tomorrow + if (state.overdueTasks.isEmpty && + state.todayTasks.isEmpty && + state.completedTodayCount == 0 && + state.tomorrowTasks.isNotEmpty) { + return _buildAllClearWithTomorrow(state, l10n, theme); + } + + // Case b extended: all clear with tomorrow tasks + if (state.overdueTasks.isEmpty && + state.todayTasks.isEmpty && + state.completedTodayCount > 0 && + state.tomorrowTasks.isNotEmpty) { + return _buildAllClearWithTomorrow(state, l10n, theme); + } + + // Case d: Normal state -- tasks exist + return _buildNormalState(state, l10n, theme); + } + + /// No tasks at all -- first-run empty state. + Widget _buildNoTasksState(AppLocalizations l10n, ThemeData theme) { return Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), @@ -24,7 +111,7 @@ class HomeScreen extends StatelessWidget { ), const SizedBox(height: 24), Text( - l10n.homeEmptyTitle, + l10n.dailyPlanNoTasks, style: theme.textTheme.headlineSmall, textAlign: TextAlign.center, ), @@ -46,4 +133,257 @@ class HomeScreen extends StatelessWidget { ), ); } + + /// All tasks done, no tomorrow tasks -- celebration state. + Widget _buildAllClearState( + DailyPlanState state, + AppLocalizations l10n, + ThemeData theme, + ) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ProgressCard( + completed: state.completedTodayCount, + total: state.totalTodayCount, + ), + const SizedBox(height: 16), + 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, + ), + ], + ), + ), + ); + } + + /// All clear for today but tomorrow tasks exist. + Widget _buildAllClearWithTomorrow( + DailyPlanState state, + AppLocalizations l10n, + ThemeData theme, + ) { + return ListView( + children: [ + ProgressCard( + completed: state.completedTodayCount, + total: state.totalTodayCount, + ), + const SizedBox(height: 16), + Center( + child: Column( + 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, + ), + ], + ), + ), + const SizedBox(height: 16), + _buildTomorrowSection(state, l10n, theme), + ], + ); + } + + /// Normal state with overdue/today/tomorrow sections. + Widget _buildNormalState( + DailyPlanState state, + AppLocalizations l10n, + ThemeData theme, + ) { + return ListView( + children: [ + ProgressCard( + completed: state.completedTodayCount, + total: state.totalTodayCount, + ), + // Overdue section (conditional) + if (state.overdueTasks.isNotEmpty) ...[ + _buildSectionHeader( + l10n.dailyPlanSectionOverdue, + theme, + color: _overdueColor, + ), + ...state.overdueTasks.map( + (tw) => _buildAnimatedTaskRow(tw, showCheckbox: true), + ), + ], + // Today section + _buildSectionHeader( + l10n.dailyPlanSectionToday, + theme, + color: theme.colorScheme.primary, + ), + if (state.todayTasks.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + l10n.dailyPlanAllClearMessage, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + else + ...state.todayTasks.map( + (tw) => _buildAnimatedTaskRow(tw, showCheckbox: true), + ), + // Tomorrow section (conditional, collapsed) + if (state.tomorrowTasks.isNotEmpty) + _buildTomorrowSection(state, l10n, theme), + ], + ); + } + + 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 showCheckbox, + }) { + final isCompleting = _completingTaskIds.contains(tw.task.id); + + if (isCompleting) { + return _CompletingTaskRow( + key: ValueKey('completing-${tw.task.id}'), + taskWithRoom: tw, + ); + } + + return DailyPlanTaskRow( + key: ValueKey('task-${tw.task.id}'), + taskWithRoom: tw, + showCheckbox: showCheckbox, + onCompleted: () => _onTaskCompleted(tw.task.id), + ); + } + + Widget _buildTomorrowSection( + DailyPlanState state, + AppLocalizations l10n, + ThemeData theme, + ) { + return ExpansionTile( + initiallyExpanded: false, + title: Text( + l10n.dailyPlanUpcomingCount(state.tomorrowTasks.length), + style: theme.textTheme.titleMedium, + ), + children: state.tomorrowTasks + .map( + (tw) => DailyPlanTaskRow( + key: ValueKey('tomorrow-${tw.task.id}'), + taskWithRoom: tw, + showCheckbox: false, + ), + ) + .toList(), + ); + } +} + +/// A task row that animates to zero height on completion. +class _CompletingTaskRow extends StatefulWidget { + const _CompletingTaskRow({ + super.key, + required this.taskWithRoom, + }); + + final TaskWithRoom taskWithRoom; + + @override + State<_CompletingTaskRow> createState() => _CompletingTaskRowState(); +} + +class _CompletingTaskRowState extends State<_CompletingTaskRow> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _sizeAnimation; + late final Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _sizeAnimation = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + _slideAnimation = Tween( + 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: DailyPlanTaskRow( + taskWithRoom: widget.taskWithRoom, + showCheckbox: true, + ), + ), + ); + } } diff --git a/test/features/home/presentation/home_screen_test.dart b/test/features/home/presentation/home_screen_test.dart new file mode 100644 index 0000000..b8dcad4 --- /dev/null +++ b/test/features/home/presentation/home_screen_test.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:household_keeper/core/database/database.dart'; +import 'package:household_keeper/core/router/router.dart'; +import 'package:household_keeper/features/home/domain/daily_plan_models.dart'; +import 'package:household_keeper/features/home/presentation/daily_plan_providers.dart'; +import 'package:household_keeper/features/rooms/presentation/room_providers.dart'; +import 'package:household_keeper/features/tasks/domain/effort_level.dart'; +import 'package:household_keeper/features/tasks/domain/frequency.dart'; +import 'package:household_keeper/l10n/app_localizations.dart'; + +/// Helper to create a test [Task] with sensible defaults. +Task _makeTask({ + int id = 1, + int roomId = 1, + String name = 'Test Task', + required DateTime nextDueDate, +}) { + return Task( + id: id, + roomId: roomId, + name: name, + intervalType: IntervalType.weekly, + intervalDays: 7, + effortLevel: EffortLevel.medium, + nextDueDate: nextDueDate, + createdAt: DateTime(2026, 1, 1), + ); +} + +/// Helper to create a [TaskWithRoom]. +TaskWithRoom _makeTaskWithRoom({ + int id = 1, + int roomId = 1, + String taskName = 'Test Task', + String roomName = 'Kueche', + required DateTime nextDueDate, +}) { + return TaskWithRoom( + task: _makeTask( + id: id, + roomId: roomId, + name: taskName, + nextDueDate: nextDueDate, + ), + roomName: roomName, + roomId: roomId, + ); +} + +/// Build the app with dailyPlanProvider overridden to the given state. +Widget _buildApp(DailyPlanState planState) { + return ProviderScope( + overrides: [ + dailyPlanProvider.overrideWith( + (ref) => Stream.value(planState), + ), + roomWithStatsListProvider.overrideWith( + (ref) => Stream.value([]), + ), + ], + child: MaterialApp.router( + routerConfig: router, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: const [Locale('de')], + locale: const Locale('de'), + ), + ); +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('HomeScreen empty states', () { + testWidgets('shows no-tasks empty state when no tasks exist at all', + (tester) async { + await tester.pumpWidget(_buildApp(const DailyPlanState( + overdueTasks: [], + todayTasks: [], + tomorrowTasks: [], + completedTodayCount: 0, + totalTodayCount: 0, + ))); + await tester.pumpAndSettle(); + + // Should show "Noch keine Aufgaben angelegt" (dailyPlanNoTasks) + expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget); + // Should show action button to create a room + expect(find.text('Raum erstellen'), findsOneWidget); + }); + + testWidgets('shows all-clear state when all tasks are done', + (tester) async { + await tester.pumpWidget(_buildApp(const DailyPlanState( + overdueTasks: [], + todayTasks: [], + tomorrowTasks: [], + completedTodayCount: 3, + totalTodayCount: 3, + ))); + await tester.pumpAndSettle(); + + // Should show celebration empty state + expect(find.text('Alles erledigt! \u{1F31F}'), findsOneWidget); + expect(find.byIcon(Icons.celebration_outlined), findsOneWidget); + // Progress card should show 3/3 + expect(find.text('3 von 3 erledigt'), findsOneWidget); + }); + }); + + group('HomeScreen normal state', () { + testWidgets('shows progress card with correct counts', (tester) async { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + + await tester.pumpWidget(_buildApp(DailyPlanState( + overdueTasks: [], + todayTasks: [ + _makeTaskWithRoom( + id: 1, + taskName: 'Staubsaugen', + roomName: 'Wohnzimmer', + nextDueDate: today, + ), + ], + tomorrowTasks: [], + completedTodayCount: 2, + totalTodayCount: 3, + ))); + await tester.pumpAndSettle(); + + // Progress card should show 2/3 + expect(find.text('2 von 3 erledigt'), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsOneWidget); + }); + + testWidgets('shows overdue section when overdue tasks exist', + (tester) async { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + + await tester.pumpWidget(_buildApp(DailyPlanState( + overdueTasks: [ + _makeTaskWithRoom( + id: 1, + taskName: 'Boden wischen', + roomName: 'Kueche', + nextDueDate: yesterday, + ), + ], + todayTasks: [ + _makeTaskWithRoom( + id: 2, + taskName: 'Staubsaugen', + roomName: 'Wohnzimmer', + nextDueDate: today, + ), + ], + tomorrowTasks: [], + completedTodayCount: 0, + totalTodayCount: 2, + ))); + await tester.pumpAndSettle(); + + // Should show overdue section header + expect(find.text('\u00dcberf\u00e4llig'), findsOneWidget); + // Should show today section header (may also appear as relative date) + expect(find.text('Heute'), findsAtLeast(1)); + // Should show both tasks + expect(find.text('Boden wischen'), findsOneWidget); + expect(find.text('Staubsaugen'), findsOneWidget); + // Should show room name tags + expect(find.text('Kueche'), findsOneWidget); + expect(find.text('Wohnzimmer'), findsOneWidget); + }); + + testWidgets('shows collapsed tomorrow section with count', + (tester) async { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final tomorrow = today.add(const Duration(days: 1)); + + await tester.pumpWidget(_buildApp(DailyPlanState( + overdueTasks: [], + todayTasks: [ + _makeTaskWithRoom( + id: 1, + taskName: 'Staubsaugen', + roomName: 'Wohnzimmer', + nextDueDate: today, + ), + ], + tomorrowTasks: [ + _makeTaskWithRoom( + id: 2, + taskName: 'Fenster putzen', + roomName: 'Schlafzimmer', + nextDueDate: tomorrow, + ), + _makeTaskWithRoom( + id: 3, + taskName: 'Bett beziehen', + roomName: 'Schlafzimmer', + nextDueDate: tomorrow, + ), + ], + completedTodayCount: 0, + totalTodayCount: 1, + ))); + await tester.pumpAndSettle(); + + // Should show collapsed tomorrow section with count + expect(find.text('Demn\u00e4chst (2)'), findsOneWidget); + // Tomorrow tasks should NOT be visible (collapsed by default) + expect(find.text('Fenster putzen'), findsNothing); + }); + + testWidgets('today tasks have checkboxes', (tester) async { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + + await tester.pumpWidget(_buildApp(DailyPlanState( + overdueTasks: [], + todayTasks: [ + _makeTaskWithRoom( + id: 1, + taskName: 'Staubsaugen', + roomName: 'Wohnzimmer', + nextDueDate: today, + ), + ], + tomorrowTasks: [], + completedTodayCount: 0, + totalTodayCount: 1, + ))); + await tester.pumpAndSettle(); + + // Today task should have a checkbox + expect(find.byType(Checkbox), findsOneWidget); + }); + }); +} diff --git a/test/shell/app_shell_test.dart b/test/shell/app_shell_test.dart index 1c92592..78a7c07 100644 --- a/test/shell/app_shell_test.dart +++ b/test/shell/app_shell_test.dart @@ -4,6 +4,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:household_keeper/core/router/router.dart'; +import 'package:household_keeper/features/home/domain/daily_plan_models.dart'; +import 'package:household_keeper/features/home/presentation/daily_plan_providers.dart'; import 'package:household_keeper/features/rooms/presentation/room_providers.dart'; import 'package:household_keeper/l10n/app_localizations.dart'; @@ -13,7 +15,7 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - /// Helper to build the app with room provider overridden to empty list. + /// Helper to build the app with providers overridden for testing. Widget buildApp() { return ProviderScope( overrides: [ @@ -22,6 +24,17 @@ void main() { roomWithStatsListProvider.overrideWith( (ref) => Stream.value([]), ), + // Override daily plan to return empty state so HomeScreen + // renders without a database. + dailyPlanProvider.overrideWith( + (ref) => Stream.value(const DailyPlanState( + overdueTasks: [], + todayTasks: [], + tomorrowTasks: [], + completedTodayCount: 0, + totalTodayCount: 0, + )), + ), ], child: MaterialApp.router( routerConfig: router, @@ -52,7 +65,8 @@ void main() { await tester.pumpAndSettle(); // Initially on Home tab (index 0) -- verify home empty state is shown - expect(find.text('Noch nichts zu tun!'), findsOneWidget); + // (dailyPlanNoTasks text from the daily plan empty state) + expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget); // Tap the Rooms tab (second destination) await tester.tap(find.text('R\u00e4ume'));