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

View File

@ -79,9 +79,12 @@ class _DailyAgendaView extends StatelessWidget {
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
onPressed: () async {
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),
),
@ -140,7 +143,12 @@ class _DailyAgendaView extends StatelessWidget {
return TaskTile(
task: task,
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),
onReschedule: () => vm.rescheduleToTomorrow(task.id),
);
@ -169,13 +177,17 @@ class _DateNavigation extends StatelessWidget {
final dateFormat = DateFormat.yMMMMEEEEd(locale);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: onPrevious,
SizedBox(
width: 48,
height: 48,
child: IconButton(
icon: const Icon(Icons.chevron_left, size: 28),
onPressed: onPrevious,
),
),
Expanded(
child: Text(
@ -184,9 +196,13 @@ class _DateNavigation extends StatelessWidget {
style: Theme.of(context).textTheme.titleMedium,
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: onNext,
SizedBox(
width: 48,
height: 48,
child: IconButton(
icon: const Icon(Icons.chevron_right, size: 28),
onPressed: onNext,
),
),
],
),

View File

@ -99,52 +99,59 @@ class _TaskFormView extends StatelessWidget {
onChanged: vm.setDescription,
maxLines: 3,
),
const SizedBox(height: 16),
ListTile(
title: Text(l10n.date),
subtitle: Text(DateFormat.yMMMd(locale).format(vm.date)),
trailing: const Icon(Icons.calendar_today),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: Theme.of(context).colorScheme.outline),
const SizedBox(height: 20),
ConstrainedBox(
constraints: const BoxConstraints(minHeight: 72),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
title: Text(l10n.date),
subtitle: Text(DateFormat.yMMMd(locale).format(vm.date)),
trailing: const Icon(Icons.calendar_today, size: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: Theme.of(context).colorScheme.outline),
),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: vm.date,
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
if (picked != null) {
vm.setDate(picked);
}
},
),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: vm.date,
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
if (picked != null) {
vm.setDate(picked);
}
},
),
const SizedBox(height: 16),
const SizedBox(height: 20),
Text(
l10n.priority,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
SegmentedButton<Priority>(
segments: [
ButtonSegment(
value: Priority.low,
label: Text(l10n.priorityLow),
),
ButtonSegment(
value: Priority.medium,
label: Text(l10n.priorityMedium),
),
ButtonSegment(
value: Priority.high,
label: Text(l10n.priorityHigh),
),
],
selected: {vm.priority},
onSelectionChanged: (selection) {
vm.setPriority(selection.first);
},
const SizedBox(height: 12),
SizedBox(
height: 48,
child: SegmentedButton<Priority>(
segments: [
ButtonSegment(
value: Priority.low,
label: Text(l10n.priorityLow),
),
ButtonSegment(
value: Priority.medium,
label: Text(l10n.priorityMedium),
),
ButtonSegment(
value: Priority.high,
label: Text(l10n.priorityHigh),
),
],
selected: {vm.priority},
onSelectionChanged: (selection) {
vm.setPriority(selection.first);
},
),
),
if (vm.status == FormStatus.error && vm.failure != null) ...[
const SizedBox(height: 24),

View File

@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/logging/app_logger.dart';
import '../../domain/entities/task_entity.dart';
import '../../domain/enums/priority.dart';
import '../../domain/repositories/task_repository.dart';
enum TasksStatus { initial, loading, success, error }
@ -31,13 +32,31 @@ class DailyTasksViewModel extends ChangeNotifier {
TaskFilter get filter => _filter;
List<TaskEntity> get _filteredTasks {
List<TaskEntity> filtered;
switch (_filter) {
case TaskFilter.all:
return _tasks;
filtered = List.from(_tasks);
break;
case TaskFilter.active:
return _tasks.where((t) => !t.isDone).toList();
filtered = _tasks.where((t) => !t.isDone).toList();
break;
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),
selected: currentFilter == TaskFilter.all,
onSelected: (_) => onFilterChanged(TaskFilter.all),
materialTapTargetSize: MaterialTapTargetSize.padded,
),
const SizedBox(width: 8),
FilterChip(
label: Text(l10n.filterActive),
selected: currentFilter == TaskFilter.active,
onSelected: (_) => onFilterChanged(TaskFilter.active),
materialTapTargetSize: MaterialTapTargetSize.padded,
),
const SizedBox(width: 8),
FilterChip(
label: Text(l10n.filterCompleted),
selected: currentFilter == TaskFilter.completed,
onSelected: (_) => onFilterChanged(TaskFilter.completed),
materialTapTargetSize: MaterialTapTargetSize.padded,
),
],
),

View File

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