feat(02-03): build task list screen with task row, completion, and overdue highlighting

- TaskListScreen with room name in AppBar, edit/delete room actions
- AsyncValue.when for loading/error/data states with empty state
- TaskRow with leading checkbox, name, German relative due date, frequency label
- Overdue dates highlighted in warm coral (0xFFE07A5F)
- Checkbox marks done immediately (optimistic UI, no undo)
- Row tap navigates to edit form, long-press shows delete confirmation
- FAB for creating new tasks
- ListView.builder for task list sorted by due date

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 22:09:42 +01:00
parent 652ff0123f
commit b535f57a39
2 changed files with 323 additions and 6 deletions

View File

@@ -1,17 +1,228 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// Placeholder for the task list screen within a room. import 'package:household_keeper/core/database/database.dart';
/// Will be fully implemented in Plan 03. import 'package:household_keeper/core/providers/database_provider.dart';
class TaskListScreen extends StatelessWidget { import 'package:household_keeper/features/tasks/presentation/task_providers.dart';
import 'package:household_keeper/features/tasks/presentation/task_row.dart';
import 'package:household_keeper/l10n/app_localizations.dart';
/// Screen displaying all tasks within a room, sorted by due date.
///
/// Shows an empty state when no tasks exist, with a button to create one.
/// FAB always visible for quick task creation.
/// AppBar shows room name with edit and delete actions.
class TaskListScreen extends ConsumerWidget {
const TaskListScreen({super.key, required this.roomId}); const TaskListScreen({super.key, required this.roomId});
final int roomId; final int roomId;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final asyncTasks = ref.watch(tasksInRoomProvider(roomId));
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Aufgaben')), appBar: AppBar(
body: const Center(child: Text('Demnächst verfügbar')), title: _RoomTitle(roomId: roomId),
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => context.go('/rooms/$roomId/edit'),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _showRoomDeleteConfirmation(context, ref, l10n),
),
],
),
body: asyncTasks.when(
data: (tasks) {
if (tasks.isEmpty) {
return _EmptyState(l10n: l10n, roomId: roomId);
}
return _TaskListView(
tasks: tasks,
l10n: l10n,
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Fehler: $error'),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: () => ref.invalidate(tasksInRoomProvider(roomId)),
child: const Text('Erneut versuchen'),
),
],
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.go('/rooms/$roomId/tasks/new'),
child: const Icon(Icons.add),
),
);
}
void _showRoomDeleteConfirmation(
BuildContext context,
WidgetRef ref,
AppLocalizations l10n,
) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.roomDeleteConfirmTitle),
content: Text(l10n.roomDeleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () {
Navigator.pop(ctx);
final db = ref.read(appDatabaseProvider);
db.roomsDao.deleteRoom(roomId);
// Navigate back to rooms list
context.go('/rooms');
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
child: Text(l10n.roomDeleteConfirmAction),
),
],
),
);
}
}
/// Async room name loader for the AppBar title.
class _RoomTitle extends ConsumerWidget {
const _RoomTitle({required this.roomId});
final int roomId;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Use a FutureBuilder-like approach: read room name from DB
return FutureBuilder<Room>(
future: ref.read(appDatabaseProvider).roomsDao.getRoomById(roomId),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Text(snapshot.data!.name);
}
return const Text('...');
},
);
}
}
/// Empty state shown when the room has no tasks.
class _EmptyState extends StatelessWidget {
const _EmptyState({required this.l10n, required this.roomId});
final AppLocalizations l10n;
final int roomId;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.task_alt,
size: 80,
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
),
const SizedBox(height: 24),
Text(
l10n.taskEmptyTitle,
style: theme.textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l10n.taskEmptyMessage,
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/$roomId/tasks/new'),
child: Text(l10n.taskEmptyAction),
),
],
),
),
);
}
}
/// List view of task rows with long-press delete support.
class _TaskListView extends ConsumerWidget {
const _TaskListView({required this.tasks, required this.l10n});
final List<Task> tasks;
final AppLocalizations l10n;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return TaskRow(
key: ValueKey(task.id),
task: task,
onDelete: () => _showDeleteConfirmation(context, ref, task),
);
},
);
}
void _showDeleteConfirmation(
BuildContext context,
WidgetRef ref,
Task task,
) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.taskDeleteConfirmTitle),
content: Text(l10n.taskDeleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () {
Navigator.pop(ctx);
ref.read(taskActionsProvider.notifier).deleteTask(task.id);
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
child: Text(l10n.taskDeleteConfirmAction),
),
],
),
); );
} }
} }

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:household_keeper/core/database/database.dart';
import 'package:household_keeper/features/tasks/domain/frequency.dart';
import 'package:household_keeper/features/tasks/domain/relative_date.dart';
import 'package:household_keeper/features/tasks/presentation/task_providers.dart';
/// Warm coral/terracotta color for overdue due date text.
const _overdueColor = Color(0xFFE07A5F);
/// A single task row with leading checkbox, name, relative due date,
/// and frequency label.
///
/// Per user decisions:
/// - Checkbox marks task done immediately (optimistic UI, no undo)
/// - Row tap opens edit form
/// - No swipe gesture
/// - No effort indicator or description preview on list view
/// - Overdue: due date text turns warm coral, rest stays normal
class TaskRow extends ConsumerWidget {
const TaskRow({
super.key,
required this.task,
this.onDelete,
});
final Task task;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
// Check if task is overdue (due date is before today)
final dueDate = DateTime(
task.nextDueDate.year,
task.nextDueDate.month,
task.nextDueDate.day,
);
final isOverdue = dueDate.isBefore(today);
// Format relative due date in German
final relativeDateText = formatRelativeDate(task.nextDueDate, now);
// Build frequency label
final freq = FrequencyInterval(
intervalType: task.intervalType,
days: task.intervalDays,
);
final frequencyText = freq.label();
return ListTile(
leading: Checkbox(
value: false, // Always unchecked -- completion is immediate + reschedule
onChanged: (_) {
// Mark done immediately (optimistic UI, no undo per user decision)
ref.read(taskActionsProvider.notifier).completeTask(task.id);
},
),
title: Text(
task.name,
style: theme.textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Row(
children: [
Text(
relativeDateText,
style: theme.textTheme.bodySmall?.copyWith(
color: isOverdue ? _overdueColor : theme.colorScheme.onSurfaceVariant,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'\u00b7', // middle dot separator
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Flexible(
child: Text(
frequencyText,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
onTap: () {
// Tap row opens edit form
context.go('/rooms/${task.roomId}/tasks/${task.id}');
},
onLongPress: onDelete,
);
}
}