feat(05-02): replace HomeScreen with calendar composition and floating Today button
- Rewrite HomeScreen as ConsumerStatefulWidget composing CalendarStrip + CalendarDayList - CalendarStripController wires floating Today button to scroll-strip-to-today animation - FloatingActionButton.extended shows "Heute" + Icons.today only when today is out of viewport - Old overdue/today/tomorrow stacked plan sections and ProgressCard fully removed
This commit is contained in:
@@ -1,22 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/calendar_day_list.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/home/presentation/daily_plan_task_row.dart';
|
import 'package:household_keeper/features/home/presentation/calendar_strip.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';
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// Warm coral/terracotta color for overdue section header.
|
/// The app's primary screen: a horizontal calendar strip at the top with a
|
||||||
const _overdueColor = Color(0xFFE07A5F);
|
/// day task list below.
|
||||||
|
|
||||||
/// 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:
|
/// Replaces the former stacked overdue/today/tomorrow daily plan layout.
|
||||||
/// see what's due, check it off, feel progress.
|
/// Users navigate by tapping day cards to see that day's tasks.
|
||||||
class HomeScreen extends ConsumerStatefulWidget {
|
class HomeScreen extends ConsumerStatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@@ -25,365 +19,51 @@ class HomeScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||||
/// Task IDs currently animating out after completion.
|
late final CalendarStripController _stripController =
|
||||||
final Set<int> _completingTaskIds = {};
|
CalendarStripController();
|
||||||
|
|
||||||
void _onTaskCompleted(int taskId) {
|
/// Whether to show the floating "Heute" button.
|
||||||
setState(() {
|
/// True when the user has scrolled away from today's card.
|
||||||
_completingTaskIds.add(taskId);
|
bool _showTodayButton = false;
|
||||||
});
|
|
||||||
ref.read(taskActionsProvider.notifier).completeTask(taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final theme = Theme.of(context);
|
|
||||||
final dailyPlan = ref.watch(dailyPlanProvider);
|
|
||||||
|
|
||||||
return dailyPlan.when(
|
return Stack(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
children: [
|
||||||
error: (error, _) => Center(child: Text(error.toString())),
|
Column(
|
||||||
data: (state) {
|
children: [
|
||||||
// Clean up completing IDs that are no longer in the data
|
CalendarStrip(
|
||||||
_completingTaskIds.removeWhere((id) =>
|
controller: _stripController,
|
||||||
!state.overdueTasks.any((t) => t.task.id == id) &&
|
onTodayVisibilityChanged: (visible) {
|
||||||
!state.todayTasks.any((t) => t.task.id == id));
|
setState(() => _showTodayButton = !visible);
|
||||||
|
|
||||||
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),
|
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
|
const Expanded(child: CalendarDayList()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (_showTodayButton)
|
||||||
|
Positioned(
|
||||||
|
bottom: 16,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Center(
|
||||||
|
child: FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
ref
|
||||||
|
.read(selectedDateProvider.notifier)
|
||||||
|
.selectDate(today);
|
||||||
|
_stripController.scrollToToday();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.today),
|
||||||
|
label: Text(l10n.calendarTodayButton),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<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: DailyPlanTaskRow(
|
|
||||||
taskWithRoom: widget.taskWithRoom,
|
|
||||||
showCheckbox: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user