further improved accessibility across the application and add tooltips to action buttons

This commit is contained in:
2026-07-15 19:29:40 +02:00
parent 478f0ff20b
commit e8c6ac1c65
13 changed files with 366 additions and 268 deletions
+11 -1
View File
@@ -106,6 +106,10 @@ class _TimetableState extends State<Timetable> {
.canViewForeignTimetables;
return Scaffold(
appBar: AppBar(
// Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen
// Scrollables feuern ScrollNotifications, die sonst den Material-3
// "scrolled under"-Farbwechsel der AppBar dauerhaft auslösen.
notificationPredicate: (_) => false,
title: const Text('Stunden & Vertretungsplan'),
actions: [
IconButton(
@@ -114,6 +118,7 @@ class _TimetableState extends State<Timetable> {
onPressed: atToday ? null : _jumpToToday,
),
PopupMenuButton<_CalendarAction>(
tooltip: 'Kalendereinträge',
icon: const Icon(Icons.edit_calendar_outlined),
onSelected: _onAction,
itemBuilder: (_) => const [
@@ -176,6 +181,9 @@ class _TimetableState extends State<Timetable> {
.canViewForeignTimetables;
return Scaffold(
appBar: AppBar(
// Siehe _buildOwnPlan: den scroll-under-Farbwechsel unterdrücken, weil
// der Kalender nicht scrollt, aber ScrollNotifications feuert.
notificationPredicate: (_) => false,
title: const Text('Stunden & Vertretungsplan'),
actions: [
IconButton(
@@ -285,7 +293,9 @@ class _ViewingBanner extends StatelessWidget {
),
compactButton(
icon: isFavorite ? Icons.star : Icons.star_border,
tooltip: isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren',
tooltip: isFavorite
? 'Favorit entfernen'
: 'Als Favorit markieren',
onPressed: () => _toggleFavorite(context),
),
const SizedBox(width: 4),
@@ -34,7 +34,15 @@ class _WeekGrid extends StatelessWidget {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PeriodRuler(schedule: schedule, layout: layout, width: rulerWidth),
// Reine Orientierungshilfe der Screenreader soll die Zeitleiste nicht
// vorlesen; jede Termin-Kachel trägt ihre Uhrzeit selbst im Label.
ExcludeSemantics(
child: _PeriodRuler(
schedule: schedule,
layout: layout,
width: rulerWidth,
),
),
for (var d = 0; d < 5; d++)
Expanded(
child: _DayColumn(
@@ -279,104 +287,134 @@ class _DayColumn extends StatelessWidget {
final isTablet = MediaQuery.of(context).size.shortestSide >= 600;
final laidOut = assignLanes(dayAppointments, maxLanes: isTablet ? 3 : 2);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onLongPressStart: (details) => _handleLongPress(details, dayAppointments),
child: DecoratedBox(
decoration: BoxDecoration(
color: isToday ? theme.colorScheme.primary.withAlpha(14) : null,
border: Border(
left: BorderSide(
color: theme.dividerColor.withAlpha(90),
width: 0.5,
final dayName = DateFormat(
'EEEE',
Localizations.localeOf(context).toString(),
).format(date);
final headerLabel = isToday
? '$dayName, ${date.formatDateShort()}, ${A11yLabels.today}'
: '$dayName, ${date.formatDateShort()}';
// container + OrdinalSortKey ⇒ der Screenreader liest Tag für Tag (Mo→Fr),
// jeden Tag vollständig, statt zeilenweise quer über alle Spalten. Der
// Header steckt als erstes Element in der Spalte, damit „Montag: …" vor den
// Stunden angesagt wird.
return Semantics(
container: true,
sortKey: OrdinalSortKey(date.weekday.toDouble()),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onLongPressStart: (details) =>
_handleLongPress(details, dayAppointments),
child: DecoratedBox(
decoration: BoxDecoration(
color: isToday ? theme.colorScheme.primary.withAlpha(14) : null,
border: Border(
left: BorderSide(
color: theme.dividerColor.withAlpha(90),
width: 0.5,
),
),
),
),
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
return Stack(
clipBehavior: Clip.none,
children: [
for (final period in schedule.periods)
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: layout.topOf(period),
top: 0,
left: 0,
right: 0,
child: Container(
height: 0.5,
color: theme.dividerColor.withAlpha(60),
child: Semantics(
header: true,
label: headerLabel,
child: const SizedBox(height: 1),
),
),
for (final region in dayRegions)
Positioned(
top: layout.yOfDateTime(region.start),
height:
(layout.yOfDateTime(region.end) -
layout.yOfDateTime(region.start))
.clamp(0, double.infinity),
left: 0,
right: 0,
child: TimeRegionTile(region: region.region),
),
for (final cell in laidOut)
Positioned(
top: layout.yOfDateTime(cell.startTime),
height:
(layout.yOfDateTime(cell.endTime) -
layout.yOfDateTime(cell.startTime))
.clamp(0, double.infinity),
left: cell.lane * width / cell.laneCount,
width: width / cell.laneCount,
child: switch (cell) {
LaidOutAppointment(:final appointment) => Semantics(
button: true,
label: A11yLabels.appointmentLabel(
subject: appointment.subject,
location: appointment.location ?? '',
start: appointment.startTime,
end: appointment.endTime,
crossedOut: isCrossedOut(appointment),
),
onTap: () => onAppointmentTap(appointment),
child: ExcludeSemantics(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onAppointmentTap(appointment),
child: AppointmentTile(
appointment: appointment,
crossedOut: isCrossedOut(appointment),
for (final period in schedule.periods)
Positioned(
top: layout.topOf(period),
left: 0,
right: 0,
child: Container(
height: 0.5,
color: theme.dividerColor.withAlpha(60),
),
),
for (final region in dayRegions)
Positioned(
top: layout.yOfDateTime(region.start),
height:
(layout.yOfDateTime(region.end) -
layout.yOfDateTime(region.start))
.clamp(0, double.infinity),
left: 0,
right: 0,
child: TimeRegionTile(region: region.region),
),
for (final cell in laidOut)
Positioned(
top: layout.yOfDateTime(cell.startTime),
height:
(layout.yOfDateTime(cell.endTime) -
layout.yOfDateTime(cell.startTime))
.clamp(0, double.infinity),
left: cell.lane * width / cell.laneCount,
width: width / cell.laneCount,
child: switch (cell) {
LaidOutAppointment(:final appointment) => Semantics(
button: true,
label: A11yLabels.appointmentLabel(
subject: appointment.subject,
location: appointment.location ?? '',
start: appointment.startTime,
end: appointment.endTime,
crossedOut: isCrossedOut(appointment),
),
onTap: () => onAppointmentTap(appointment),
child: ExcludeSemantics(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onAppointmentTap(appointment),
child: AppointmentTile(
appointment: appointment,
crossedOut: isCrossedOut(appointment),
),
),
),
),
),
LaidOutOverflow(:final appointments) => Semantics(
button: true,
label: A11yLabels.moreAppointments(appointments.length),
onTap: () => _showOverflowSheet(context, appointments),
child: ExcludeSemantics(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () =>
_showOverflowSheet(context, appointments),
child: _OverflowTile(count: appointments.length),
LaidOutOverflow(:final appointments) => Semantics(
button: true,
label: A11yLabels.moreAppointments(
appointments.length,
),
onTap: () =>
_showOverflowSheet(context, appointments),
child: ExcludeSemantics(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () =>
_showOverflowSheet(context, appointments),
child: _OverflowTile(count: appointments.length),
),
),
),
),
},
),
if (isToday)
ValueListenableBuilder<DateTime>(
valueListenable: nowNotifier,
builder: (_, now, child) => _CurrentTimeMarker(
now: now,
layout: layout,
theme: theme,
},
),
),
],
);
},
if (isToday)
ValueListenableBuilder<DateTime>(
valueListenable: nowNotifier,
builder: (_, now, child) => _CurrentTimeMarker(
now: now,
layout: layout,
theme: theme,
),
),
],
);
},
),
),
),
);
@@ -9,6 +9,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
@@ -103,11 +104,7 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
final newTotalWeeks =
newLastMonday.difference(newFirstMonday).inDays ~/ 7 + 1;
final visibleWeekStart = _firstMonday.addDays(_currentWeekIndex * 7);
final newIndex = visibleWeekStart
.difference(newFirstMonday)
.inDays
~/
7;
final newIndex = visibleWeekStart.difference(newFirstMonday).inDays ~/ 7;
final clampedIndex = newIndex.clamp(0, newTotalWeeks - 1);
final oldController = _pageController;
_pageController = PageController(initialPage: clampedIndex);
@@ -167,11 +164,16 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
child: child,
),
),
child: _DayHeaderStrip(
key: ValueKey(visibleWeekStart),
weekStart: visibleWeekStart,
today: _today,
rulerWidth: _rulerWidth,
// Die visuelle „MO/14"-Leiste ist für den Screenreader unbrauchbar;
// die Tag-Info wird stattdessen als Header in jede Tagesspalte
// eingehängt (siehe _DayColumn).
child: ExcludeSemantics(
child: _DayHeaderStrip(
key: ValueKey(visibleWeekStart),
weekStart: visibleWeekStart,
today: _today,
rulerWidth: _rulerWidth,
),
),
),
),