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:
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
106
lib/features/tasks/presentation/task_row.dart
Normal file
106
lib/features/tasks/presentation/task_row.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user