From 478f0ff20b2930670a83ae53463a7d58a9d561d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Mon, 13 Jul 2026 23:41:47 +0200 Subject: [PATCH] implemented comprehensive accessibility (A11y) support across the app --- .../view/loadable_state_error_bar.dart | 28 ++-- lib/state/app/modules/app_modules.dart | 1 + lib/view/login/widgets/login_branding.dart | 13 +- .../files/search/files_search_delegate.dart | 1 + .../files/sharing/share_options_sheet.dart | 1 + .../files/sharing/sharee_picker_page.dart | 1 + .../element_picker_page.dart | 1 + .../grade_averages_list_view.dart | 3 + .../grade_averages/grade_averages_view.dart | 1 + lib/view/pages/holidays/holidays_view.dart | 1 + .../marianum_dates/marianum_dates_view.dart | 1 + .../marianum_dates/search_marianum_dates.dart | 7 +- .../marianum_message_list_view.dart | 1 + .../search_marianum_messages.dart | 7 +- lib/view/pages/more/roomplan/roomplan.dart | 18 ++- lib/view/pages/overhang.dart | 1 + .../pages/settings/modules_settings_page.dart | 2 + .../settings/widgets/push_status_sheet.dart | 13 +- .../pages/share_intent/share_chat_picker.dart | 1 + lib/view/pages/talk/chat_list.dart | 1 + lib/view/pages/talk/join_chat.dart | 6 +- lib/view/pages/talk/search_chat.dart | 6 +- lib/view/pages/talk/widgets/chat_bubble.dart | 125 ++++++++-------- .../talk/widgets/chat_bubble_reactions.dart | 85 ++++++----- .../widgets/chat_message_options_dialog.dart | 1 + .../talk/widgets/chat_search_app_bar.dart | 3 + .../pages/talk/widgets/chat_textfield.dart | 1 + lib/view/pages/talk/widgets/chat_tile.dart | 38 +++-- .../talk/widgets/split_view_placeholder.dart | 5 +- .../custom_events/custom_events_view.dart | 3 + .../pages/timetable/details/lesson_sheet.dart | 1 + .../subject_colors/search_subject_colors.dart | 7 +- .../subject_colors/subject_colors_view.dart | 1 + lib/view/pages/timetable/timetable.dart | 2 + .../widgets/calendar/outside_chips.dart | 134 +++++++++++------- .../timetable/widgets/calendar/week_grid.dart | 38 +++-- .../widgets/custom_workweek_calendar.dart | 1 + lib/widget/a11y/a11y_labels.dart | 49 +++++++ lib/widget/app_progress_indicator.dart | 32 ++++- lib/widget/async_actions/async_mixin.dart | 12 +- lib/widget/emoji_picker_dialog.dart | 1 + lib/widget/file_viewer.dart | 1 + lib/widget/large_profile_picture_view.dart | 34 +++-- lib/widget/user_avatar.dart | 34 ++++- test/widget/a11y_labels_test.dart | 63 ++++++++ test/widget/app_progress_indicator_test.dart | 30 ++++ 46 files changed, 590 insertions(+), 226 deletions(-) create mode 100644 lib/widget/a11y/a11y_labels.dart create mode 100644 test/widget/a11y_labels_test.dart create mode 100644 test/widget/app_progress_indicator_test.dart diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart index 021952b..417af24 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart @@ -107,16 +107,26 @@ class _LoadableStateErrorBarTextState extends State { var bloc = context.watch(); final foreground = bloc.connectionForegroundColor(context); - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(bloc.connectionIcon(), size: 14, color: foreground), - const SizedBox(width: 10), - Text( - bloc.connectionText(lastUpdated: widget.lastUpdated), - style: TextStyle(fontSize: 12, color: foreground), + // liveRegion, damit das Auftauchen des Offline-/Fehlerbanners angesagt wird; + // Row-Semantik ausgeschlossen (Icon + Text) und Text über das explizite + // Label getragen, sonst liest der Screenreader ihn doppelt. + return Semantics( + liveRegion: true, + container: true, + label: bloc.connectionText(lastUpdated: widget.lastUpdated), + child: ExcludeSemantics( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(bloc.connectionIcon(), size: 14, color: foreground), + const SizedBox(width: 10), + Text( + bloc.connectionText(lastUpdated: widget.lastUpdated), + style: TextStyle(fontSize: 12, color: foreground), + ), + ], ), - ], + ), ); } diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index ca7fd0c..0aa7433 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -248,6 +248,7 @@ class AppModule { mainAxisSize: MainAxisSize.min, children: [ IconButton( + tooltip: isVisible ? 'Modul ausblenden' : 'Modul einblenden', onPressed: onVisibleChange, icon: Icon( isVisible diff --git a/lib/view/login/widgets/login_branding.dart b/lib/view/login/widgets/login_branding.dart index 2b2c99f..d25167c 100644 --- a/lib/view/login/widgets/login_branding.dart +++ b/lib/view/login/widgets/login_branding.dart @@ -7,11 +7,14 @@ class LoginHeader extends StatelessWidget { Widget build(BuildContext context) => Column( children: [ const SizedBox(height: 40), - Image.asset( - 'assets/logo/icon.png', - height: 110, - fit: BoxFit.contain, - gaplessPlayback: true, + // Dekoratives Logo – der Schulname steht direkt darunter als Text. + const ExcludeSemantics( + child: Image( + image: AssetImage('assets/logo/icon.png'), + height: 110, + fit: BoxFit.contain, + gaplessPlayback: true, + ), ), const SizedBox(height: 20), const Text( diff --git a/lib/view/pages/files/search/files_search_delegate.dart b/lib/view/pages/files/search/files_search_delegate.dart index c909a7b..f7e6d84 100644 --- a/lib/view/pages/files/search/files_search_delegate.dart +++ b/lib/view/pages/files/search/files_search_delegate.dart @@ -33,6 +33,7 @@ class FilesSearchDelegate extends SearchDelegate { @override Widget? buildLeading(BuildContext context) => IconButton( icon: const Icon(Icons.arrow_back), + tooltip: 'Zurück', onPressed: () => close(context, null), ); diff --git a/lib/view/pages/files/sharing/share_options_sheet.dart b/lib/view/pages/files/sharing/share_options_sheet.dart index 2405c2c..49fbc9b 100644 --- a/lib/view/pages/files/sharing/share_options_sheet.dart +++ b/lib/view/pages/files/sharing/share_options_sheet.dart @@ -239,6 +239,7 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> { ), trailing: IconButton( onPressed: () => copyToClipboard(context, _share.url!), + tooltip: 'Link kopieren', icon: const Icon(Icons.copy_outlined), ), ), diff --git a/lib/view/pages/files/sharing/sharee_picker_page.dart b/lib/view/pages/files/sharing/sharee_picker_page.dart index 44a6a3a..e2d347d 100644 --- a/lib/view/pages/files/sharing/sharee_picker_page.dart +++ b/lib/view/pages/files/sharing/sharee_picker_page.dart @@ -127,6 +127,7 @@ class _ShareePickerPageState extends State { ? null : IconButton( icon: const Icon(Icons.clear), + tooltip: 'Leeren', onPressed: () { _searchController.clear(); _query = ''; diff --git a/lib/view/pages/foreign_timetable/element_picker_page.dart b/lib/view/pages/foreign_timetable/element_picker_page.dart index 7a942b7..61db705 100644 --- a/lib/view/pages/foreign_timetable/element_picker_page.dart +++ b/lib/view/pages/foreign_timetable/element_picker_page.dart @@ -166,6 +166,7 @@ class _ElementPickerPageState extends State { ? null : IconButton( icon: const Icon(Icons.clear), + tooltip: 'Leeren', onPressed: () { _searchController.clear(); setState(() => _query = ''); diff --git a/lib/view/pages/grade_averages/grade_averages_list_view.dart b/lib/view/pages/grade_averages/grade_averages_list_view.dart index d87625e..c6578ae 100644 --- a/lib/view/pages/grade_averages/grade_averages_list_view.dart +++ b/lib/view/pages/grade_averages/grade_averages_list_view.dart @@ -35,6 +35,7 @@ class GradeAveragesListView extends StatelessWidget { Text(getGradeDisplay(grade)), const SizedBox(width: 30), IconButton( + tooltip: 'Note entfernen', onPressed: () { bloc.add(DecrementGrade(grade)); }, @@ -49,6 +50,7 @@ class GradeAveragesListView extends StatelessWidget { ), ), IconButton( + tooltip: 'Note hinzufügen', onPressed: () { bloc.add(IncrementGrade(grade)); }, @@ -64,6 +66,7 @@ class GradeAveragesListView extends StatelessWidget { maintainSize: true, visible: bloc.canDecrementOrDelete(grade), child: IconButton( + tooltip: 'Löschen', icon: const Icon(Icons.delete), onPressed: () { bloc.add(ResetGrade(grade)); diff --git a/lib/view/pages/grade_averages/grade_averages_view.dart b/lib/view/pages/grade_averages/grade_averages_view.dart index 7670592..ca5a255 100644 --- a/lib/view/pages/grade_averages/grade_averages_view.dart +++ b/lib/view/pages/grade_averages/grade_averages_view.dart @@ -24,6 +24,7 @@ class GradeAveragesView extends StatelessWidget { Visibility( visible: bloc.state.grades.isNotEmpty, child: IconButton( + tooltip: 'Alle zurücksetzen', onPressed: () => ConfirmDialog( title: 'Zurücksetzen?', content: 'Alle Einträge werden entfernt.', diff --git a/lib/view/pages/holidays/holidays_view.dart b/lib/view/pages/holidays/holidays_view.dart index d3adae1..74f56b6 100644 --- a/lib/view/pages/holidays/holidays_view.dart +++ b/lib/view/pages/holidays/holidays_view.dart @@ -38,6 +38,7 @@ class HolidaysView extends StatelessWidget { title: const Text('Schulferien'), actions: [ IconButton( + tooltip: 'Informationen', icon: const Icon(Icons.info_outline), onPressed: showDisclaimer, ), diff --git a/lib/view/pages/marianum_dates/marianum_dates_view.dart b/lib/view/pages/marianum_dates/marianum_dates_view.dart index 98d464c..13434c6 100644 --- a/lib/view/pages/marianum_dates/marianum_dates_view.dart +++ b/lib/view/pages/marianum_dates/marianum_dates_view.dart @@ -68,6 +68,7 @@ class MarianumDatesView extends StatelessWidget { onSelected: (e) => bloc.add(SetPastEventsVisible(e)), ), IconButton( + tooltip: 'Suchen', icon: const Icon(Icons.search), onPressed: () { final events = bloc.getEvents() ?? const []; diff --git a/lib/view/pages/marianum_dates/search_marianum_dates.dart b/lib/view/pages/marianum_dates/search_marianum_dates.dart index 19745a4..b528b50 100644 --- a/lib/view/pages/marianum_dates/search_marianum_dates.dart +++ b/lib/view/pages/marianum_dates/search_marianum_dates.dart @@ -22,11 +22,16 @@ class SearchMarianumDates extends SearchDelegate { @override List? buildActions(BuildContext context) => [ if (query.isNotEmpty) - IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)), + IconButton( + tooltip: 'Leeren', + onPressed: () => query = '', + icon: const Icon(Icons.clear), + ), ]; @override Widget? buildLeading(BuildContext context) => IconButton( + tooltip: 'Zurück', icon: const Icon(Icons.arrow_back), onPressed: () => close(context, null), ); diff --git a/lib/view/pages/marianum_message/marianum_message_list_view.dart b/lib/view/pages/marianum_message/marianum_message_list_view.dart index 58abb6e..0a86ec0 100644 --- a/lib/view/pages/marianum_message/marianum_message_list_view.dart +++ b/lib/view/pages/marianum_message/marianum_message_list_view.dart @@ -22,6 +22,7 @@ class MarianumMessageListView extends StatelessWidget { title: const Text('Marianum Message'), actions: [ IconButton( + tooltip: 'Suchen', icon: const Icon(Icons.search), onPressed: () { final list = bloc.state.data?.messageList; diff --git a/lib/view/pages/marianum_message/search_marianum_messages.dart b/lib/view/pages/marianum_message/search_marianum_messages.dart index c66d4ff..6a1b791 100644 --- a/lib/view/pages/marianum_message/search_marianum_messages.dart +++ b/lib/view/pages/marianum_message/search_marianum_messages.dart @@ -23,11 +23,16 @@ class SearchMarianumMessages extends SearchDelegate { @override List? buildActions(BuildContext context) => [ if (query.isNotEmpty) - IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)), + IconButton( + tooltip: 'Leeren', + onPressed: () => query = '', + icon: const Icon(Icons.clear), + ), ]; @override Widget? buildLeading(BuildContext context) => IconButton( + tooltip: 'Zurück', icon: const Icon(Icons.arrow_back), onPressed: () => close(context, null), ); diff --git a/lib/view/pages/more/roomplan/roomplan.dart b/lib/view/pages/more/roomplan/roomplan.dart index 63f3d3e..9b7faf9 100644 --- a/lib/view/pages/more/roomplan/roomplan.dart +++ b/lib/view/pages/more/roomplan/roomplan.dart @@ -1,18 +1,24 @@ import 'package:flutter/material.dart'; import 'package:photo_view/photo_view.dart'; +import '../../../../widget/a11y/a11y_labels.dart'; + class Roomplan extends StatelessWidget { const Roomplan({super.key}); @override Widget build(BuildContext context) => Scaffold( appBar: AppBar(title: const Text('Raumplan')), - body: PhotoView( - imageProvider: Image.asset('assets/img/raumplan.png').image, - minScale: 0.5, - maxScale: 2.0, - backgroundDecoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, + body: Semantics( + image: true, + label: A11yLabels.roomPlan, + child: PhotoView( + imageProvider: Image.asset('assets/img/raumplan.png').image, + minScale: 0.5, + maxScale: 2.0, + backgroundDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + ), ), ), ); diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index 5966ca4..349578f 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -23,6 +23,7 @@ class _OverhangState extends State { title: const Text('Mehr'), actions: [ IconButton( + tooltip: 'Einstellungen', onPressed: () => AppRoutes.openSettings(context), icon: const Icon(Icons.settings), ), diff --git a/lib/view/pages/settings/modules_settings_page.dart b/lib/view/pages/settings/modules_settings_page.dart index 17e2f1f..28081ca 100644 --- a/lib/view/pages/settings/modules_settings_page.dart +++ b/lib/view/pages/settings/modules_settings_page.dart @@ -66,6 +66,7 @@ class ModuleSortBody extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ IconButton( + tooltip: 'Slot entfernen', icon: const Icon(Icons.remove_circle_outline), onPressed: modulesSettings.fixedBottomBarSlots > @@ -80,6 +81,7 @@ class ModuleSortBody extends StatelessWidget { ), Text('${modulesSettings.fixedBottomBarSlots}'), IconButton( + tooltip: 'Slot hinzufügen', icon: const Icon(Icons.add_circle_outline), onPressed: modulesSettings.fixedBottomBarSlots < diff --git a/lib/view/pages/settings/widgets/push_status_sheet.dart b/lib/view/pages/settings/widgets/push_status_sheet.dart index 4fa6402..6b1fc73 100644 --- a/lib/view/pages/settings/widgets/push_status_sheet.dart +++ b/lib/view/pages/settings/widgets/push_status_sheet.dart @@ -277,13 +277,22 @@ class _PushStatusBodyState extends State<_PushStatusBody> Widget _stateIcon(PushCheck state, ThemeData theme) { switch (state) { case PushCheck.ok: - return const Icon(Icons.check_circle_outline, color: Colors.green); + return const Icon( + Icons.check_circle_outline, + color: Colors.green, + semanticLabel: 'In Ordnung', + ); case PushCheck.fail: - return Icon(Icons.cancel_outlined, color: theme.colorScheme.error); + return Icon( + Icons.cancel_outlined, + color: theme.colorScheme.error, + semanticLabel: 'Fehler', + ); case PushCheck.unknown: return Icon( Icons.remove_circle_outline, color: theme.colorScheme.onSurfaceVariant, + semanticLabel: 'Unbekannt', ); } } diff --git a/lib/view/pages/share_intent/share_chat_picker.dart b/lib/view/pages/share_intent/share_chat_picker.dart index a5692ce..f1199fb 100644 --- a/lib/view/pages/share_intent/share_chat_picker.dart +++ b/lib/view/pages/share_intent/share_chat_picker.dart @@ -76,6 +76,7 @@ class ShareChatPicker extends StatelessWidget { actions: [ Builder( builder: (ctx) => IconButton( + tooltip: 'Suchen', icon: const Icon(Icons.search), onPressed: () { final rooms = ctx.read().state.data?.rooms; diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 88f7f63..18051e4 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -85,6 +85,7 @@ class _ChatListViewState extends State<_ChatListView> { title: const Text('Talk'), actions: [ IconButton( + tooltip: 'Suchen', icon: const Icon(Icons.search), onPressed: () { final rooms = bloc.state.data?.rooms; diff --git a/lib/view/pages/talk/join_chat.dart b/lib/view/pages/talk/join_chat.dart index def6e98..f8afef9 100644 --- a/lib/view/pages/talk/join_chat.dart +++ b/lib/view/pages/talk/join_chat.dart @@ -27,7 +27,11 @@ class JoinChat extends SearchDelegate { }, ), if (query.isNotEmpty) - IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)), + IconButton( + tooltip: 'Leeren', + onPressed: () => query = '', + icon: const Icon(Icons.clear), + ), ]; @override diff --git a/lib/view/pages/talk/search_chat.dart b/lib/view/pages/talk/search_chat.dart index af76f38..919e0a7 100644 --- a/lib/view/pages/talk/search_chat.dart +++ b/lib/view/pages/talk/search_chat.dart @@ -25,7 +25,11 @@ class SearchChat extends SearchDelegate { @override List? buildActions(BuildContext context) => [ if (query.isNotEmpty) - IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)), + IconButton( + tooltip: 'Leeren', + onPressed: () => query = '', + icon: const Icon(Icons.clear), + ), ]; @override diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index 55ffed2..5ff2114 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -9,6 +9,7 @@ import '../../../../share_intent/remote_file_ref.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../utils/downloads/download_job.dart'; import '../../../../utils/haptics.dart'; +import '../../../../widget/a11y/a11y_labels.dart'; import '../../../../widget/demo_restricted.dart'; import '../../../../widget/downloads/download_trigger.dart'; import '../data/chat_bubble_styles.dart'; @@ -120,9 +121,7 @@ class _ChatBubbleState extends State if (!_rendersAsCommentBubble) { base = styles.getSystemStyle(); } else { - base = widget.isSender - ? styles.getSelfStyle() - : styles.getRemoteStyle(); + base = widget.isSender ? styles.getSelfStyle() : styles.getRemoteStyle(); } switch (widget.matchHighlight) { case SearchHighlight.none: @@ -130,7 +129,9 @@ class _ChatBubbleState extends State case SearchHighlight.secondary: return base.copyWith( borderWidth: 1.5, - borderColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.45), + borderColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.45), ); case SearchHighlight.active: return base.copyWith( @@ -342,68 +343,72 @@ class _BubbleContent extends StatelessWidget { }); @override - Widget build(BuildContext context) => Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.9, - minWidth: showActorDisplayName - ? actorText.size.width - : timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3, - ), - child: Stack( - children: [ - if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorWidget), - Padding( - padding: EdgeInsets.only( - bottom: showBubbleTime ? 18 : 0, - top: showActorDisplayName ? 18 : 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (parent != null && - bubbleData.messageType == - GetRoomResponseObjectMessageType.comment) ...[ - AnswerReference( - referenceMessage: parent!, - selfId: selfId, - ), - const SizedBox(height: 5), - ], - messageWidget, - ], - ), - ), - if (showBubbleTime) - Positioned( - bottom: 0, - right: 0, - child: Row( + Widget build(BuildContext context) => MergeSemantics( + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.9, + minWidth: showActorDisplayName + ? actorText.size.width + : timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3, + ), + child: Stack( + children: [ + if (showActorDisplayName) + Positioned(top: 0, left: 0, child: actorWidget), + Padding( + padding: EdgeInsets.only( + bottom: showBubbleTime ? 18 : 0, + top: showActorDisplayName ? 18 : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - timeText, - if (isSender) ...[ - SizedBox(width: spacing), - Icon( - isRead ? Icons.done_all_outlined : Icons.done_outlined, - size: timeIconSize, - color: timeIconColor, - ), + if (parent != null && + bubbleData.messageType == + GetRoomResponseObjectMessageType.comment) ...[ + AnswerReference(referenceMessage: parent!, selfId: selfId), + const SizedBox(height: 5), ], + messageWidget, ], ), ), - if (downloadJob?.status.value is DownloadInProgress) - Positioned( - bottom: 0, - right: 0, - left: 0, - child: LinearProgressIndicator( - value: () { - final s = downloadJob!.status.value as DownloadInProgress; - return s.percent <= 0 ? null : s.percent / 100; - }(), + if (showBubbleTime) + Positioned( + bottom: 0, + right: 0, + child: Row( + children: [ + timeText, + if (isSender) ...[ + SizedBox(width: spacing), + Icon( + isRead ? Icons.done_all_outlined : Icons.done_outlined, + size: timeIconSize, + color: timeIconColor, + semanticLabel: isRead + ? A11yLabels.messageRead + : A11yLabels.messageSent, + ), + ], + ], + ), ), - ), - ], + if (downloadJob?.status.value is DownloadInProgress) + Positioned( + bottom: 0, + right: 0, + left: 0, + child: LinearProgressIndicator( + semanticsLabel: A11yLabels.downloadingFile, + value: () { + final s = downloadJob!.status.value as DownloadInProgress; + return s.percent <= 0 ? null : s.percent / 100; + }(), + ), + ), + ], + ), ), ); } diff --git a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart index 05e6a57..e7bd55c 100644 --- a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart +++ b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart @@ -6,6 +6,7 @@ import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_mes import '../../../../api/marianumcloud/talk/react_message/react_message.dart'; import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../widget/a11y/a11y_labels.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/demo_restricted.dart'; import '../../../../widget/emoji_text.dart'; @@ -41,44 +42,58 @@ class ChatBubbleReactions extends StatelessWidget { children: reactions.entries.map((e) { final hasSelfReacted = bubbleData.reactionsSelf?.contains(e.key) ?? false; + void toggle() { + if (guardDemoAction(context)) return; + runWithErrorDialog(context, () async { + if (hasSelfReacted) { + await DeleteReactMessage( + chatToken: chatData.token, + messageId: bubbleData.id, + params: DeleteReactMessageParams(e.key), + ).run(); + } else { + await ReactMessage( + chatToken: chatData.token, + messageId: bubbleData.id, + params: ReactMessageParams(e.key), + ).run(); + } + onChanged(renew: true); + }); + } + + // Ownership (eigene Reaktion) wird visuell nur über die + // Hintergrundfarbe transportiert – für den Screenreader ins Label. + final label = hasSelfReacted + ? '${e.key} ${e.value}, ${A11yLabels.yourReaction}' + : '${e.key} ${e.value}'; return Container( margin: const EdgeInsets.only(right: 2.5, left: 2.5), - child: ActionChip( - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - EmojiText(e.key, size: EmojiText.sizeInline), - const SizedBox(width: 4), - Text('${e.value}'), - ], + child: Semantics( + button: true, + label: label, + onTap: toggle, + child: ExcludeSemantics( + child: ActionChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + EmojiText(e.key, size: EmojiText.sizeInline), + const SizedBox(width: 4), + Text('${e.value}'), + ], + ), + visualDensity: const VisualDensity( + vertical: VisualDensity.minimumDensity, + horizontal: VisualDensity.minimumDensity, + ), + padding: EdgeInsets.zero, + backgroundColor: hasSelfReacted + ? Theme.of(context).primaryColor + : null, + onPressed: toggle, + ), ), - visualDensity: const VisualDensity( - vertical: VisualDensity.minimumDensity, - horizontal: VisualDensity.minimumDensity, - ), - padding: EdgeInsets.zero, - backgroundColor: hasSelfReacted - ? Theme.of(context).primaryColor - : null, - onPressed: () { - if (guardDemoAction(context)) return; - runWithErrorDialog(context, () async { - if (hasSelfReacted) { - await DeleteReactMessage( - chatToken: chatData.token, - messageId: bubbleData.id, - params: DeleteReactMessageParams(e.key), - ).run(); - } else { - await ReactMessage( - chatToken: chatData.token, - messageId: bubbleData.id, - params: ReactMessageParams(e.key), - ).run(); - } - onChanged(renew: true); - }); - }, ), ); }).toList(), diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart index 0999dfd..9f6244d 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -259,6 +259,7 @@ class _ReactionsRowState extends State<_ReactionsRow> { ], _groupDivider(context), IconButton( + tooltip: 'Reaktion hinzufügen', onPressed: busy ? null : () => _showEmojiPicker(context), style: IconButton.styleFrom( padding: EdgeInsets.zero, diff --git a/lib/view/pages/talk/widgets/chat_search_app_bar.dart b/lib/view/pages/talk/widgets/chat_search_app_bar.dart index 0b1c9f7..02ffff8 100644 --- a/lib/view/pages/talk/widgets/chat_search_app_bar.dart +++ b/lib/view/pages/talk/widgets/chat_search_app_bar.dart @@ -30,6 +30,7 @@ class ChatSearchAppBar extends StatelessWidget implements PreferredSizeWidget { : '${activeIndex + 1}/$matchCount'; return AppBar( leading: IconButton( + tooltip: 'Zurück', icon: const Icon(Icons.arrow_back), onPressed: onClose, ), @@ -54,10 +55,12 @@ class ChatSearchAppBar extends StatelessWidget implements PreferredSizeWidget { ), ), IconButton( + tooltip: 'Vorheriges Ergebnis', icon: const Icon(Icons.keyboard_arrow_up), onPressed: onPrevious, ), IconButton( + tooltip: 'Nächstes Ergebnis', icon: const Icon(Icons.keyboard_arrow_down), onPressed: onNext, ), diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 815e8eb..ee1cacc 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -268,6 +268,7 @@ class _ChatTextfieldState extends State { ), ), IconButton( + tooltip: 'Antwort verwerfen', onPressed: () { chatBloc.setReferenceMessageId(null); _setDraftReply(null); diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index 20dd12a..d46aa49 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -14,6 +14,7 @@ import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../utils/haptics.dart'; +import '../../../../widget/a11y/a11y_labels.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/debug/debug_tile.dart'; @@ -106,6 +107,7 @@ class _ChatTileState extends State { Icons.star, color: Colors.amberAccent, size: 15, + semanticLabel: A11yLabels.favorite, ), ), ), @@ -123,7 +125,11 @@ class _ChatTileState extends State { ), if (widget.hasDraft) ...[ const SizedBox(width: 5), - const Icon(Icons.edit_outlined, size: 15), + const Icon( + Icons.edit_outlined, + size: 15, + semanticLabel: A11yLabels.draft, + ), ], ], ), @@ -134,17 +140,25 @@ class _ChatTileState extends State { ), trailing: widget.data.unreadMessages <= 0 ? null - : Container( - padding: const EdgeInsets.all(1), - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.circular(30), - ), - constraints: const BoxConstraints(minWidth: 20, minHeight: 20), - child: Text( - '${widget.data.unreadMessages}', - style: const TextStyle(color: Colors.white, fontSize: 15), - textAlign: TextAlign.center, + : Semantics( + label: '${widget.data.unreadMessages} ${A11yLabels.unread}', + child: ExcludeSemantics( + child: Container( + padding: const EdgeInsets.all(1), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(30), + ), + constraints: const BoxConstraints( + minWidth: 20, + minHeight: 20, + ), + child: Text( + '${widget.data.unreadMessages}', + style: const TextStyle(color: Colors.white, fontSize: 15), + textAlign: TextAlign.center, + ), + ), ), ), onTap: () { diff --git a/lib/view/pages/talk/widgets/split_view_placeholder.dart b/lib/view/pages/talk/widgets/split_view_placeholder.dart index d5b12e5..8a0ec1b 100644 --- a/lib/view/pages/talk/widgets/split_view_placeholder.dart +++ b/lib/view/pages/talk/widgets/split_view_placeholder.dart @@ -16,7 +16,10 @@ class SplitViewPlaceholder extends StatelessWidget { data: MediaQuery.of( context, ).copyWith(invertColors: !AppTheme.isDarkMode(context)), - child: Image.asset('assets/logo/icon.png', height: 200), + // Dekoratives Logo – der „Talk"-Text darunter trägt die Aussage. + child: ExcludeSemantics( + child: Image.asset('assets/logo/icon.png', height: 200), + ), ), const SizedBox(height: 30), const Text( diff --git a/lib/view/pages/timetable/custom_events/custom_events_view.dart b/lib/view/pages/timetable/custom_events/custom_events_view.dart index 24263f7..4096995 100644 --- a/lib/view/pages/timetable/custom_events/custom_events_view.dart +++ b/lib/view/pages/timetable/custom_events/custom_events_view.dart @@ -27,6 +27,7 @@ class CustomEventsView extends StatelessWidget { actions: [ IconButton( icon: const Icon(Icons.add), + tooltip: 'Termin erstellen', onPressed: () => _openCreateDialog(context), ), ], @@ -67,6 +68,7 @@ class CustomEventsView extends StatelessWidget { children: [ IconButton( icon: const Icon(Icons.edit_outlined), + tooltip: 'Bearbeiten', onPressed: () => showDialog( context: context, builder: (_) => @@ -75,6 +77,7 @@ class CustomEventsView extends StatelessWidget { ), IconButton( icon: const Icon(Icons.delete_outline), + tooltip: 'Löschen', onPressed: () => showDeleteCustomEventDialog(context, e), ), diff --git a/lib/view/pages/timetable/details/lesson_sheet.dart b/lib/view/pages/timetable/details/lesson_sheet.dart index 3ea8504..c528a17 100644 --- a/lib/view/pages/timetable/details/lesson_sheet.dart +++ b/lib/view/pages/timetable/details/lesson_sheet.dart @@ -103,6 +103,7 @@ class LessonSheet { static Widget _roomTile(BuildContext context, McTimetableEntry lesson) { final trailing = IconButton( icon: const Icon(Icons.house_outlined), + tooltip: 'Raumplan öffnen', onPressed: () => AppRoutes.openRoomplan(context), ); diff --git a/lib/view/pages/timetable/subject_colors/search_subject_colors.dart b/lib/view/pages/timetable/subject_colors/search_subject_colors.dart index 0b94608..f68cf5d 100644 --- a/lib/view/pages/timetable/subject_colors/search_subject_colors.dart +++ b/lib/view/pages/timetable/subject_colors/search_subject_colors.dart @@ -14,12 +14,17 @@ class SearchSubjectColors extends SearchDelegate { @override List? buildActions(BuildContext context) => [ if (query.isNotEmpty) - IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)), + IconButton( + onPressed: () => query = '', + tooltip: 'Leeren', + icon: const Icon(Icons.clear), + ), ]; @override Widget? buildLeading(BuildContext context) => IconButton( icon: const Icon(Icons.arrow_back), + tooltip: 'Zurück', onPressed: () => close(context, null), ); diff --git a/lib/view/pages/timetable/subject_colors/subject_colors_view.dart b/lib/view/pages/timetable/subject_colors/subject_colors_view.dart index 603381a..4eafdb0 100644 --- a/lib/view/pages/timetable/subject_colors/subject_colors_view.dart +++ b/lib/view/pages/timetable/subject_colors/subject_colors_view.dart @@ -21,6 +21,7 @@ class SubjectColorsView extends StatelessWidget { actions: [ IconButton( icon: const Icon(Icons.search), + tooltip: 'Suchen', onPressed: () => showSearch(context: context, delegate: SearchSubjectColors()), ), diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 226f2cf..7709939 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -110,6 +110,7 @@ class _TimetableState extends State { actions: [ IconButton( icon: const Icon(Icons.home_outlined), + tooltip: 'Zur aktuellen Woche', onPressed: atToday ? null : _jumpToToday, ), PopupMenuButton<_CalendarAction>( @@ -179,6 +180,7 @@ class _TimetableState extends State { actions: [ IconButton( icon: const Icon(Icons.home_outlined), + tooltip: 'Zur aktuellen Woche', onPressed: atToday ? null : _jumpToToday, ), if (canViewForeign) diff --git a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart index a78cb59..35ba38a 100644 --- a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart +++ b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart @@ -88,7 +88,7 @@ class _OutsideDayColumn extends StatelessWidget { } static String _subtitleFor(Appointment a) { - if (isAllDayLike(a)) return 'Ganztägig'; + if (isAllDayLike(a)) return A11yLabels.allDay; return '${a.startTime.formatHm()}–${a.endTime.formatHm()}'; } @@ -122,6 +122,7 @@ class _OutsideDayColumn extends StatelessWidget { height: kOutsideChipHeight, child: _OutsideChip( appointment: visible[i], + crossedOut: isCrossedOut(visible[i]), onTap: () => onAppointmentTap(visible[i]), ), ), @@ -144,15 +145,28 @@ class _OutsideDayColumn extends StatelessWidget { class _OutsideChip extends StatelessWidget { final Appointment appointment; + final bool crossedOut; final VoidCallback onTap; - const _OutsideChip({required this.appointment, required this.onTap}); + const _OutsideChip({ + required this.appointment, + required this.crossedOut, + required this.onTap, + }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final allDay = isAllDayLike(appointment); final timeLabel = allDay ? null : appointment.startTime.formatHm(); + final semanticsLabel = [ + appointment.subject, + if (allDay) + A11yLabels.allDay + else + '${appointment.startTime.formatHm()}–${appointment.endTime.formatHm()}', + if (crossedOut) A11yLabels.cancelled, + ].join(', '); // Past chips fade further, future/ongoing ones get a more saturated tint // so the strip no longer reads as one uniform grey block. @@ -163,47 +177,54 @@ class _OutsideChip extends StatelessWidget { : theme.colorScheme.onSurface; final subjectWeight = isPast ? FontWeight.w400 : FontWeight.w600; - return Material( - color: appointment.color.withAlpha(backgroundAlpha), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(7)), - ), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - child: Row( - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: Text( - appointment.subject, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: theme.textTheme.labelSmall?.copyWith( - color: subjectColor, - fontWeight: subjectWeight, - ), - ), - ), - if (timeLabel != null) ...[ - const SizedBox(width: 4), - Flexible( - child: Text( - timeLabel, - maxLines: 1, - overflow: TextOverflow.fade, - softWrap: false, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 10, + return Semantics( + button: true, + label: semanticsLabel, + onTap: onTap, + child: ExcludeSemantics( + child: Material( + color: appointment.color.withAlpha(backgroundAlpha), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(7)), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Text( + appointment.subject, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: theme.textTheme.labelSmall?.copyWith( + color: subjectColor, + fontWeight: subjectWeight, + ), ), ), - ), - ], - ], + if (timeLabel != null) ...[ + const SizedBox(width: 4), + Flexible( + child: Text( + timeLabel, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 10, + ), + ), + ), + ], + ], + ), + ), ), ), ), @@ -220,18 +241,25 @@ class _OutsideOverflowChip extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Material( - color: theme.colorScheme.secondaryContainer, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Center( - child: Text( - '+$count weitere', - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSecondaryContainer, - fontWeight: FontWeight.w600, + return Semantics( + button: true, + label: A11yLabels.moreAppointments(count), + onTap: onTap, + child: ExcludeSemantics( + child: Material( + color: theme.colorScheme.secondaryContainer, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Center( + child: Text( + '+$count weitere', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + ), ), ), ), diff --git a/lib/view/pages/timetable/widgets/calendar/week_grid.dart b/lib/view/pages/timetable/widgets/calendar/week_grid.dart index 6275dd9..f830f04 100644 --- a/lib/view/pages/timetable/widgets/calendar/week_grid.dart +++ b/lib/view/pages/timetable/widgets/calendar/week_grid.dart @@ -112,6 +112,7 @@ class _PeriodLabel extends StatelessWidget { Icons.coffee_outlined, size: 12, color: secondaryTextColor.withAlpha(180), + semanticLabel: A11yLabels.breakTime, ), ); } @@ -328,18 +329,39 @@ class _DayColumn extends StatelessWidget { left: cell.lane * width / cell.laneCount, width: width / cell.laneCount, child: switch (cell) { - LaidOutAppointment(:final appointment) => GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onAppointmentTap(appointment), - child: AppointmentTile( - appointment: appointment, + 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) => GestureDetector( - behavior: HitTestBehavior.opaque, + LaidOutOverflow(:final appointments) => Semantics( + button: true, + label: A11yLabels.moreAppointments(appointments.length), onTap: () => _showOverflowSheet(context, appointments), - child: _OverflowTile(count: appointments.length), + child: ExcludeSemantics( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => + _showOverflowSheet(context, appointments), + child: _OverflowTile(count: appointments.length), + ), + ), ), }, ), diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index bb8a6be..1e7faa5 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -14,6 +14,7 @@ import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../extensions/date_time.dart'; import '../../../../utils/haptics.dart'; +import '../../../../widget/a11y/a11y_labels.dart'; import '../../../../widget/details_bottom_sheet.dart'; import '../data/calendar_layout.dart'; import '../data/calendar_logic.dart'; diff --git a/lib/widget/a11y/a11y_labels.dart b/lib/widget/a11y/a11y_labels.dart new file mode 100644 index 0000000..3754922 --- /dev/null +++ b/lib/widget/a11y/a11y_labels.dart @@ -0,0 +1,49 @@ +import '../../extensions/date_time.dart'; + +/// Zentrale deutschsprachige Screenreader-Labels. Single Source of Truth für +/// alle `Semantics`/`semanticLabel`/`tooltip`-Texte – und der eine Punkt, an +/// dem später echtes l10n andockt (die App ist aktuell fest auf `de`). +abstract final class A11yLabels { + // Zustände + static const loading = 'Lädt'; + static const downloadingFile = 'Lädt herunter'; + + // Chat + static const messageRead = 'Gelesen'; + static const messageSent = 'Gesendet'; + static const unread = 'Ungelesen'; + static const favorite = 'Favorit'; + static const draft = 'Entwurf'; + static const yourReaction = 'deine Reaktion'; + + // Bilder + static const profilePicture = 'Profilbild'; + static const groupPicture = 'Gruppenbild'; + static const roomPlan = 'Raumplan der Schule als Grafik'; + + // Stundenplan + static const cancelled = 'Ausfall'; + static const breakTime = 'Pause'; + static const allDay = 'Ganztägig'; + + /// Beschreibt eine Stundenplan-Kachel für den Screenreader, z.B. + /// „Mathe, Raum 101, 08:00–08:45, Ausfall". Zeilenumbrüche in [location] + /// (Raum/Lehrer stehen dort mehrzeilig) werden zu Trennern geglättet. + static String appointmentLabel({ + required String subject, + required String location, + required DateTime start, + required DateTime end, + required bool crossedOut, + }) { + final parts = [subject]; + final loc = location.replaceAll('\n', ', ').trim(); + if (loc.isNotEmpty) parts.add(loc); + parts.add('${start.formatHm()}–${end.formatHm()}'); + if (crossedOut) parts.add(cancelled); + return parts.join(', '); + } + + /// „+3 weitere Termine" für die zusammengefasste Overflow-Kachel. + static String moreAppointments(int count) => '+$count weitere Termine'; +} diff --git a/lib/widget/app_progress_indicator.dart b/lib/widget/app_progress_indicator.dart index c383687..e38883d 100644 --- a/lib/widget/app_progress_indicator.dart +++ b/lib/widget/app_progress_indicator.dart @@ -1,24 +1,43 @@ import 'package:flutter/material.dart'; +import 'a11y/a11y_labels.dart'; + class AppProgressIndicator extends StatelessWidget { final double size; final double strokeWidth; final Color? color; + final String semanticsLabel; const AppProgressIndicator._({ required this.size, required this.strokeWidth, this.color, + this.semanticsLabel = A11yLabels.loading, }); - const AppProgressIndicator.small({Color? color}) - : this._(size: 16, strokeWidth: 2, color: color); + const AppProgressIndicator.small({Color? color, String? semanticsLabel}) + : this._( + size: 16, + strokeWidth: 2, + color: color, + semanticsLabel: semanticsLabel ?? A11yLabels.loading, + ); - const AppProgressIndicator.medium({Color? color}) - : this._(size: 24, strokeWidth: 2.5, color: color); + const AppProgressIndicator.medium({Color? color, String? semanticsLabel}) + : this._( + size: 24, + strokeWidth: 2.5, + color: color, + semanticsLabel: semanticsLabel ?? A11yLabels.loading, + ); - const AppProgressIndicator.large({Color? color}) - : this._(size: 40, strokeWidth: 3, color: color); + const AppProgressIndicator.large({Color? color, String? semanticsLabel}) + : this._( + size: 40, + strokeWidth: 3, + color: color, + semanticsLabel: semanticsLabel ?? A11yLabels.loading, + ); @override Widget build(BuildContext context) { @@ -29,6 +48,7 @@ class AppProgressIndicator extends StatelessWidget { child: CircularProgressIndicator( strokeWidth: strokeWidth, valueColor: AlwaysStoppedAnimation(resolved), + semanticsLabel: semanticsLabel, ), ); } diff --git a/lib/widget/async_actions/async_mixin.dart b/lib/widget/async_actions/async_mixin.dart index 13763fe..2bb4da1 100644 --- a/lib/widget/async_actions/async_mixin.dart +++ b/lib/widget/async_actions/async_mixin.dart @@ -105,10 +105,14 @@ class _InlineErrorWrapper extends StatelessWidget { child, if (err != null) ...[ const SizedBox(height: 8), - Text( - err, - textAlign: TextAlign.center, - style: _asyncErrorTextStyle(context), + Semantics( + liveRegion: true, + container: true, + child: Text( + err, + textAlign: TextAlign.center, + style: _asyncErrorTextStyle(context), + ), ), ], ], diff --git a/lib/widget/emoji_picker_dialog.dart b/lib/widget/emoji_picker_dialog.dart index 73456df..73021fc 100644 --- a/lib/widget/emoji_picker_dialog.dart +++ b/lib/widget/emoji_picker_dialog.dart @@ -20,6 +20,7 @@ Future showEmojiPicker( title: Row( children: [ IconButton( + tooltip: 'Zurück', onPressed: () => Navigator.of(pickerCtx).pop(), icon: const Icon(Icons.arrow_back), ), diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index fcbdfcc..d0ca27f 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -249,6 +249,7 @@ class _FileViewerState extends State { photoViewController.rotation += pi / 2; }); }, + tooltip: 'Drehen', icon: const Icon(Icons.rotate_right), ), ], diff --git a/lib/widget/large_profile_picture_view.dart b/lib/widget/large_profile_picture_view.dart index 75194ea..40e9be1 100644 --- a/lib/widget/large_profile_picture_view.dart +++ b/lib/widget/large_profile_picture_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:photo_view/photo_view.dart'; import '../model/account_data.dart'; +import 'a11y/a11y_labels.dart'; import 'user_avatar.dart'; class LargeProfilePictureView extends StatelessWidget { @@ -15,18 +16,25 @@ class LargeProfilePictureView extends StatelessWidget { }); @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: Text(isGroup ? 'Gruppenbild' : 'Profilbild')), - body: PhotoView( - minScale: 0.5, - maxScale: 3.0, - imageProvider: Image.network( - avatarUrl(id: id, isGroup: isGroup, size: 1024), - headers: {'Authorization': AccountData().getBasicAuthHeader()}, - ).image, - backgroundDecoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, + Widget build(BuildContext context) { + final label = isGroup ? A11yLabels.groupPicture : A11yLabels.profilePicture; + return Scaffold( + appBar: AppBar(title: Text(label)), + body: Semantics( + image: true, + label: label, + child: PhotoView( + minScale: 0.5, + maxScale: 3.0, + imageProvider: Image.network( + avatarUrl(id: id, isGroup: isGroup, size: 1024), + headers: {'Authorization': AccountData().getBasicAuthHeader()}, + ).image, + backgroundDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + ), + ), ), - ), - ); + ); + } } diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index 696da73..fdaf017 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -10,6 +10,7 @@ import 'package:http/http.dart' as http; import '../model/account_data.dart'; import '../model/endpoint_data.dart'; import '../push/push_avatar.dart'; +import 'a11y/a11y_labels.dart'; import 'avatar_disk_cache.dart'; class UserAvatar extends StatefulWidget { @@ -17,6 +18,10 @@ class UserAvatar extends StatefulWidget { final bool isGroup; final int size; + /// Screenreader-Beschreibung. Aufrufer, die den Namen kennen, sollten ihn + /// mitgeben; sonst greift ein generisches „Profilbild"/„Gruppenbild". + final String? semanticLabel; + /// Server-side pixel size requested for user avatars. `null` lets the /// widget pick `(size * 4).clamp(64, 1024)` — enough headroom for typical /// device pixel ratios. Group avatars ignore this (Spreed serves one @@ -28,6 +33,7 @@ class UserAvatar extends StatefulWidget { this.isGroup = false, this.size = 20, this.requestSize, + this.semanticLabel, super.key, }); @@ -225,7 +231,9 @@ class _UserAvatarState extends State { final pending = _pendingAvatars.putIfAbsent(url, () { final future = _fetch(url); future.whenComplete(() { - if (identical(_pendingAvatars[url], future)) _pendingAvatars.remove(url); + if (identical(_pendingAvatars[url], future)) { + _pendingAvatars.remove(url); + } }); return future; }); @@ -345,12 +353,24 @@ class _UserAvatarState extends State { ); } - return CircleAvatar( - radius: radius, - backgroundColor: theme.primaryColor, - foregroundColor: Colors.white, - child: ClipOval( - child: SizedBox(width: radius * 2, height: radius * 2, child: content), + return Semantics( + image: true, + label: + widget.semanticLabel ?? + (widget.isGroup + ? A11yLabels.groupPicture + : A11yLabels.profilePicture), + child: CircleAvatar( + radius: radius, + backgroundColor: theme.primaryColor, + foregroundColor: Colors.white, + child: ClipOval( + child: SizedBox( + width: radius * 2, + height: radius * 2, + child: content, + ), + ), ), ); } diff --git a/test/widget/a11y_labels_test.dart b/test/widget/a11y_labels_test.dart new file mode 100644 index 0000000..956f9c0 --- /dev/null +++ b/test/widget/a11y_labels_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:marianum_mobile/widget/a11y/a11y_labels.dart'; + +void main() { + setUpAll(() async { + // appointmentLabel formatiert über formatHm() (Jiffy) – Locale einmal laden. + await Jiffy.setLocale('de'); + }); + + group('A11yLabels.appointmentLabel', () { + test('setzt Fach, Ort und Zeitspanne zusammen', () { + final label = A11yLabels.appointmentLabel( + subject: 'Mathe', + location: 'Raum 101', + start: DateTime(2026, 5, 8, 8, 0), + end: DateTime(2026, 5, 8, 8, 45), + crossedOut: false, + ); + expect(label, 'Mathe, Raum 101, 08:00–08:45'); + }); + + test('hängt bei Ausfall den Ausfall-Hinweis an', () { + final label = A11yLabels.appointmentLabel( + subject: 'Deutsch', + location: 'Raum 5', + start: DateTime(2026, 5, 8, 9, 0), + end: DateTime(2026, 5, 8, 9, 45), + crossedOut: true, + ); + expect(label, endsWith(A11yLabels.cancelled)); + expect(label, 'Deutsch, Raum 5, 09:00–09:45, Ausfall'); + }); + + test('glättet Zeilenumbrüche im Ort zu Trennern', () { + final label = A11yLabels.appointmentLabel( + subject: 'Sport', + location: 'Halle\nHr. Müller', + start: DateTime(2026, 5, 8, 10, 0), + end: DateTime(2026, 5, 8, 10, 45), + crossedOut: false, + ); + expect(label, 'Sport, Halle, Hr. Müller, 10:00–10:45'); + }); + + test('lässt einen leeren Ort weg', () { + final label = A11yLabels.appointmentLabel( + subject: 'Pause', + location: '', + start: DateTime(2026, 5, 8, 11, 0), + end: DateTime(2026, 5, 8, 11, 15), + crossedOut: false, + ); + expect(label, 'Pause, 11:00–11:15'); + }); + }); + + group('A11yLabels.moreAppointments', () { + test('pluralisiert die Overflow-Beschriftung', () { + expect(A11yLabels.moreAppointments(3), '+3 weitere Termine'); + }); + }); +} diff --git a/test/widget/app_progress_indicator_test.dart b/test/widget/app_progress_indicator_test.dart new file mode 100644 index 0000000..39967e6 --- /dev/null +++ b/test/widget/app_progress_indicator_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/widget/a11y/a11y_labels.dart'; +import 'package:marianum_mobile/widget/app_progress_indicator.dart'; + +void main() { + Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + + testWidgets('trägt standardmäßig das Lade-Label an den Screenreader', ( + tester, + ) async { + await tester.pumpWidget(wrap(const AppProgressIndicator.large())); + + final indicator = tester.widget( + find.byType(CircularProgressIndicator), + ); + expect(indicator.semanticsLabel, A11yLabels.loading); + }); + + testWidgets('reicht ein spezifisches Label durch', (tester) async { + await tester.pumpWidget( + wrap(const AppProgressIndicator.small(semanticsLabel: 'Wird gesendet')), + ); + + final indicator = tester.widget( + find.byType(CircularProgressIndicator), + ); + expect(indicator.semanticsLabel, 'Wird gesendet'); + }); +}