Improve touch targets with 48dp minimum size for mobile

- Enlarge calendar priority dots to 10px with pulsing animation
- Increase task tile checkbox and menu button containers to 48x48
- Add proper minVerticalPadding to task tiles (72dp height)
- Update filter chips with MaterialTapTargetSize.padded
- Increase navigation buttons to 48x48 with 28px icons
- Update task form with 48dp height segmented button
This commit is contained in:
m3mo 2026-02-02 22:57:17 +01:00
parent da873afae0
commit 5cd79e096d
6 changed files with 219 additions and 127 deletions

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:agenda_tasks/l10n/app_localizations.dart'; import 'package:agenda_tasks/l10n/app_localizations.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../../../../core/di/injection_container.dart'; import '../../../../core/di/injection_container.dart';
import '../../domain/entities/task_entity.dart'; import '../../domain/entities/task_entity.dart';
@ -35,7 +34,6 @@ class _CalendarPageState extends State<CalendarPage> {
final repository = getIt<TaskRepository>(); final repository = getIt<TaskRepository>();
final Map<String, List<TaskEntity>> tasks = {}; final Map<String, List<TaskEntity>> tasks = {};
final firstDay = DateTime(_focusedMonth.year, _focusedMonth.month, 1);
final lastDay = DateTime(_focusedMonth.year, _focusedMonth.month + 1, 0); final lastDay = DateTime(_focusedMonth.year, _focusedMonth.month + 1, 0);
for (int day = 1; day <= lastDay.day; day++) { for (int day = 1; day <= lastDay.day; day++) {
@ -60,19 +58,24 @@ class _CalendarPageState extends State<CalendarPage> {
} }
} }
Priority? _getHighestPriority(List<TaskEntity>? tasks) { List<Priority> _getUniquePriorities(List<TaskEntity>? tasks) {
if (tasks == null || tasks.isEmpty) return null; if (tasks == null || tasks.isEmpty) return [];
final incompleteTasks = tasks.where((t) => !t.isDone).toList(); final incompleteTasks = tasks.where((t) => !t.isDone).toList();
if (incompleteTasks.isEmpty) return null; if (incompleteTasks.isEmpty) return [];
// Get unique priorities, sorted by importance (high first)
final priorities = <Priority>[];
if (incompleteTasks.any((t) => t.priority == Priority.high)) { if (incompleteTasks.any((t) => t.priority == Priority.high)) {
return Priority.high; priorities.add(Priority.high);
} }
if (incompleteTasks.any((t) => t.priority == Priority.medium)) { if (incompleteTasks.any((t) => t.priority == Priority.medium)) {
return Priority.medium; priorities.add(Priority.medium);
} }
return Priority.low; if (incompleteTasks.any((t) => t.priority == Priority.low)) {
priorities.add(Priority.low);
}
return priorities;
} }
@override @override
@ -114,7 +117,7 @@ class _CalendarPageState extends State<CalendarPage> {
focusedMonth: _focusedMonth, focusedMonth: _focusedMonth,
selectedDate: _selectedDate, selectedDate: _selectedDate,
tasksByDate: _tasksByDate, tasksByDate: _tasksByDate,
getHighestPriority: _getHighestPriority, getUniquePriorities: _getUniquePriorities,
onDateSelected: (date) { onDateSelected: (date) {
setState(() => _selectedDate = date); setState(() => _selectedDate = date);
}, },
@ -126,6 +129,8 @@ class _CalendarPageState extends State<CalendarPage> {
? SafeArea( ? SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: SizedBox(
height: 56,
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () {
final dateStr = DateFormat('yyyy-MM-dd').format(_selectedDate!); final dateStr = DateFormat('yyyy-MM-dd').format(_selectedDate!);
@ -134,6 +139,7 @@ class _CalendarPageState extends State<CalendarPage> {
child: Text(l10n.goToDay), child: Text(l10n.goToDay),
), ),
), ),
),
) )
: null, : null,
); );
@ -158,22 +164,30 @@ class _MonthNavigation extends StatelessWidget {
final monthFormat = DateFormat.yMMMM(locale); final monthFormat = DateFormat.yMMMM(locale);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 12),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
IconButton( SizedBox(
icon: const Icon(Icons.chevron_left), width: 48,
height: 48,
child: IconButton(
icon: const Icon(Icons.chevron_left, size: 28),
onPressed: onPrevious, onPressed: onPrevious,
), ),
),
Text( Text(
monthFormat.format(month), monthFormat.format(month),
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
IconButton( SizedBox(
icon: const Icon(Icons.chevron_right), width: 48,
height: 48,
child: IconButton(
icon: const Icon(Icons.chevron_right, size: 28),
onPressed: onNext, onPressed: onNext,
), ),
),
], ],
), ),
); );
@ -216,14 +230,14 @@ class _CalendarGrid extends StatelessWidget {
final DateTime focusedMonth; final DateTime focusedMonth;
final DateTime? selectedDate; final DateTime? selectedDate;
final Map<String, List<TaskEntity>> tasksByDate; final Map<String, List<TaskEntity>> tasksByDate;
final Priority? Function(List<TaskEntity>?) getHighestPriority; final List<Priority> Function(List<TaskEntity>?) getUniquePriorities;
final ValueChanged<DateTime> onDateSelected; final ValueChanged<DateTime> onDateSelected;
const _CalendarGrid({ const _CalendarGrid({
required this.focusedMonth, required this.focusedMonth,
required this.selectedDate, required this.selectedDate,
required this.tasksByDate, required this.tasksByDate,
required this.getHighestPriority, required this.getUniquePriorities,
required this.onDateSelected, required this.onDateSelected,
}); });
@ -237,10 +251,12 @@ class _CalendarGrid extends StatelessWidget {
final today = DateTime.now(); final today = DateTime.now();
return GridView.builder( return GridView.builder(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7, crossAxisCount: 7,
childAspectRatio: 1, childAspectRatio: 1,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
), ),
itemCount: rowCount * 7, itemCount: rowCount * 7,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@ -260,12 +276,11 @@ class _CalendarGrid extends StatelessWidget {
date.day == selectedDate!.day; date.day == selectedDate!.day;
final tasks = tasksByDate[dateStr]; final tasks = tasksByDate[dateStr];
final highestPriority = getHighestPriority(tasks); final priorities = getUniquePriorities(tasks);
return GestureDetector( return GestureDetector(
onTap: () => onDateSelected(date), onTap: () => onDateSelected(date),
child: Container( child: Container(
margin: const EdgeInsets.all(2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
@ -288,10 +303,10 @@ class _CalendarGrid extends StatelessWidget {
fontWeight: isToday || isSelected ? FontWeight.bold : null, fontWeight: isToday || isSelected ? FontWeight.bold : null,
), ),
), ),
if (highestPriority != null) if (priorities.isNotEmpty)
Positioned( Positioned(
bottom: 4, bottom: 6,
child: _PulsingDot(priority: highestPriority), child: _PriorityDots(priorities: priorities),
), ),
], ],
), ),
@ -302,6 +317,25 @@ class _CalendarGrid extends StatelessWidget {
} }
} }
class _PriorityDots extends StatelessWidget {
final List<Priority> priorities;
const _PriorityDots({required this.priorities});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: priorities
.map((priority) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: _PulsingDot(priority: priority),
))
.toList(),
);
}
}
class _PulsingDot extends StatefulWidget { class _PulsingDot extends StatefulWidget {
final Priority priority; final Priority priority;
@ -354,15 +388,15 @@ class _PulsingDotState extends State<_PulsingDot>
return Opacity( return Opacity(
opacity: _animation.value, opacity: _animation.value,
child: Container( child: Container(
width: 6, width: 10,
height: 6, height: 10,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getPriorityColor(), color: _getPriorityColor(),
shape: BoxShape.circle, shape: BoxShape.circle,
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: _getPriorityColor().withOpacity(0.5), color: _getPriorityColor().withValues(alpha: 0.5),
blurRadius: 2, blurRadius: 3,
spreadRadius: 1, spreadRadius: 1,
), ),
], ],

View File

@ -79,9 +79,12 @@ class _DailyAgendaView extends StatelessWidget {
], ],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () { onPressed: () async {
final dateStr = DateFormat('yyyy-MM-dd').format(vm.selectedDate); final dateStr = DateFormat('yyyy-MM-dd').format(vm.selectedDate);
context.push('/task/new?date=$dateStr'); final result = await context.push('/task/new?date=$dateStr');
if (result == true) {
vm.loadTasks();
}
}, },
child: const Icon(Icons.add), child: const Icon(Icons.add),
), ),
@ -140,7 +143,12 @@ class _DailyAgendaView extends StatelessWidget {
return TaskTile( return TaskTile(
task: task, task: task,
onToggle: () => vm.toggleTask(task.id), onToggle: () => vm.toggleTask(task.id),
onTap: () => context.push('/task/${task.id}/edit'), onTap: () async {
final result = await context.push('/task/${task.id}/edit');
if (result == true) {
vm.loadTasks();
}
},
onDelete: () => vm.deleteTask(task.id), onDelete: () => vm.deleteTask(task.id),
onReschedule: () => vm.rescheduleToTomorrow(task.id), onReschedule: () => vm.rescheduleToTomorrow(task.id),
); );
@ -169,14 +177,18 @@ class _DateNavigation extends StatelessWidget {
final dateFormat = DateFormat.yMMMMEEEEd(locale); final dateFormat = DateFormat.yMMMMEEEEd(locale);
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
IconButton( SizedBox(
icon: const Icon(Icons.chevron_left), width: 48,
height: 48,
child: IconButton(
icon: const Icon(Icons.chevron_left, size: 28),
onPressed: onPrevious, onPressed: onPrevious,
), ),
),
Expanded( Expanded(
child: Text( child: Text(
dateFormat.format(date), dateFormat.format(date),
@ -184,10 +196,14 @@ class _DateNavigation extends StatelessWidget {
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
), ),
IconButton( SizedBox(
icon: const Icon(Icons.chevron_right), width: 48,
height: 48,
child: IconButton(
icon: const Icon(Icons.chevron_right, size: 28),
onPressed: onNext, onPressed: onNext,
), ),
),
], ],
), ),
); );

View File

@ -99,11 +99,14 @@ class _TaskFormView extends StatelessWidget {
onChanged: vm.setDescription, onChanged: vm.setDescription,
maxLines: 3, maxLines: 3,
), ),
const SizedBox(height: 16), const SizedBox(height: 20),
ListTile( ConstrainedBox(
constraints: const BoxConstraints(minHeight: 72),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
title: Text(l10n.date), title: Text(l10n.date),
subtitle: Text(DateFormat.yMMMd(locale).format(vm.date)), subtitle: Text(DateFormat.yMMMd(locale).format(vm.date)),
trailing: const Icon(Icons.calendar_today), trailing: const Icon(Icons.calendar_today, size: 24),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
side: BorderSide(color: Theme.of(context).colorScheme.outline), side: BorderSide(color: Theme.of(context).colorScheme.outline),
@ -120,13 +123,16 @@ class _TaskFormView extends StatelessWidget {
} }
}, },
), ),
const SizedBox(height: 16), ),
const SizedBox(height: 20),
Text( Text(
l10n.priority, l10n.priority,
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
), ),
const SizedBox(height: 8), const SizedBox(height: 12),
SegmentedButton<Priority>( SizedBox(
height: 48,
child: SegmentedButton<Priority>(
segments: [ segments: [
ButtonSegment( ButtonSegment(
value: Priority.low, value: Priority.low,
@ -146,6 +152,7 @@ class _TaskFormView extends StatelessWidget {
vm.setPriority(selection.first); vm.setPriority(selection.first);
}, },
), ),
),
if (vm.status == FormStatus.error && vm.failure != null) ...[ if (vm.status == FormStatus.error && vm.failure != null) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
Container( Container(

View File

@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import '../../../../core/errors/failures.dart'; import '../../../../core/errors/failures.dart';
import '../../../../core/logging/app_logger.dart'; import '../../../../core/logging/app_logger.dart';
import '../../domain/entities/task_entity.dart'; import '../../domain/entities/task_entity.dart';
import '../../domain/enums/priority.dart';
import '../../domain/repositories/task_repository.dart'; import '../../domain/repositories/task_repository.dart';
enum TasksStatus { initial, loading, success, error } enum TasksStatus { initial, loading, success, error }
@ -31,13 +32,31 @@ class DailyTasksViewModel extends ChangeNotifier {
TaskFilter get filter => _filter; TaskFilter get filter => _filter;
List<TaskEntity> get _filteredTasks { List<TaskEntity> get _filteredTasks {
List<TaskEntity> filtered;
switch (_filter) { switch (_filter) {
case TaskFilter.all: case TaskFilter.all:
return _tasks; filtered = List.from(_tasks);
break;
case TaskFilter.active: case TaskFilter.active:
return _tasks.where((t) => !t.isDone).toList(); filtered = _tasks.where((t) => !t.isDone).toList();
break;
case TaskFilter.completed: case TaskFilter.completed:
return _tasks.where((t) => t.isDone).toList(); filtered = _tasks.where((t) => t.isDone).toList();
break;
}
// Sort by priority: high first, then medium, then low
filtered.sort((a, b) => _priorityOrder(a.priority).compareTo(_priorityOrder(b.priority)));
return filtered;
}
int _priorityOrder(Priority priority) {
switch (priority) {
case Priority.high:
return 0;
case Priority.medium:
return 1;
case Priority.low:
return 2;
} }
} }

View File

@ -26,18 +26,21 @@ class FilterChips extends StatelessWidget {
label: Text(l10n.filterAll), label: Text(l10n.filterAll),
selected: currentFilter == TaskFilter.all, selected: currentFilter == TaskFilter.all,
onSelected: (_) => onFilterChanged(TaskFilter.all), onSelected: (_) => onFilterChanged(TaskFilter.all),
materialTapTargetSize: MaterialTapTargetSize.padded,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FilterChip( FilterChip(
label: Text(l10n.filterActive), label: Text(l10n.filterActive),
selected: currentFilter == TaskFilter.active, selected: currentFilter == TaskFilter.active,
onSelected: (_) => onFilterChanged(TaskFilter.active), onSelected: (_) => onFilterChanged(TaskFilter.active),
materialTapTargetSize: MaterialTapTargetSize.padded,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FilterChip( FilterChip(
label: Text(l10n.filterCompleted), label: Text(l10n.filterCompleted),
selected: currentFilter == TaskFilter.completed, selected: currentFilter == TaskFilter.completed,
onSelected: (_) => onFilterChanged(TaskFilter.completed), onSelected: (_) => onFilterChanged(TaskFilter.completed),
materialTapTargetSize: MaterialTapTargetSize.padded,
), ),
], ],
), ),

View File

@ -69,28 +69,33 @@ class TaskTile extends StatelessWidget {
}, },
onDismissed: (_) => onDelete(), onDismissed: (_) => onDelete(),
child: Card( child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 72),
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row( child: Row(
children: [ children: [
Container( Container(
width: 4, width: 5,
height: 48, height: 56,
decoration: BoxDecoration( decoration: BoxDecoration(
color: priorityColor, color: priorityColor,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(3),
), ),
), ),
const SizedBox(width: 12), SizedBox(
Checkbox( width: 48,
height: 48,
child: Checkbox(
value: task.isDone, value: task.isDone,
onChanged: (_) => onToggle(), onChanged: (_) => onToggle(),
), ),
const SizedBox(width: 8), ),
const SizedBox(width: 4),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -118,7 +123,11 @@ class TaskTile extends StatelessWidget {
], ],
), ),
), ),
PopupMenuButton<String>( SizedBox(
width: 48,
height: 48,
child: PopupMenuButton<String>(
padding: EdgeInsets.zero,
onSelected: (value) async { onSelected: (value) async {
if (value == 'reschedule') { if (value == 'reschedule') {
onReschedule(); onReschedule();
@ -147,32 +156,36 @@ class TaskTile extends StatelessWidget {
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
PopupMenuItem( PopupMenuItem(
height: 56,
value: 'reschedule', value: 'reschedule',
child: Row( child: Row(
children: [ children: [
const Icon(Icons.schedule), const Icon(Icons.schedule, size: 24),
const SizedBox(width: 8), const SizedBox(width: 12),
Text(l10n.rescheduleToTomorrow), Text(l10n.rescheduleToTomorrow),
], ],
), ),
), ),
PopupMenuItem( PopupMenuItem(
height: 56,
value: 'delete', value: 'delete',
child: Row( child: Row(
children: [ children: [
Icon(Icons.delete, color: Theme.of(context).colorScheme.error), Icon(Icons.delete, size: 24, color: Theme.of(context).colorScheme.error),
const SizedBox(width: 8), const SizedBox(width: 12),
Text(l10n.delete, style: TextStyle(color: Theme.of(context).colorScheme.error)), Text(l10n.delete, style: TextStyle(color: Theme.of(context).colorScheme.error)),
], ],
), ),
), ),
], ],
), ),
),
], ],
), ),
), ),
), ),
), ),
),
); );
} }
} }