feat(05-02): build CalendarStrip, CalendarTaskRow, CalendarDayList widgets
- 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
This commit is contained in:
@@ -45,6 +45,16 @@ class CalendarDao extends DatabaseAccessor<AppDatabase>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the total count of tasks across all rooms and dates.
|
||||||
|
///
|
||||||
|
/// Used by the UI to distinguish first-run empty state from celebration state.
|
||||||
|
Future<int> getTaskCount() async {
|
||||||
|
final countExp = tasks.id.count();
|
||||||
|
final query = selectOnly(tasks)..addColumns([countExp]);
|
||||||
|
final result = await query.getSingle();
|
||||||
|
return result.read(countExp) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// Watch tasks whose [nextDueDate] is strictly before [referenceDate].
|
/// Watch tasks whose [nextDueDate] is strictly before [referenceDate].
|
||||||
///
|
///
|
||||||
/// Returns tasks sorted by [nextDueDate] ascending (oldest first).
|
/// Returns tasks sorted by [nextDueDate] ascending (oldest first).
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ class CalendarDayState {
|
|||||||
final List<TaskWithRoom> dayTasks;
|
final List<TaskWithRoom> dayTasks;
|
||||||
final List<TaskWithRoom> overdueTasks;
|
final List<TaskWithRoom> overdueTasks;
|
||||||
|
|
||||||
|
/// Total number of tasks in the database (across all days/rooms).
|
||||||
|
/// Used by the UI to distinguish first-run empty state (no tasks exist at all)
|
||||||
|
/// from celebration state (tasks exist but today's are all done).
|
||||||
|
final int totalTaskCount;
|
||||||
|
|
||||||
const CalendarDayState({
|
const CalendarDayState({
|
||||||
required this.selectedDate,
|
required this.selectedDate,
|
||||||
required this.dayTasks,
|
required this.dayTasks,
|
||||||
required this.overdueTasks,
|
required this.overdueTasks,
|
||||||
|
required this.totalTaskCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// True when both day tasks and overdue tasks are empty.
|
/// True when both day tasks and overdue tasks are empty.
|
||||||
|
|||||||
310
lib/features/home/presentation/calendar_day_list.dart
Normal file
310
lib/features/home/presentation/calendar_day_list.dart
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
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.
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,10 +57,13 @@ final calendarDayProvider =
|
|||||||
overdueTasks = const [];
|
overdueTasks = const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final totalTaskCount = await db.calendarDao.getTaskCount();
|
||||||
|
|
||||||
return CalendarDayState(
|
return CalendarDayState(
|
||||||
selectedDate: selectedDate,
|
selectedDate: selectedDate,
|
||||||
dayTasks: dayTasks,
|
dayTasks: dayTasks,
|
||||||
overdueTasks: overdueTasks,
|
overdueTasks: overdueTasks,
|
||||||
|
totalTaskCount: totalTaskCount,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
348
lib/features/home/presentation/calendar_strip.dart
Normal file
348
lib/features/home/presentation/calendar_strip.dart
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
|
||||||
|
|
||||||
|
/// Number of days in the past and future to show in the strip.
|
||||||
|
const _kPastDays = 90;
|
||||||
|
const _kFutureDays = 90;
|
||||||
|
|
||||||
|
/// Total number of day cards in the strip.
|
||||||
|
const _kTotalDays = _kPastDays + 1 + _kFutureDays;
|
||||||
|
|
||||||
|
/// Fixed card width and height for each day card.
|
||||||
|
const _kCardWidth = 56.0;
|
||||||
|
const _kCardHeight = 72.0;
|
||||||
|
|
||||||
|
/// Default horizontal margin between cards.
|
||||||
|
const _kCardMargin = 4.0;
|
||||||
|
|
||||||
|
/// Wider gap inserted at month boundaries (left side margin of the first-of-month card).
|
||||||
|
const _kMonthBoundaryGap = 16.0;
|
||||||
|
|
||||||
|
/// Controller that allows external code (e.g. the Today button) to trigger
|
||||||
|
/// a scroll-to-today animation on the strip.
|
||||||
|
class CalendarStripController {
|
||||||
|
VoidCallback? _scrollToToday;
|
||||||
|
|
||||||
|
/// Animate the strip to center today's card.
|
||||||
|
void scrollToToday() => _scrollToToday?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A horizontal scrollable strip of day cards spanning [_kPastDays] days in the
|
||||||
|
/// past and [_kFutureDays] days in the future.
|
||||||
|
///
|
||||||
|
/// Each card shows:
|
||||||
|
/// - German day abbreviation (Mo, Di, Mi, Do, Fr, Sa, So)
|
||||||
|
/// - Date number (day of month)
|
||||||
|
///
|
||||||
|
/// The selected card is highlighted and always centered.
|
||||||
|
/// Today's card uses bold text + an accent underline bar.
|
||||||
|
/// Month boundaries get a wider gap and a small month label.
|
||||||
|
class CalendarStrip extends ConsumerStatefulWidget {
|
||||||
|
const CalendarStrip({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.onTodayVisibilityChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Controller for programmatic scroll-to-today.
|
||||||
|
final CalendarStripController controller;
|
||||||
|
|
||||||
|
/// Called when today's card enters or leaves the viewport.
|
||||||
|
final ValueChanged<bool> onTodayVisibilityChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CalendarStrip> createState() => _CalendarStripState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CalendarStripState extends ConsumerState<CalendarStrip> {
|
||||||
|
late final ScrollController _scrollController;
|
||||||
|
late final DateTime _today;
|
||||||
|
late final List<DateTime> _dates;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
_today = DateTime(now.year, now.month, now.day);
|
||||||
|
|
||||||
|
// Build the date list: _kPastDays before today, today, _kFutureDays after.
|
||||||
|
_dates = List.generate(
|
||||||
|
_kTotalDays,
|
||||||
|
(i) => _today.subtract(Duration(days: _kPastDays - i)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate initial scroll offset so today's card is centered.
|
||||||
|
_scrollController = ScrollController(
|
||||||
|
initialScrollOffset: _offsetForIndex(_kPastDays),
|
||||||
|
);
|
||||||
|
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
|
// Register the scroll-to-today callback on the controller.
|
||||||
|
widget.controller._scrollToToday = _animateToToday;
|
||||||
|
|
||||||
|
// After first frame, animate to center today with a short delay so the
|
||||||
|
// strip has laid out its children.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_animateToToday();
|
||||||
|
// Initial visibility check
|
||||||
|
_onScroll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.removeListener(_onScroll);
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the scroll offset that centers the card at [index].
|
||||||
|
double _offsetForIndex(int index) {
|
||||||
|
// Sum the widths of all items before [index], then subtract half the viewport
|
||||||
|
// width so the card is centered. We approximate viewport as screen width
|
||||||
|
// because we cannot access it here; we compensate in the post-frame callback.
|
||||||
|
double offset = 0;
|
||||||
|
for (int i = 0; i < index; i++) {
|
||||||
|
offset += _itemWidth(i);
|
||||||
|
}
|
||||||
|
// Center by subtracting half the card container width (will be corrected post-frame).
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total width occupied by the item at [index], including margins
|
||||||
|
/// and any month-boundary gap on its left side.
|
||||||
|
double _itemWidth(int index) {
|
||||||
|
final date = _dates[index];
|
||||||
|
final leftMargin = _isFirstOfMonth(date) && index > 0
|
||||||
|
? _kMonthBoundaryGap
|
||||||
|
: _kCardMargin;
|
||||||
|
// Each item = leftMargin + card width + rightMargin
|
||||||
|
return leftMargin + _kCardWidth + _kCardMargin;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isFirstOfMonth(DateTime date) => date.day == 1;
|
||||||
|
|
||||||
|
void _animateToToday() {
|
||||||
|
if (!mounted || !_scrollController.hasClients) return;
|
||||||
|
final viewportWidth = _scrollController.position.viewportDimension;
|
||||||
|
double targetOffset = 0;
|
||||||
|
for (int i = 0; i < _kPastDays; i++) {
|
||||||
|
targetOffset += _itemWidth(i);
|
||||||
|
}
|
||||||
|
// Center today's card in the viewport.
|
||||||
|
targetOffset -= (viewportWidth - _kCardWidth) / 2;
|
||||||
|
targetOffset = targetOffset.clamp(
|
||||||
|
_scrollController.position.minScrollExtent,
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
);
|
||||||
|
_scrollController.animateTo(
|
||||||
|
targetOffset,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _animateToIndex(int index) {
|
||||||
|
if (!mounted || !_scrollController.hasClients) return;
|
||||||
|
final viewportWidth = _scrollController.position.viewportDimension;
|
||||||
|
double targetOffset = 0;
|
||||||
|
for (int i = 0; i < index; i++) {
|
||||||
|
targetOffset += _itemWidth(i);
|
||||||
|
}
|
||||||
|
targetOffset -= (viewportWidth - _kCardWidth) / 2;
|
||||||
|
targetOffset = targetOffset.clamp(
|
||||||
|
_scrollController.position.minScrollExtent,
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
);
|
||||||
|
_scrollController.animateTo(
|
||||||
|
targetOffset,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (!mounted || !_scrollController.hasClients) return;
|
||||||
|
final viewportWidth = _scrollController.position.viewportDimension;
|
||||||
|
final scrollOffset = _scrollController.offset;
|
||||||
|
|
||||||
|
// Calculate the left edge of today's card.
|
||||||
|
double todayLeftEdge = 0;
|
||||||
|
for (int i = 0; i < _kPastDays; i++) {
|
||||||
|
todayLeftEdge += _itemWidth(i);
|
||||||
|
}
|
||||||
|
final todayRightEdge = todayLeftEdge + _kCardWidth;
|
||||||
|
|
||||||
|
// Today is visible if any part of the card is in the viewport.
|
||||||
|
final isVisible =
|
||||||
|
todayRightEdge > scrollOffset &&
|
||||||
|
todayLeftEdge < scrollOffset + viewportWidth;
|
||||||
|
|
||||||
|
widget.onTodayVisibilityChanged(isVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCardTapped(int index) {
|
||||||
|
final tappedDate = _dates[index];
|
||||||
|
ref.read(selectedDateProvider.notifier).selectDate(tappedDate);
|
||||||
|
_animateToIndex(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final selectedDate = ref.watch(selectedDateProvider);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: _kCardHeight + 24, // extra height for month label
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: _kTotalDays,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final date = _dates[index];
|
||||||
|
final isToday = date == _today;
|
||||||
|
final isSelected = date == selectedDate;
|
||||||
|
final isFirstOfMonth = _isFirstOfMonth(date) && index > 0;
|
||||||
|
|
||||||
|
return _DayCardItem(
|
||||||
|
date: date,
|
||||||
|
isToday: isToday,
|
||||||
|
isSelected: isSelected,
|
||||||
|
isFirstOfMonth: isFirstOfMonth,
|
||||||
|
onTap: () => _onCardTapped(index),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single day card in the calendar strip, with optional month boundary label.
|
||||||
|
class _DayCardItem extends StatelessWidget {
|
||||||
|
const _DayCardItem({
|
||||||
|
required this.date,
|
||||||
|
required this.isToday,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.isFirstOfMonth,
|
||||||
|
required this.onTap,
|
||||||
|
required this.theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime date;
|
||||||
|
final bool isToday;
|
||||||
|
final bool isSelected;
|
||||||
|
final bool isFirstOfMonth;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final ThemeData theme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final leftMargin = isFirstOfMonth ? _kMonthBoundaryGap : _kCardMargin;
|
||||||
|
|
||||||
|
// Card background color: selected gets full primaryContainer, others get
|
||||||
|
// a subtle tint of primaryContainer.
|
||||||
|
final bgColor = isSelected
|
||||||
|
? theme.colorScheme.primaryContainer
|
||||||
|
: theme.colorScheme.primaryContainer.withValues(alpha: 0.3);
|
||||||
|
|
||||||
|
// Border: selected card gets a primary color border.
|
||||||
|
final border = isSelected
|
||||||
|
? Border.all(color: theme.colorScheme.primary, width: 1.5)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Text weight: today uses bold.
|
||||||
|
final fontWeight = isToday ? FontWeight.bold : FontWeight.normal;
|
||||||
|
|
||||||
|
// Day abbreviation (German locale): Mo, Di, Mi, Do, Fr, Sa, So
|
||||||
|
final dayAbbr = DateFormat('E', 'de').format(date);
|
||||||
|
// Date number
|
||||||
|
final dayNum = date.day.toString();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Month label at boundary
|
||||||
|
if (isFirstOfMonth)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(left: leftMargin),
|
||||||
|
child: SizedBox(
|
||||||
|
width: _kCardWidth + _kCardMargin,
|
||||||
|
child: Text(
|
||||||
|
DateFormat('MMM', 'de').format(date),
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(height: 16), // Reserve space for month label row
|
||||||
|
|
||||||
|
// Day card
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: _kCardWidth,
|
||||||
|
height: _kCardHeight,
|
||||||
|
margin: EdgeInsets.only(left: leftMargin, right: _kCardMargin),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: border,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// German day abbreviation
|
||||||
|
Text(
|
||||||
|
dayAbbr,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.onPrimaryContainer
|
||||||
|
: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
// Date number
|
||||||
|
Text(
|
||||||
|
dayNum,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.onPrimaryContainer
|
||||||
|
: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Today accent underline bar
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
if (isToday)
|
||||||
|
Container(
|
||||||
|
width: 20,
|
||||||
|
height: 2,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
borderRadius: BorderRadius.circular(1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
lib/features/home/presentation/calendar_task_row.dart
Normal file
69
lib/features/home/presentation/calendar_task_row.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
||||||
|
|
||||||
|
/// Warm coral/terracotta color for overdue task name text.
|
||||||
|
const _overdueColor = Color(0xFFE07A5F);
|
||||||
|
|
||||||
|
/// A task row adapted for the calendar day list.
|
||||||
|
///
|
||||||
|
/// Shows task name, a tappable room tag (navigates to room task list),
|
||||||
|
/// and an interactive checkbox. Does NOT show a relative date — the
|
||||||
|
/// calendar strip already communicates which day is selected.
|
||||||
|
///
|
||||||
|
/// When [isOverdue] is true the task name uses coral text to visually
|
||||||
|
/// distinguish overdue carry-over from today's regular tasks.
|
||||||
|
class CalendarTaskRow extends StatelessWidget {
|
||||||
|
const CalendarTaskRow({
|
||||||
|
super.key,
|
||||||
|
required this.taskWithRoom,
|
||||||
|
required this.onCompleted,
|
||||||
|
this.isOverdue = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TaskWithRoom taskWithRoom;
|
||||||
|
|
||||||
|
/// Called when the user checks the checkbox.
|
||||||
|
final VoidCallback onCompleted;
|
||||||
|
|
||||||
|
/// When true, task name is rendered in coral color.
|
||||||
|
final bool isOverdue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final task = taskWithRoom.task;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: Checkbox(
|
||||||
|
value: false,
|
||||||
|
onChanged: (_) => onCompleted(),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
task.name,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: isOverdue ? _overdueColor : null,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: GestureDetector(
|
||||||
|
onTap: () => context.go('/rooms/${taskWithRoom.roomId}'),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
taskWithRoom.roomName,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,13 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
import 'package:household_keeper/core/database/database.dart';
|
import 'package:household_keeper/core/database/database.dart';
|
||||||
import 'package:household_keeper/core/router/router.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/domain/calendar_models.dart';
|
||||||
import 'package:household_keeper/features/home/presentation/daily_plan_providers.dart';
|
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
|
||||||
import 'package:household_keeper/features/rooms/presentation/room_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/effort_level.dart';
|
||||||
import 'package:household_keeper/features/tasks/domain/frequency.dart';
|
import 'package:household_keeper/features/tasks/domain/frequency.dart';
|
||||||
import 'package:household_keeper/l10n/app_localizations.dart';
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
|
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
||||||
|
|
||||||
/// Helper to create a test [Task] with sensible defaults.
|
/// Helper to create a test [Task] with sensible defaults.
|
||||||
Task _makeTask({
|
Task _makeTask({
|
||||||
@@ -51,15 +52,16 @@ TaskWithRoom _makeTaskWithRoom({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the app with dailyPlanProvider overridden to the given state.
|
/// Build the app with calendarDayProvider overridden to the given state.
|
||||||
///
|
///
|
||||||
/// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid
|
/// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid
|
||||||
/// the riverpod_lint scoped_providers_should_specify_dependencies warning.
|
/// the riverpod_lint scoped_providers_should_specify_dependencies warning.
|
||||||
Widget _buildApp(DailyPlanState planState) {
|
Widget _buildApp(CalendarDayState dayState) {
|
||||||
final container = ProviderContainer(overrides: [
|
final container = ProviderContainer(overrides: [
|
||||||
dailyPlanProvider.overrideWith(
|
calendarDayProvider.overrideWith(
|
||||||
(ref) => Stream.value(planState),
|
(ref) => Stream.value(dayState),
|
||||||
),
|
),
|
||||||
|
selectedDateProvider.overrideWith(SelectedDateNotifier.new),
|
||||||
roomWithStatsListProvider.overrideWith(
|
roomWithStatsListProvider.overrideWith(
|
||||||
(ref) => Stream.value([]),
|
(ref) => Stream.value([]),
|
||||||
),
|
),
|
||||||
@@ -81,17 +83,21 @@ void main() {
|
|||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final yesterday = today.subtract(const Duration(days: 1));
|
||||||
|
|
||||||
group('HomeScreen empty states', () {
|
group('HomeScreen empty states', () {
|
||||||
testWidgets('shows no-tasks empty state when no tasks exist at all',
|
testWidgets('shows no-tasks empty state when no tasks exist at all',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(_buildApp(const DailyPlanState(
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
overdueTasks: [],
|
selectedDate: today,
|
||||||
todayTasks: [],
|
dayTasks: const [],
|
||||||
tomorrowTasks: [],
|
overdueTasks: const [],
|
||||||
completedTodayCount: 0,
|
totalTaskCount: 0,
|
||||||
totalTodayCount: 0,
|
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Should show "Noch keine Aufgaben angelegt" (dailyPlanNoTasks)
|
// Should show "Noch keine Aufgaben angelegt" (dailyPlanNoTasks)
|
||||||
expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget);
|
expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget);
|
||||||
@@ -99,58 +105,53 @@ void main() {
|
|||||||
expect(find.text('Raum erstellen'), findsOneWidget);
|
expect(find.text('Raum erstellen'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows all-clear state when all tasks are done',
|
testWidgets('shows celebration state when tasks exist but today is clear',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(_buildApp(const DailyPlanState(
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
overdueTasks: [],
|
selectedDate: today,
|
||||||
todayTasks: [],
|
dayTasks: const [],
|
||||||
tomorrowTasks: [],
|
overdueTasks: const [],
|
||||||
completedTodayCount: 3,
|
totalTaskCount: 5, // tasks exist elsewhere
|
||||||
totalTodayCount: 3,
|
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Should show celebration empty state
|
// Should show celebration state
|
||||||
expect(find.text('Alles erledigt! \u{1F31F}'), findsOneWidget);
|
|
||||||
expect(find.byIcon(Icons.celebration_outlined), findsOneWidget);
|
expect(find.byIcon(Icons.celebration_outlined), findsOneWidget);
|
||||||
// Progress card should show 3/3
|
expect(find.text('Alles erledigt! \u{1F31F}'), findsOneWidget);
|
||||||
expect(find.text('3 von 3 erledigt'), findsOneWidget);
|
});
|
||||||
|
|
||||||
|
testWidgets('shows empty-day state for non-today date with no tasks',
|
||||||
|
(tester) async {
|
||||||
|
final tomorrow = today.add(const Duration(days: 1));
|
||||||
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
|
selectedDate: tomorrow,
|
||||||
|
dayTasks: const [],
|
||||||
|
overdueTasks: const [],
|
||||||
|
totalTaskCount: 5, // tasks exist on other days
|
||||||
|
)));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// Should show "Keine Aufgaben" (not celebration — not today)
|
||||||
|
expect(find.text('Keine Aufgaben'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.event_available), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('HomeScreen normal state', () {
|
group('HomeScreen normal state', () {
|
||||||
testWidgets('shows progress card with correct counts', (tester) async {
|
testWidgets('shows overdue section when overdue tasks exist (today)',
|
||||||
final now = DateTime.now();
|
(tester) async {
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
|
selectedDate: today,
|
||||||
await tester.pumpWidget(_buildApp(DailyPlanState(
|
dayTasks: [
|
||||||
overdueTasks: [],
|
|
||||||
todayTasks: [
|
|
||||||
_makeTaskWithRoom(
|
_makeTaskWithRoom(
|
||||||
id: 1,
|
id: 2,
|
||||||
taskName: 'Staubsaugen',
|
taskName: 'Staubsaugen',
|
||||||
roomName: 'Wohnzimmer',
|
roomName: 'Wohnzimmer',
|
||||||
nextDueDate: today,
|
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: [
|
overdueTasks: [
|
||||||
_makeTaskWithRoom(
|
_makeTaskWithRoom(
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -159,24 +160,13 @@ void main() {
|
|||||||
nextDueDate: yesterday,
|
nextDueDate: yesterday,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
todayTasks: [
|
totalTaskCount: 2,
|
||||||
_makeTaskWithRoom(
|
|
||||||
id: 2,
|
|
||||||
taskName: 'Staubsaugen',
|
|
||||||
roomName: 'Wohnzimmer',
|
|
||||||
nextDueDate: today,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
tomorrowTasks: [],
|
|
||||||
completedTodayCount: 0,
|
|
||||||
totalTodayCount: 2,
|
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Should show overdue section header
|
// Should show overdue section header
|
||||||
expect(find.text('\u00dcberf\u00e4llig'), findsOneWidget);
|
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
|
// Should show both tasks
|
||||||
expect(find.text('Boden wischen'), findsOneWidget);
|
expect(find.text('Boden wischen'), findsOneWidget);
|
||||||
expect(find.text('Staubsaugen'), findsOneWidget);
|
expect(find.text('Staubsaugen'), findsOneWidget);
|
||||||
@@ -185,54 +175,37 @@ void main() {
|
|||||||
expect(find.text('Wohnzimmer'), findsOneWidget);
|
expect(find.text('Wohnzimmer'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows collapsed tomorrow section with count',
|
testWidgets('does not show overdue section for non-today date',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
final now = DateTime.now();
|
// On a future date, overdueTasks will be empty (calendarDayProvider
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
// only populates overdueTasks when isToday).
|
||||||
final tomorrow = today.add(const Duration(days: 1));
|
final tomorrow = today.add(const Duration(days: 1));
|
||||||
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
await tester.pumpWidget(_buildApp(DailyPlanState(
|
selectedDate: tomorrow,
|
||||||
overdueTasks: [],
|
dayTasks: [
|
||||||
todayTasks: [
|
|
||||||
_makeTaskWithRoom(
|
_makeTaskWithRoom(
|
||||||
id: 1,
|
id: 1,
|
||||||
taskName: 'Staubsaugen',
|
taskName: 'Staubsaugen',
|
||||||
roomName: 'Wohnzimmer',
|
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,
|
nextDueDate: tomorrow,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
completedTodayCount: 0,
|
overdueTasks: const [], // No overdue for non-today
|
||||||
totalTodayCount: 1,
|
totalTaskCount: 1,
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Should show collapsed tomorrow section with count
|
// Should NOT show overdue section header
|
||||||
expect(find.text('Demn\u00e4chst (2)'), findsOneWidget);
|
expect(find.text('\u00dcberf\u00e4llig'), findsNothing);
|
||||||
// Tomorrow tasks should NOT be visible (collapsed by default)
|
// Should show day task
|
||||||
expect(find.text('Fenster putzen'), findsNothing);
|
expect(find.text('Staubsaugen'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('today tasks have checkboxes', (tester) async {
|
testWidgets('tasks have checkboxes', (tester) async {
|
||||||
final now = DateTime.now();
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
selectedDate: today,
|
||||||
|
dayTasks: [
|
||||||
await tester.pumpWidget(_buildApp(DailyPlanState(
|
|
||||||
overdueTasks: [],
|
|
||||||
todayTasks: [
|
|
||||||
_makeTaskWithRoom(
|
_makeTaskWithRoom(
|
||||||
id: 1,
|
id: 1,
|
||||||
taskName: 'Staubsaugen',
|
taskName: 'Staubsaugen',
|
||||||
@@ -240,14 +213,29 @@ void main() {
|
|||||||
nextDueDate: today,
|
nextDueDate: today,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
tomorrowTasks: [],
|
overdueTasks: const [],
|
||||||
completedTodayCount: 0,
|
totalTaskCount: 1,
|
||||||
totalTodayCount: 1,
|
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Today task should have a checkbox
|
// Task should have a checkbox
|
||||||
expect(find.byType(Checkbox), findsOneWidget);
|
expect(find.byType(Checkbox), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('calendar strip is shown', (tester) async {
|
||||||
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
|
selectedDate: today,
|
||||||
|
dayTasks: const [],
|
||||||
|
overdueTasks: const [],
|
||||||
|
totalTaskCount: 0,
|
||||||
|
)));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// The strip is a horizontal ListView — verify it exists by finding
|
||||||
|
// ListView widgets (strip + potentially the task list).
|
||||||
|
expect(find.byType(ListView), findsWidgets);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:household_keeper/core/router/router.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/domain/calendar_models.dart';
|
||||||
import 'package:household_keeper/features/home/presentation/daily_plan_providers.dart';
|
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
|
||||||
import 'package:household_keeper/features/rooms/presentation/room_providers.dart';
|
import 'package:household_keeper/features/rooms/presentation/room_providers.dart';
|
||||||
import 'package:household_keeper/l10n/app_localizations.dart';
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -15,6 +15,9 @@ void main() {
|
|||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
|
||||||
/// Helper to build the app with providers overridden for testing.
|
/// Helper to build the app with providers overridden for testing.
|
||||||
///
|
///
|
||||||
/// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid
|
/// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid
|
||||||
@@ -26,15 +29,16 @@ void main() {
|
|||||||
roomWithStatsListProvider.overrideWith(
|
roomWithStatsListProvider.overrideWith(
|
||||||
(ref) => Stream.value([]),
|
(ref) => Stream.value([]),
|
||||||
),
|
),
|
||||||
// Override daily plan to return empty state so HomeScreen
|
// Override selected date to avoid any DB access.
|
||||||
// renders without a database.
|
selectedDateProvider.overrideWith(SelectedDateNotifier.new),
|
||||||
dailyPlanProvider.overrideWith(
|
// Override calendar day provider to return empty first-run state so
|
||||||
(ref) => Stream.value(const DailyPlanState(
|
// HomeScreen renders without a database.
|
||||||
overdueTasks: [],
|
calendarDayProvider.overrideWith(
|
||||||
todayTasks: [],
|
(ref) => Stream.value(CalendarDayState(
|
||||||
tomorrowTasks: [],
|
selectedDate: today,
|
||||||
completedTodayCount: 0,
|
dayTasks: const [],
|
||||||
totalTodayCount: 0,
|
overdueTasks: const [],
|
||||||
|
totalTaskCount: 0,
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -53,7 +57,8 @@ void main() {
|
|||||||
testWidgets('renders 3 navigation destinations with correct German labels',
|
testWidgets('renders 3 navigation destinations with correct German labels',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(buildApp());
|
await tester.pumpWidget(buildApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Verify 3 NavigationDestination widgets are rendered
|
// Verify 3 NavigationDestination widgets are rendered
|
||||||
expect(find.byType(NavigationDestination), findsNWidgets(3));
|
expect(find.byType(NavigationDestination), findsNWidgets(3));
|
||||||
@@ -67,22 +72,24 @@ void main() {
|
|||||||
testWidgets('tapping a destination changes the selected tab',
|
testWidgets('tapping a destination changes the selected tab',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(buildApp());
|
await tester.pumpWidget(buildApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Initially on Home tab (index 0) -- verify home empty state is shown
|
// Initially on Home tab (index 0) -- verify home first-run empty state
|
||||||
// (dailyPlanNoTasks text from the daily plan empty state)
|
|
||||||
expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget);
|
expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget);
|
||||||
|
|
||||||
// Tap the Rooms tab (second destination)
|
// Tap the Rooms tab (second destination)
|
||||||
await tester.tap(find.text('R\u00e4ume'));
|
await tester.tap(find.text('R\u00e4ume'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Verify we see Rooms content now (empty state)
|
// Verify we see Rooms content now (empty state)
|
||||||
expect(find.text('Hier ist noch alles leer!'), findsOneWidget);
|
expect(find.text('Hier ist noch alles leer!'), findsOneWidget);
|
||||||
|
|
||||||
// Tap the Settings tab (third destination)
|
// Tap the Settings tab (third destination)
|
||||||
await tester.tap(find.text('Einstellungen'));
|
await tester.tap(find.text('Einstellungen'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Verify we see Settings content now
|
// Verify we see Settings content now
|
||||||
expect(find.text('Darstellung'), findsOneWidget);
|
expect(find.text('Darstellung'), findsOneWidget);
|
||||||
|
|||||||
Reference in New Issue
Block a user