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
+21 -9
View File
@@ -18,6 +18,14 @@ class LoginErrorBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final showDetails = details != null
? () => InfoDialog.show(
context,
details!,
copyable: true,
title: 'Fehlerdetails',
)
: null;
return AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
@@ -25,18 +33,20 @@ class LoginErrorBanner extends StatelessWidget {
? const SizedBox(height: 0, width: double.infinity)
: Padding(
padding: const EdgeInsets.only(top: 14),
child: Semantics(
button: showDetails != null,
label: showDetails != null
? '${message!}, Fehlerdetails anzeigen'
: message,
onTap: showDetails,
child: ExcludeSemantics(
child: Material(
color: theme.colorScheme.errorContainer.withValues(alpha: 0.6),
color: theme.colorScheme.errorContainer.withValues(
alpha: 0.6,
),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: details != null
? () => InfoDialog.show(
context,
details!,
copyable: true,
title: 'Fehlerdetails',
)
: null,
onTap: showDetails,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(
@@ -76,6 +86,8 @@ class LoginErrorBanner extends StatelessWidget {
),
),
),
),
),
);
}
}
@@ -23,6 +23,8 @@ class FilesSortActions extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton<bool>(
tooltip:
'Sortierrichtung: ${ascending ? 'aufsteigend' : 'absteigend'}',
icon: Icon(
ascending ? Icons.text_rotate_up : Icons.text_rotation_down,
),
@@ -47,6 +49,8 @@ class FilesSortActions extends StatelessWidget {
onSelected: onDirectionChanged,
),
PopupMenuButton<SortOption>(
tooltip:
'Sortieren nach: ${SortOptions.getOption(currentSort).displayName}',
icon: const Icon(Icons.sort),
itemBuilder: (context) => SortOptions.options.keys
.map(
@@ -35,6 +35,8 @@ class GradeAveragesView extends StatelessWidget {
),
),
PopupMenuButton<bool>(
tooltip:
'Notensystem: ${bloc.isMiddleSchool() ? 'Realschule' : 'Oberstufe'}',
initialValue: bloc.isMiddleSchool(),
icon: const Icon(Icons.more_horiz),
itemBuilder: (context) => [true, false]
+5 -8
View File
@@ -43,6 +43,8 @@ class HolidaysView extends StatelessWidget {
onPressed: showDisclaimer,
),
PopupMenuButton<bool>(
tooltip:
'Vergangene Ferien ${bloc.showPastHolidays() ? 'ausblenden' : 'anzeigen'}',
initialValue: bloc.showPastHolidays(),
icon: const Icon(Icons.history),
itemBuilder: (context) => [true, false]
@@ -82,9 +84,7 @@ class HolidaysView extends StatelessWidget {
text: 'Keine Schulferien verfügbar',
);
}
return ListViewUtil.fromList<McHoliday>(
holidays,
(holiday) {
return ListViewUtil.fromList<McHoliday>(holidays, (holiday) {
String holidayYear() {
final startYear = holiday.startDate.year;
final endYear = holiday.endDate.year;
@@ -94,9 +94,7 @@ class HolidaysView extends StatelessWidget {
return ListTile(
leading: const CenteredLeading(Icon(Icons.calendar_month)),
title: Text(
'${holiday.longName} ${holidayYear()}',
),
title: Text('${holiday.longName} ${holidayYear()}'),
subtitle: Text(
'${holiday.startDate.formatDate()} - ${holiday.endDate.formatDate()}',
),
@@ -148,8 +146,7 @@ class HolidaysView extends StatelessWidget {
),
trailing: const Icon(Icons.arrow_right),
);
},
);
});
},
),
);
@@ -32,8 +32,9 @@ class MarianumDatesView extends StatelessWidget {
}
@override
Widget build(BuildContext context) =>
BlocModule<MarianumDatesBloc, LoadableState<MarianumDatesState>>(
Widget build(
BuildContext context,
) => BlocModule<MarianumDatesBloc, LoadableState<MarianumDatesState>>(
create: (context) => MarianumDatesBloc(),
autoRebuild: true,
child: (context, bloc, state) => Scaffold(
@@ -41,6 +42,8 @@ class MarianumDatesView extends StatelessWidget {
title: const Text('Marianum Termine'),
actions: [
PopupMenuButton<bool>(
tooltip:
'Vergangene Termine ${bloc.showPastEvents() ? 'ausblenden' : 'anzeigen'}',
initialValue: bloc.showPastEvents(),
icon: const Icon(Icons.history),
itemBuilder: (context) => [true, false]
@@ -57,9 +60,7 @@ class MarianumDatesView extends StatelessWidget {
color: Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 15),
Text(
e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen',
),
Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'),
],
),
),
+8 -1
View File
@@ -91,7 +91,14 @@ class _ChatTileState extends State<ChatTile> {
: null,
leading: Stack(
children: [
circleAvatar,
// Der Name steht bereits im Titel der Zeile das Avatarbild selbst
// muss der Screenreader nicht ansagen. Nur bei Gruppen ist der
// Hinweis „Gruppe" nützlich (sonst nicht vom Einzelchat zu
// unterscheiden).
Semantics(
label: isGroup ? A11yLabels.group : null,
child: ExcludeSemantics(child: circleAvatar),
),
Visibility(
visible: widget.data.isFavorite,
child: Positioned(
+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,9 +287,25 @@ class _DayColumn extends StatelessWidget {
final isTablet = MediaQuery.of(context).size.shortestSide >= 600;
final laidOut = assignLanes(dayAppointments, maxLanes: isTablet ? 3 : 2);
return GestureDetector(
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),
onLongPressStart: (details) =>
_handleLongPress(details, dayAppointments),
child: DecoratedBox(
decoration: BoxDecoration(
color: isToday ? theme.colorScheme.primary.withAlpha(14) : null,
@@ -298,6 +322,16 @@ class _DayColumn extends StatelessWidget {
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: 0,
left: 0,
right: 0,
child: Semantics(
header: true,
label: headerLabel,
child: const SizedBox(height: 1),
),
),
for (final period in schedule.periods)
Positioned(
top: layout.topOf(period),
@@ -352,8 +386,11 @@ class _DayColumn extends StatelessWidget {
),
LaidOutOverflow(:final appointments) => Semantics(
button: true,
label: A11yLabels.moreAppointments(appointments.length),
onTap: () => _showOverflowSheet(context, appointments),
label: A11yLabels.moreAppointments(
appointments.length,
),
onTap: () =>
_showOverflowSheet(context, appointments),
child: ExcludeSemantics(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
@@ -379,6 +416,7 @@ class _DayColumn extends StatelessWidget {
},
),
),
),
);
}
}
@@ -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,6 +164,10 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
child: child,
),
),
// 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,
@@ -175,6 +176,7 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
),
),
),
),
ClipRect(
child: AnimatedSize(
duration: const Duration(milliseconds: 320),
+3 -1
View File
@@ -16,15 +16,17 @@ abstract final class A11yLabels {
static const draft = 'Entwurf';
static const yourReaction = 'deine Reaktion';
// Bilder
// Bilder / Kontakte
static const profilePicture = 'Profilbild';
static const groupPicture = 'Gruppenbild';
static const group = 'Gruppe';
static const roomPlan = 'Raumplan der Schule als Grafik';
// Stundenplan
static const cancelled = 'Ausfall';
static const breakTime = 'Pause';
static const allDay = 'Ganztägig';
static const today = 'heute';
/// Beschreibt eine Stundenplan-Kachel für den Screenreader, z.B.
/// „Mathe, Raum 101, 08:0008:45, Ausfall". Zeilenumbrüche in [location]
+5
View File
@@ -22,6 +22,10 @@ Future<void> showDetailsBottomSheet(
padding: EdgeInsets.only(
bottom: 16 + MediaQuery.viewInsetsOf(sheetContext).bottom,
),
// Ohne dieses innere Material malt ListTile-Ink aufs Sheet-Material
// außerhalb des Scrollbereichs und stretcht beim Overscroll nicht mit.
child: Material(
type: MaterialType.transparency,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -32,5 +36,6 @@ Future<void> showDetailsBottomSheet(
),
),
),
),
);
}
+19 -4
View File
@@ -154,7 +154,8 @@ class _DownloadTrayHostState extends State<DownloadTrayHost>
/// The chip only shows when there's something the inline UI can't already
/// convey: multiple downloads, a finished/failed one (no inline progress
/// left), or a lone download whose originating screen the user has left.
bool _shouldShowChip(List<DownloadJob> jobs, int epoch) => shouldShowDownloadChip(
bool _shouldShowChip(List<DownloadJob> jobs, int epoch) =>
shouldShowDownloadChip(
sheetOpen: _sheetOpen,
jobCount: jobs.length,
anySurfaced: jobs.any(
@@ -311,7 +312,12 @@ class _TrayChip extends StatelessWidget {
label = doneCount == 1 ? 'Download fertig' : '$doneCount fertig';
}
return Material(
return Semantics(
button: true,
label: '$label, Downloads anzeigen',
onTap: onTap,
child: ExcludeSemantics(
child: Material(
color: colors.surfaceContainerHigh,
elevation: 4,
borderRadius: BorderRadius.circular(24),
@@ -320,7 +326,10 @@ class _TrayChip extends StatelessWidget {
borderRadius: BorderRadius.circular(24),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -337,11 +346,17 @@ class _TrayChip extends StatelessWidget {
),
),
const SizedBox(width: 8),
Icon(Icons.expand_less, size: 20, color: colors.onSurfaceVariant),
Icon(
Icons.expand_less,
size: 20,
color: colors.onSurfaceVariant,
),
],
),
),
),
),
),
);
},
);
+5 -2
View File
@@ -186,6 +186,7 @@ class _FileViewerState extends State<FileViewer> {
...actions,
if (showActionsMenu)
PopupMenuButton<FileViewingActions>(
tooltip: 'Dateiaktionen',
onSelected: _handleAction,
itemBuilder: (context) => _availableActions()
.map(
@@ -282,8 +283,10 @@ class _FileViewerState extends State<FileViewer> {
),
);
Widget _buildPdfView() =>
Scaffold(appBar: _appbar(), body: DeferredPdfViewer(path: widget.path));
Widget _buildPdfView() => Scaffold(
appBar: _appbar(),
body: DeferredPdfViewer(path: widget.path),
);
Widget _buildVideoView() => Scaffold(
appBar: _appbar(),