From e8c6ac1c656d9b075af2e382607ae9894f1a0ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 15 Jul 2026 19:29:40 +0200 Subject: [PATCH] further improved accessibility across the application and add tooltips to action buttons --- .../login/widgets/login_error_banner.dart | 94 ++++---- .../files/widgets/files_sort_actions.dart | 4 + .../grade_averages/grade_averages_view.dart | 2 + lib/view/pages/holidays/holidays_view.dart | 13 +- .../marianum_dates/marianum_dates_view.dart | 155 ++++++------- lib/view/pages/talk/widgets/chat_tile.dart | 9 +- lib/view/pages/timetable/timetable.dart | 12 +- .../timetable/widgets/calendar/week_grid.dart | 210 +++++++++++------- .../widgets/custom_workweek_calendar.dart | 22 +- lib/widget/a11y/a11y_labels.dart | 4 +- lib/widget/details_bottom_sheet.dart | 19 +- lib/widget/downloads/download_tray.dart | 83 ++++--- lib/widget/file_viewer.dart | 7 +- 13 files changed, 366 insertions(+), 268 deletions(-) diff --git a/lib/view/login/widgets/login_error_banner.dart b/lib/view/login/widgets/login_error_banner.dart index 87d2ff4..3f32e83 100644 --- a/lib/view/login/widgets/login_error_banner.dart +++ b/lib/view/login/widgets/login_error_banner.dart @@ -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,52 +33,56 @@ class LoginErrorBanner extends StatelessWidget { ? const SizedBox(height: 0, width: double.infinity) : Padding( padding: const EdgeInsets.only(top: 14), - child: Material( - 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, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, + 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, ), - child: Row( - children: [ - Icon( - Icons.error_outline, - size: 20, - color: theme.colorScheme.onErrorContainer, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: showDetails, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, ), - const SizedBox(width: 10), - Expanded( - child: Text( - message!, - style: TextStyle( + child: Row( + children: [ + Icon( + Icons.error_outline, + size: 20, color: theme.colorScheme.onErrorContainer, - fontSize: 13, - height: 1.3, ), - ), + const SizedBox(width: 10), + Expanded( + child: Text( + message!, + style: TextStyle( + color: theme.colorScheme.onErrorContainer, + fontSize: 13, + height: 1.3, + ), + ), + ), + if (details != null) ...[ + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + size: 20, + color: theme.colorScheme.onErrorContainer + .withValues(alpha: 0.7), + ), + ], + ], ), - if (details != null) ...[ - const SizedBox(width: 8), - Icon( - Icons.chevron_right, - size: 20, - color: theme.colorScheme.onErrorContainer - .withValues(alpha: 0.7), - ), - ], - ], + ), ), ), ), diff --git a/lib/view/pages/files/widgets/files_sort_actions.dart b/lib/view/pages/files/widgets/files_sort_actions.dart index 77a3175..2ff1631 100644 --- a/lib/view/pages/files/widgets/files_sort_actions.dart +++ b/lib/view/pages/files/widgets/files_sort_actions.dart @@ -23,6 +23,8 @@ class FilesSortActions extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ PopupMenuButton( + 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( + tooltip: + 'Sortieren nach: ${SortOptions.getOption(currentSort).displayName}', icon: const Icon(Icons.sort), itemBuilder: (context) => SortOptions.options.keys .map( diff --git a/lib/view/pages/grade_averages/grade_averages_view.dart b/lib/view/pages/grade_averages/grade_averages_view.dart index ca5a255..6386c23 100644 --- a/lib/view/pages/grade_averages/grade_averages_view.dart +++ b/lib/view/pages/grade_averages/grade_averages_view.dart @@ -35,6 +35,8 @@ class GradeAveragesView extends StatelessWidget { ), ), PopupMenuButton( + tooltip: + 'Notensystem: ${bloc.isMiddleSchool() ? 'Realschule' : 'Oberstufe'}', initialValue: bloc.isMiddleSchool(), icon: const Icon(Icons.more_horiz), itemBuilder: (context) => [true, false] diff --git a/lib/view/pages/holidays/holidays_view.dart b/lib/view/pages/holidays/holidays_view.dart index 74f56b6..75ec1c9 100644 --- a/lib/view/pages/holidays/holidays_view.dart +++ b/lib/view/pages/holidays/holidays_view.dart @@ -43,6 +43,8 @@ class HolidaysView extends StatelessWidget { onPressed: showDisclaimer, ), PopupMenuButton( + 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( - holidays, - (holiday) { + return ListViewUtil.fromList(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), ); - }, - ); + }); }, ), ); diff --git a/lib/view/pages/marianum_dates/marianum_dates_view.dart b/lib/view/pages/marianum_dates/marianum_dates_view.dart index 13434c6..8aa2891 100644 --- a/lib/view/pages/marianum_dates/marianum_dates_view.dart +++ b/lib/view/pages/marianum_dates/marianum_dates_view.dart @@ -32,89 +32,90 @@ class MarianumDatesView extends StatelessWidget { } @override - Widget build(BuildContext context) => - BlocModule>( - create: (context) => MarianumDatesBloc(), - autoRebuild: true, - child: (context, bloc, state) => Scaffold( - appBar: AppBar( - title: const Text('Marianum Termine'), - actions: [ - PopupMenuButton( - initialValue: bloc.showPastEvents(), - icon: const Icon(Icons.history), - itemBuilder: (context) => [true, false] - .map( - (e) => PopupMenuItem( - value: e, - enabled: e != bloc.showPastEvents(), - child: Row( - children: [ - Icon( - e - ? Icons.history_outlined - : Icons.history_toggle_off_outlined, - color: Theme.of(context).colorScheme.onSurface, - ), - const SizedBox(width: 15), - Text( - e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen', - ), - ], - ), - ), - ) - .toList(), - onSelected: (e) => bloc.add(SetPastEventsVisible(e)), - ), - IconButton( - tooltip: 'Suchen', - icon: const Icon(Icons.search), - onPressed: () { - final events = bloc.getEvents() ?? const []; - showSearch( - context: context, - delegate: SearchMarianumDates(events), - ); - }, - ), - ], - ), - body: LoadableStateConsumer( - child: (state, loading) { - final events = bloc.getEvents() ?? const []; - final groups = _groupByMonth(events); - - if (groups.isEmpty) { - return const PlaceholderView( - icon: Icons.event_busy_outlined, - text: 'Keine Termine', - ); - } - - return CustomScrollView( - slivers: [ - for (final group in groups) - SliverMainAxisGroup( - slivers: [ - SliverPersistentHeader( - pinned: true, - delegate: MonthHeaderDelegate(label: group.label), - ), - SliverList.builder( - itemCount: group.events.length, - itemBuilder: (_, i) => - MarianumDateRow(event: group.events[i]), + Widget build( + BuildContext context, + ) => BlocModule>( + create: (context) => MarianumDatesBloc(), + autoRebuild: true, + child: (context, bloc, state) => Scaffold( + appBar: AppBar( + title: const Text('Marianum Termine'), + actions: [ + PopupMenuButton( + tooltip: + 'Vergangene Termine ${bloc.showPastEvents() ? 'ausblenden' : 'anzeigen'}', + initialValue: bloc.showPastEvents(), + icon: const Icon(Icons.history), + itemBuilder: (context) => [true, false] + .map( + (e) => PopupMenuItem( + value: e, + enabled: e != bloc.showPastEvents(), + child: Row( + children: [ + Icon( + e + ? Icons.history_outlined + : Icons.history_toggle_off_outlined, + color: Theme.of(context).colorScheme.onSurface, ), + const SizedBox(width: 15), + Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'), ], ), - const SliverToBoxAdapter(child: SizedBox(height: 24)), - ], + ), + ) + .toList(), + onSelected: (e) => bloc.add(SetPastEventsVisible(e)), + ), + IconButton( + tooltip: 'Suchen', + icon: const Icon(Icons.search), + onPressed: () { + final events = bloc.getEvents() ?? const []; + showSearch( + context: context, + delegate: SearchMarianumDates(events), ); }, ), - ), - ); + ], + ), + body: LoadableStateConsumer( + child: (state, loading) { + final events = bloc.getEvents() ?? const []; + final groups = _groupByMonth(events); + + if (groups.isEmpty) { + return const PlaceholderView( + icon: Icons.event_busy_outlined, + text: 'Keine Termine', + ); + } + + return CustomScrollView( + slivers: [ + for (final group in groups) + SliverMainAxisGroup( + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: MonthHeaderDelegate(label: group.label), + ), + SliverList.builder( + itemCount: group.events.length, + itemBuilder: (_, i) => + MarianumDateRow(event: group.events[i]), + ), + ], + ), + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ); + }, + ), + ), + ); } class _MonthGroup { diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index d46aa49..f471b84 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -91,7 +91,14 @@ class _ChatTileState extends State { : 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( diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 7709939..da3f848 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -106,6 +106,10 @@ class _TimetableState extends State { .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 { 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 { .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), diff --git a/lib/view/pages/timetable/widgets/calendar/week_grid.dart b/lib/view/pages/timetable/widgets/calendar/week_grid.dart index f830f04..f46fa61 100644 --- a/lib/view/pages/timetable/widgets/calendar/week_grid.dart +++ b/lib/view/pages/timetable/widgets/calendar/week_grid.dart @@ -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( - valueListenable: nowNotifier, - builder: (_, now, child) => _CurrentTimeMarker( - now: now, - layout: layout, - theme: theme, + }, ), - ), - ], - ); - }, + if (isToday) + ValueListenableBuilder( + valueListenable: nowNotifier, + builder: (_, now, child) => _CurrentTimeMarker( + now: now, + layout: layout, + theme: theme, + ), + ), + ], + ); + }, + ), ), ), ); diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 1e7faa5..690a749 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -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 { 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 { 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, + ), ), ), ), diff --git a/lib/widget/a11y/a11y_labels.dart b/lib/widget/a11y/a11y_labels.dart index 3754922..806ce6f 100644 --- a/lib/widget/a11y/a11y_labels.dart +++ b/lib/widget/a11y/a11y_labels.dart @@ -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:00–08:45, Ausfall". Zeilenumbrüche in [location] diff --git a/lib/widget/details_bottom_sheet.dart b/lib/widget/details_bottom_sheet.dart index 823d6e5..e74486e 100644 --- a/lib/widget/details_bottom_sheet.dart +++ b/lib/widget/details_bottom_sheet.dart @@ -22,13 +22,18 @@ Future showDetailsBottomSheet( padding: EdgeInsets.only( bottom: 16 + MediaQuery.viewInsetsOf(sheetContext).bottom, ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (header != null) ...[header, const Divider(height: 1)], - ...children(sheetContext), - ], + // 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, + children: [ + if (header != null) ...[header, const Divider(height: 1)], + ...children(sheetContext), + ], + ), ), ), ), diff --git a/lib/widget/downloads/download_tray.dart b/lib/widget/downloads/download_tray.dart index a3a33c0..f120849 100644 --- a/lib/widget/downloads/download_tray.dart +++ b/lib/widget/downloads/download_tray.dart @@ -154,13 +154,14 @@ class _DownloadTrayHostState extends State /// 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 jobs, int epoch) => shouldShowDownloadChip( - sheetOpen: _sheetOpen, - jobCount: jobs.length, - anySurfaced: jobs.any( - (j) => j.isDone || j.isFailed || (_originEpoch[j] ?? epoch) != epoch, - ), - ); + bool _shouldShowChip(List jobs, int epoch) => + shouldShowDownloadChip( + sheetOpen: _sheetOpen, + jobCount: jobs.length, + anySurfaced: jobs.any( + (j) => j.isDone || j.isFailed || (_originEpoch[j] ?? epoch) != epoch, + ), + ); Future _openSheet() async { if (_sheetOpen) return; @@ -311,34 +312,48 @@ class _TrayChip extends StatelessWidget { label = doneCount == 1 ? 'Download fertig' : '$doneCount fertig'; } - return Material( - color: colors.surfaceContainerHigh, - elevation: 4, - borderRadius: BorderRadius.circular(24), - shadowColor: Colors.black45, - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - leading, - const SizedBox(width: 12), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), + return Semantics( + button: true, + label: '$label, Downloads anzeigen', + onTap: onTap, + child: ExcludeSemantics( + child: Material( + color: colors.surfaceContainerHigh, + elevation: 4, + borderRadius: BorderRadius.circular(24), + shadowColor: Colors.black45, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, ), - const SizedBox(width: 8), - Icon(Icons.expand_less, size: 20, color: colors.onSurfaceVariant), - ], + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + leading, + const SizedBox(width: 12), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Icon( + Icons.expand_less, + size: 20, + color: colors.onSurfaceVariant, + ), + ], + ), + ), ), ), ), diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index d0ca27f..ac9ed2f 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -186,6 +186,7 @@ class _FileViewerState extends State { ...actions, if (showActionsMenu) PopupMenuButton( + tooltip: 'Dateiaktionen', onSelected: _handleAction, itemBuilder: (context) => _availableActions() .map( @@ -282,8 +283,10 @@ class _FileViewerState extends State { ), ); - 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(),