implemented comprehensive accessibility (A11y) support across the app

This commit is contained in:
2026-07-13 23:41:47 +02:00
parent 8274dd46cd
commit 478f0ff20b
46 changed files with 590 additions and 226 deletions
@@ -107,16 +107,26 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
var bloc = context.watch<LoadableStateBloc>(); var bloc = context.watch<LoadableStateBloc>();
final foreground = bloc.connectionForegroundColor(context); final foreground = bloc.connectionForegroundColor(context);
return Row( // liveRegion, damit das Auftauchen des Offline-/Fehlerbanners angesagt wird;
mainAxisAlignment: MainAxisAlignment.center, // Row-Semantik ausgeschlossen (Icon + Text) und Text über das explizite
children: [ // Label getragen, sonst liest der Screenreader ihn doppelt.
Icon(bloc.connectionIcon(), size: 14, color: foreground), return Semantics(
const SizedBox(width: 10), liveRegion: true,
Text( container: true,
bloc.connectionText(lastUpdated: widget.lastUpdated), label: bloc.connectionText(lastUpdated: widget.lastUpdated),
style: TextStyle(fontSize: 12, color: foreground), 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),
),
],
), ),
], ),
); );
} }
+1
View File
@@ -248,6 +248,7 @@ class AppModule {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
tooltip: isVisible ? 'Modul ausblenden' : 'Modul einblenden',
onPressed: onVisibleChange, onPressed: onVisibleChange,
icon: Icon( icon: Icon(
isVisible isVisible
+8 -5
View File
@@ -7,11 +7,14 @@ class LoginHeader extends StatelessWidget {
Widget build(BuildContext context) => Column( Widget build(BuildContext context) => Column(
children: [ children: [
const SizedBox(height: 40), const SizedBox(height: 40),
Image.asset( // Dekoratives Logo der Schulname steht direkt darunter als Text.
'assets/logo/icon.png', const ExcludeSemantics(
height: 110, child: Image(
fit: BoxFit.contain, image: AssetImage('assets/logo/icon.png'),
gaplessPlayback: true, height: 110,
fit: BoxFit.contain,
gaplessPlayback: true,
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
const Text( const Text(
@@ -33,6 +33,7 @@ class FilesSearchDelegate extends SearchDelegate<void> {
@override @override
Widget? buildLeading(BuildContext context) => IconButton( Widget? buildLeading(BuildContext context) => IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
tooltip: 'Zurück',
onPressed: () => close(context, null), onPressed: () => close(context, null),
); );
@@ -239,6 +239,7 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
), ),
trailing: IconButton( trailing: IconButton(
onPressed: () => copyToClipboard(context, _share.url!), onPressed: () => copyToClipboard(context, _share.url!),
tooltip: 'Link kopieren',
icon: const Icon(Icons.copy_outlined), icon: const Icon(Icons.copy_outlined),
), ),
), ),
@@ -127,6 +127,7 @@ class _ShareePickerPageState extends State<ShareePickerPage> {
? null ? null
: IconButton( : IconButton(
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
tooltip: 'Leeren',
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
_query = ''; _query = '';
@@ -166,6 +166,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
? null ? null
: IconButton( : IconButton(
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
tooltip: 'Leeren',
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
setState(() => _query = ''); setState(() => _query = '');
@@ -35,6 +35,7 @@ class GradeAveragesListView extends StatelessWidget {
Text(getGradeDisplay(grade)), Text(getGradeDisplay(grade)),
const SizedBox(width: 30), const SizedBox(width: 30),
IconButton( IconButton(
tooltip: 'Note entfernen',
onPressed: () { onPressed: () {
bloc.add(DecrementGrade(grade)); bloc.add(DecrementGrade(grade));
}, },
@@ -49,6 +50,7 @@ class GradeAveragesListView extends StatelessWidget {
), ),
), ),
IconButton( IconButton(
tooltip: 'Note hinzufügen',
onPressed: () { onPressed: () {
bloc.add(IncrementGrade(grade)); bloc.add(IncrementGrade(grade));
}, },
@@ -64,6 +66,7 @@ class GradeAveragesListView extends StatelessWidget {
maintainSize: true, maintainSize: true,
visible: bloc.canDecrementOrDelete(grade), visible: bloc.canDecrementOrDelete(grade),
child: IconButton( child: IconButton(
tooltip: 'Löschen',
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
onPressed: () { onPressed: () {
bloc.add(ResetGrade(grade)); bloc.add(ResetGrade(grade));
@@ -24,6 +24,7 @@ class GradeAveragesView extends StatelessWidget {
Visibility( Visibility(
visible: bloc.state.grades.isNotEmpty, visible: bloc.state.grades.isNotEmpty,
child: IconButton( child: IconButton(
tooltip: 'Alle zurücksetzen',
onPressed: () => ConfirmDialog( onPressed: () => ConfirmDialog(
title: 'Zurücksetzen?', title: 'Zurücksetzen?',
content: 'Alle Einträge werden entfernt.', content: 'Alle Einträge werden entfernt.',
@@ -38,6 +38,7 @@ class HolidaysView extends StatelessWidget {
title: const Text('Schulferien'), title: const Text('Schulferien'),
actions: [ actions: [
IconButton( IconButton(
tooltip: 'Informationen',
icon: const Icon(Icons.info_outline), icon: const Icon(Icons.info_outline),
onPressed: showDisclaimer, onPressed: showDisclaimer,
), ),
@@ -68,6 +68,7 @@ class MarianumDatesView extends StatelessWidget {
onSelected: (e) => bloc.add(SetPastEventsVisible(e)), onSelected: (e) => bloc.add(SetPastEventsVisible(e)),
), ),
IconButton( IconButton(
tooltip: 'Suchen',
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
onPressed: () { onPressed: () {
final events = bloc.getEvents() ?? const <MarianumDate>[]; final events = bloc.getEvents() ?? const <MarianumDate>[];
@@ -22,11 +22,16 @@ class SearchMarianumDates extends SearchDelegate<MarianumDate?> {
@override @override
List<Widget>? buildActions(BuildContext context) => [ List<Widget>? buildActions(BuildContext context) => [
if (query.isNotEmpty) if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)), IconButton(
tooltip: 'Leeren',
onPressed: () => query = '',
icon: const Icon(Icons.clear),
),
]; ];
@override @override
Widget? buildLeading(BuildContext context) => IconButton( Widget? buildLeading(BuildContext context) => IconButton(
tooltip: 'Zurück',
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => close(context, null), onPressed: () => close(context, null),
); );
@@ -22,6 +22,7 @@ class MarianumMessageListView extends StatelessWidget {
title: const Text('Marianum Message'), title: const Text('Marianum Message'),
actions: [ actions: [
IconButton( IconButton(
tooltip: 'Suchen',
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
onPressed: () { onPressed: () {
final list = bloc.state.data?.messageList; final list = bloc.state.data?.messageList;
@@ -23,11 +23,16 @@ class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
@override @override
List<Widget>? buildActions(BuildContext context) => [ List<Widget>? buildActions(BuildContext context) => [
if (query.isNotEmpty) if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)), IconButton(
tooltip: 'Leeren',
onPressed: () => query = '',
icon: const Icon(Icons.clear),
),
]; ];
@override @override
Widget? buildLeading(BuildContext context) => IconButton( Widget? buildLeading(BuildContext context) => IconButton(
tooltip: 'Zurück',
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => close(context, null), onPressed: () => close(context, null),
); );
+12 -6
View File
@@ -1,18 +1,24 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import '../../../../widget/a11y/a11y_labels.dart';
class Roomplan extends StatelessWidget { class Roomplan extends StatelessWidget {
const Roomplan({super.key}); const Roomplan({super.key});
@override @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Raumplan')), appBar: AppBar(title: const Text('Raumplan')),
body: PhotoView( body: Semantics(
imageProvider: Image.asset('assets/img/raumplan.png').image, image: true,
minScale: 0.5, label: A11yLabels.roomPlan,
maxScale: 2.0, child: PhotoView(
backgroundDecoration: BoxDecoration( imageProvider: Image.asset('assets/img/raumplan.png').image,
color: Theme.of(context).colorScheme.surface, minScale: 0.5,
maxScale: 2.0,
backgroundDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
),
), ),
), ),
); );
+1
View File
@@ -23,6 +23,7 @@ class _OverhangState extends State<Overhang> {
title: const Text('Mehr'), title: const Text('Mehr'),
actions: [ actions: [
IconButton( IconButton(
tooltip: 'Einstellungen',
onPressed: () => AppRoutes.openSettings(context), onPressed: () => AppRoutes.openSettings(context),
icon: const Icon(Icons.settings), icon: const Icon(Icons.settings),
), ),
@@ -66,6 +66,7 @@ class ModuleSortBody extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
tooltip: 'Slot entfernen',
icon: const Icon(Icons.remove_circle_outline), icon: const Icon(Icons.remove_circle_outline),
onPressed: onPressed:
modulesSettings.fixedBottomBarSlots > modulesSettings.fixedBottomBarSlots >
@@ -80,6 +81,7 @@ class ModuleSortBody extends StatelessWidget {
), ),
Text('${modulesSettings.fixedBottomBarSlots}'), Text('${modulesSettings.fixedBottomBarSlots}'),
IconButton( IconButton(
tooltip: 'Slot hinzufügen',
icon: const Icon(Icons.add_circle_outline), icon: const Icon(Icons.add_circle_outline),
onPressed: onPressed:
modulesSettings.fixedBottomBarSlots < modulesSettings.fixedBottomBarSlots <
@@ -277,13 +277,22 @@ class _PushStatusBodyState extends State<_PushStatusBody>
Widget _stateIcon(PushCheck state, ThemeData theme) { Widget _stateIcon(PushCheck state, ThemeData theme) {
switch (state) { switch (state) {
case PushCheck.ok: 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: 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: case PushCheck.unknown:
return Icon( return Icon(
Icons.remove_circle_outline, Icons.remove_circle_outline,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
semanticLabel: 'Unbekannt',
); );
} }
} }
@@ -76,6 +76,7 @@ class ShareChatPicker extends StatelessWidget {
actions: [ actions: [
Builder( Builder(
builder: (ctx) => IconButton( builder: (ctx) => IconButton(
tooltip: 'Suchen',
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
onPressed: () { onPressed: () {
final rooms = ctx.read<ChatListBloc>().state.data?.rooms; final rooms = ctx.read<ChatListBloc>().state.data?.rooms;
+1
View File
@@ -85,6 +85,7 @@ class _ChatListViewState extends State<_ChatListView> {
title: const Text('Talk'), title: const Text('Talk'),
actions: [ actions: [
IconButton( IconButton(
tooltip: 'Suchen',
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
onPressed: () { onPressed: () {
final rooms = bloc.state.data?.rooms; final rooms = bloc.state.data?.rooms;
+5 -1
View File
@@ -27,7 +27,11 @@ class JoinChat extends SearchDelegate<String> {
}, },
), ),
if (query.isNotEmpty) if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)), IconButton(
tooltip: 'Leeren',
onPressed: () => query = '',
icon: const Icon(Icons.clear),
),
]; ];
@override @override
+5 -1
View File
@@ -25,7 +25,11 @@ class SearchChat extends SearchDelegate<GetRoomResponseObject?> {
@override @override
List<Widget>? buildActions(BuildContext context) => [ List<Widget>? buildActions(BuildContext context) => [
if (query.isNotEmpty) if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)), IconButton(
tooltip: 'Leeren',
onPressed: () => query = '',
icon: const Icon(Icons.clear),
),
]; ];
@override @override
+65 -60
View File
@@ -9,6 +9,7 @@ import '../../../../share_intent/remote_file_ref.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../utils/downloads/download_job.dart'; import '../../../../utils/downloads/download_job.dart';
import '../../../../utils/haptics.dart'; import '../../../../utils/haptics.dart';
import '../../../../widget/a11y/a11y_labels.dart';
import '../../../../widget/demo_restricted.dart'; import '../../../../widget/demo_restricted.dart';
import '../../../../widget/downloads/download_trigger.dart'; import '../../../../widget/downloads/download_trigger.dart';
import '../data/chat_bubble_styles.dart'; import '../data/chat_bubble_styles.dart';
@@ -120,9 +121,7 @@ class _ChatBubbleState extends State<ChatBubble>
if (!_rendersAsCommentBubble) { if (!_rendersAsCommentBubble) {
base = styles.getSystemStyle(); base = styles.getSystemStyle();
} else { } else {
base = widget.isSender base = widget.isSender ? styles.getSelfStyle() : styles.getRemoteStyle();
? styles.getSelfStyle()
: styles.getRemoteStyle();
} }
switch (widget.matchHighlight) { switch (widget.matchHighlight) {
case SearchHighlight.none: case SearchHighlight.none:
@@ -130,7 +129,9 @@ class _ChatBubbleState extends State<ChatBubble>
case SearchHighlight.secondary: case SearchHighlight.secondary:
return base.copyWith( return base.copyWith(
borderWidth: 1.5, 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: case SearchHighlight.active:
return base.copyWith( return base.copyWith(
@@ -342,68 +343,72 @@ class _BubbleContent extends StatelessWidget {
}); });
@override @override
Widget build(BuildContext context) => Container( Widget build(BuildContext context) => MergeSemantics(
constraints: BoxConstraints( child: Container(
maxWidth: MediaQuery.of(context).size.width * 0.9, constraints: BoxConstraints(
minWidth: showActorDisplayName maxWidth: MediaQuery.of(context).size.width * 0.9,
? actorText.size.width minWidth: showActorDisplayName
: timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3, ? actorText.size.width
), : timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3,
child: Stack( ),
children: [ child: Stack(
if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorWidget), children: [
Padding( if (showActorDisplayName)
padding: EdgeInsets.only( Positioned(top: 0, left: 0, child: actorWidget),
bottom: showBubbleTime ? 18 : 0, Padding(
top: showActorDisplayName ? 18 : 0, padding: EdgeInsets.only(
), bottom: showBubbleTime ? 18 : 0,
child: Column( top: showActorDisplayName ? 18 : 0,
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ child: Column(
if (parent != null && crossAxisAlignment: CrossAxisAlignment.start,
bubbleData.messageType ==
GetRoomResponseObjectMessageType.comment) ...[
AnswerReference(
referenceMessage: parent!,
selfId: selfId,
),
const SizedBox(height: 5),
],
messageWidget,
],
),
),
if (showBubbleTime)
Positioned(
bottom: 0,
right: 0,
child: Row(
children: [ children: [
timeText, if (parent != null &&
if (isSender) ...[ bubbleData.messageType ==
SizedBox(width: spacing), GetRoomResponseObjectMessageType.comment) ...[
Icon( AnswerReference(referenceMessage: parent!, selfId: selfId),
isRead ? Icons.done_all_outlined : Icons.done_outlined, const SizedBox(height: 5),
size: timeIconSize,
color: timeIconColor,
),
], ],
messageWidget,
], ],
), ),
), ),
if (downloadJob?.status.value is DownloadInProgress) if (showBubbleTime)
Positioned( Positioned(
bottom: 0, bottom: 0,
right: 0, right: 0,
left: 0, child: Row(
child: LinearProgressIndicator( children: [
value: () { timeText,
final s = downloadJob!.status.value as DownloadInProgress; if (isSender) ...[
return s.percent <= 0 ? null : s.percent / 100; 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;
}(),
),
),
],
),
), ),
); );
} }
@@ -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.dart';
import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart'; import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../widget/a11y/a11y_labels.dart';
import '../../../../widget/async_action_button.dart'; import '../../../../widget/async_action_button.dart';
import '../../../../widget/demo_restricted.dart'; import '../../../../widget/demo_restricted.dart';
import '../../../../widget/emoji_text.dart'; import '../../../../widget/emoji_text.dart';
@@ -41,44 +42,58 @@ class ChatBubbleReactions extends StatelessWidget {
children: reactions.entries.map<Widget>((e) { children: reactions.entries.map<Widget>((e) {
final hasSelfReacted = final hasSelfReacted =
bubbleData.reactionsSelf?.contains(e.key) ?? false; 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( return Container(
margin: const EdgeInsets.only(right: 2.5, left: 2.5), margin: const EdgeInsets.only(right: 2.5, left: 2.5),
child: ActionChip( child: Semantics(
label: Row( button: true,
mainAxisSize: MainAxisSize.min, label: label,
children: [ onTap: toggle,
EmojiText(e.key, size: EmojiText.sizeInline), child: ExcludeSemantics(
const SizedBox(width: 4), child: ActionChip(
Text('${e.value}'), 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(), }).toList(),
@@ -259,6 +259,7 @@ class _ReactionsRowState extends State<_ReactionsRow> {
], ],
_groupDivider(context), _groupDivider(context),
IconButton( IconButton(
tooltip: 'Reaktion hinzufügen',
onPressed: busy ? null : () => _showEmojiPicker(context), onPressed: busy ? null : () => _showEmojiPicker(context),
style: IconButton.styleFrom( style: IconButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@@ -30,6 +30,7 @@ class ChatSearchAppBar extends StatelessWidget implements PreferredSizeWidget {
: '${activeIndex + 1}/$matchCount'; : '${activeIndex + 1}/$matchCount';
return AppBar( return AppBar(
leading: IconButton( leading: IconButton(
tooltip: 'Zurück',
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: onClose, onPressed: onClose,
), ),
@@ -54,10 +55,12 @@ class ChatSearchAppBar extends StatelessWidget implements PreferredSizeWidget {
), ),
), ),
IconButton( IconButton(
tooltip: 'Vorheriges Ergebnis',
icon: const Icon(Icons.keyboard_arrow_up), icon: const Icon(Icons.keyboard_arrow_up),
onPressed: onPrevious, onPressed: onPrevious,
), ),
IconButton( IconButton(
tooltip: 'Nächstes Ergebnis',
icon: const Icon(Icons.keyboard_arrow_down), icon: const Icon(Icons.keyboard_arrow_down),
onPressed: onNext, onPressed: onNext,
), ),
@@ -268,6 +268,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
), ),
), ),
IconButton( IconButton(
tooltip: 'Antwort verwerfen',
onPressed: () { onPressed: () {
chatBloc.setReferenceMessageId(null); chatBloc.setReferenceMessageId(null);
_setDraftReply(null); _setDraftReply(null);
+26 -12
View File
@@ -14,6 +14,7 @@ import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../../utils/haptics.dart'; import '../../../../utils/haptics.dart';
import '../../../../widget/a11y/a11y_labels.dart';
import '../../../../widget/async_action_button.dart'; import '../../../../widget/async_action_button.dart';
import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/debug/debug_tile.dart';
@@ -106,6 +107,7 @@ class _ChatTileState extends State<ChatTile> {
Icons.star, Icons.star,
color: Colors.amberAccent, color: Colors.amberAccent,
size: 15, size: 15,
semanticLabel: A11yLabels.favorite,
), ),
), ),
), ),
@@ -123,7 +125,11 @@ class _ChatTileState extends State<ChatTile> {
), ),
if (widget.hasDraft) ...[ if (widget.hasDraft) ...[
const SizedBox(width: 5), 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<ChatTile> {
), ),
trailing: widget.data.unreadMessages <= 0 trailing: widget.data.unreadMessages <= 0
? null ? null
: Container( : Semantics(
padding: const EdgeInsets.all(1), label: '${widget.data.unreadMessages} ${A11yLabels.unread}',
decoration: BoxDecoration( child: ExcludeSemantics(
color: Theme.of(context).primaryColor, child: Container(
borderRadius: BorderRadius.circular(30), padding: const EdgeInsets.all(1),
), decoration: BoxDecoration(
constraints: const BoxConstraints(minWidth: 20, minHeight: 20), color: Theme.of(context).primaryColor,
child: Text( borderRadius: BorderRadius.circular(30),
'${widget.data.unreadMessages}', ),
style: const TextStyle(color: Colors.white, fontSize: 15), constraints: const BoxConstraints(
textAlign: TextAlign.center, minWidth: 20,
minHeight: 20,
),
child: Text(
'${widget.data.unreadMessages}',
style: const TextStyle(color: Colors.white, fontSize: 15),
textAlign: TextAlign.center,
),
),
), ),
), ),
onTap: () { onTap: () {
@@ -16,7 +16,10 @@ class SplitViewPlaceholder extends StatelessWidget {
data: MediaQuery.of( data: MediaQuery.of(
context, context,
).copyWith(invertColors: !AppTheme.isDarkMode(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 SizedBox(height: 30),
const Text( const Text(
@@ -27,6 +27,7 @@ class CustomEventsView extends StatelessWidget {
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
tooltip: 'Termin erstellen',
onPressed: () => _openCreateDialog(context), onPressed: () => _openCreateDialog(context),
), ),
], ],
@@ -67,6 +68,7 @@ class CustomEventsView extends StatelessWidget {
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.edit_outlined), icon: const Icon(Icons.edit_outlined),
tooltip: 'Bearbeiten',
onPressed: () => showDialog( onPressed: () => showDialog(
context: context, context: context,
builder: (_) => builder: (_) =>
@@ -75,6 +77,7 @@ class CustomEventsView extends StatelessWidget {
), ),
IconButton( IconButton(
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
tooltip: 'Löschen',
onPressed: () => onPressed: () =>
showDeleteCustomEventDialog(context, e), showDeleteCustomEventDialog(context, e),
), ),
@@ -103,6 +103,7 @@ class LessonSheet {
static Widget _roomTile(BuildContext context, McTimetableEntry lesson) { static Widget _roomTile(BuildContext context, McTimetableEntry lesson) {
final trailing = IconButton( final trailing = IconButton(
icon: const Icon(Icons.house_outlined), icon: const Icon(Icons.house_outlined),
tooltip: 'Raumplan öffnen',
onPressed: () => AppRoutes.openRoomplan(context), onPressed: () => AppRoutes.openRoomplan(context),
); );
@@ -14,12 +14,17 @@ class SearchSubjectColors extends SearchDelegate<void> {
@override @override
List<Widget>? buildActions(BuildContext context) => [ List<Widget>? buildActions(BuildContext context) => [
if (query.isNotEmpty) if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)), IconButton(
onPressed: () => query = '',
tooltip: 'Leeren',
icon: const Icon(Icons.clear),
),
]; ];
@override @override
Widget? buildLeading(BuildContext context) => IconButton( Widget? buildLeading(BuildContext context) => IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
tooltip: 'Zurück',
onPressed: () => close(context, null), onPressed: () => close(context, null),
); );
@@ -21,6 +21,7 @@ class SubjectColorsView extends StatelessWidget {
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
tooltip: 'Suchen',
onPressed: () => onPressed: () =>
showSearch(context: context, delegate: SearchSubjectColors()), showSearch(context: context, delegate: SearchSubjectColors()),
), ),
+2
View File
@@ -110,6 +110,7 @@ class _TimetableState extends State<Timetable> {
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.home_outlined), icon: const Icon(Icons.home_outlined),
tooltip: 'Zur aktuellen Woche',
onPressed: atToday ? null : _jumpToToday, onPressed: atToday ? null : _jumpToToday,
), ),
PopupMenuButton<_CalendarAction>( PopupMenuButton<_CalendarAction>(
@@ -179,6 +180,7 @@ class _TimetableState extends State<Timetable> {
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.home_outlined), icon: const Icon(Icons.home_outlined),
tooltip: 'Zur aktuellen Woche',
onPressed: atToday ? null : _jumpToToday, onPressed: atToday ? null : _jumpToToday,
), ),
if (canViewForeign) if (canViewForeign)
@@ -88,7 +88,7 @@ class _OutsideDayColumn extends StatelessWidget {
} }
static String _subtitleFor(Appointment a) { static String _subtitleFor(Appointment a) {
if (isAllDayLike(a)) return 'Ganztägig'; if (isAllDayLike(a)) return A11yLabels.allDay;
return '${a.startTime.formatHm()}${a.endTime.formatHm()}'; return '${a.startTime.formatHm()}${a.endTime.formatHm()}';
} }
@@ -122,6 +122,7 @@ class _OutsideDayColumn extends StatelessWidget {
height: kOutsideChipHeight, height: kOutsideChipHeight,
child: _OutsideChip( child: _OutsideChip(
appointment: visible[i], appointment: visible[i],
crossedOut: isCrossedOut(visible[i]),
onTap: () => onAppointmentTap(visible[i]), onTap: () => onAppointmentTap(visible[i]),
), ),
), ),
@@ -144,15 +145,28 @@ class _OutsideDayColumn extends StatelessWidget {
class _OutsideChip extends StatelessWidget { class _OutsideChip extends StatelessWidget {
final Appointment appointment; final Appointment appointment;
final bool crossedOut;
final VoidCallback onTap; final VoidCallback onTap;
const _OutsideChip({required this.appointment, required this.onTap}); const _OutsideChip({
required this.appointment,
required this.crossedOut,
required this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final allDay = isAllDayLike(appointment); final allDay = isAllDayLike(appointment);
final timeLabel = allDay ? null : appointment.startTime.formatHm(); 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 // Past chips fade further, future/ongoing ones get a more saturated tint
// so the strip no longer reads as one uniform grey block. // so the strip no longer reads as one uniform grey block.
@@ -163,47 +177,54 @@ class _OutsideChip extends StatelessWidget {
: theme.colorScheme.onSurface; : theme.colorScheme.onSurface;
final subjectWeight = isPast ? FontWeight.w400 : FontWeight.w600; final subjectWeight = isPast ? FontWeight.w400 : FontWeight.w600;
return Material( return Semantics(
color: appointment.color.withAlpha(backgroundAlpha), button: true,
shape: const RoundedRectangleBorder( label: semanticsLabel,
borderRadius: BorderRadius.all(Radius.circular(7)), onTap: onTap,
), child: ExcludeSemantics(
clipBehavior: Clip.antiAlias, child: Material(
child: InkWell( color: appointment.color.withAlpha(backgroundAlpha),
onTap: onTap, shape: const RoundedRectangleBorder(
child: Padding( borderRadius: BorderRadius.all(Radius.circular(7)),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), ),
child: Row( clipBehavior: Clip.antiAlias,
mainAxisSize: MainAxisSize.max, child: InkWell(
children: [ onTap: onTap,
Expanded( child: Padding(
child: Text( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
appointment.subject, child: Row(
maxLines: 1, mainAxisSize: MainAxisSize.max,
overflow: TextOverflow.ellipsis, children: [
softWrap: false, Expanded(
style: theme.textTheme.labelSmall?.copyWith( child: Text(
color: subjectColor, appointment.subject,
fontWeight: subjectWeight, maxLines: 1,
), overflow: TextOverflow.ellipsis,
), softWrap: false,
), style: theme.textTheme.labelSmall?.copyWith(
if (timeLabel != null) ...[ color: subjectColor,
const SizedBox(width: 4), fontWeight: subjectWeight,
Flexible( ),
child: Text(
timeLabel,
maxLines: 1,
overflow: TextOverflow.fade,
softWrap: false,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 10,
), ),
), ),
), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Material( return Semantics(
color: theme.colorScheme.secondaryContainer, button: true,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), label: A11yLabels.moreAppointments(count),
clipBehavior: Clip.antiAlias, onTap: onTap,
child: InkWell( child: ExcludeSemantics(
onTap: onTap, child: Material(
child: Center( color: theme.colorScheme.secondaryContainer,
child: Text( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
'+$count weitere', clipBehavior: Clip.antiAlias,
style: theme.textTheme.labelSmall?.copyWith( child: InkWell(
color: theme.colorScheme.onSecondaryContainer, onTap: onTap,
fontWeight: FontWeight.w600, child: Center(
child: Text(
'+$count weitere',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
), ),
), ),
), ),
@@ -112,6 +112,7 @@ class _PeriodLabel extends StatelessWidget {
Icons.coffee_outlined, Icons.coffee_outlined,
size: 12, size: 12,
color: secondaryTextColor.withAlpha(180), color: secondaryTextColor.withAlpha(180),
semanticLabel: A11yLabels.breakTime,
), ),
); );
} }
@@ -328,18 +329,39 @@ class _DayColumn extends StatelessWidget {
left: cell.lane * width / cell.laneCount, left: cell.lane * width / cell.laneCount,
width: width / cell.laneCount, width: width / cell.laneCount,
child: switch (cell) { child: switch (cell) {
LaidOutAppointment(:final appointment) => GestureDetector( LaidOutAppointment(:final appointment) => Semantics(
behavior: HitTestBehavior.opaque, button: true,
onTap: () => onAppointmentTap(appointment), label: A11yLabels.appointmentLabel(
child: AppointmentTile( subject: appointment.subject,
appointment: appointment, location: appointment.location ?? '',
start: appointment.startTime,
end: appointment.endTime,
crossedOut: isCrossedOut(appointment), 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( LaidOutOverflow(:final appointments) => Semantics(
behavior: HitTestBehavior.opaque, button: true,
label: A11yLabels.moreAppointments(appointments.length),
onTap: () => _showOverflowSheet(context, appointments), 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),
),
),
), ),
}, },
), ),
@@ -14,6 +14,7 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../extensions/date_time.dart'; import '../../../../extensions/date_time.dart';
import '../../../../utils/haptics.dart'; import '../../../../utils/haptics.dart';
import '../../../../widget/a11y/a11y_labels.dart';
import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/details_bottom_sheet.dart';
import '../data/calendar_layout.dart'; import '../data/calendar_layout.dart';
import '../data/calendar_logic.dart'; import '../data/calendar_logic.dart';
+49
View File
@@ -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:0008: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 = <String>[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';
}
+26 -6
View File
@@ -1,24 +1,43 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'a11y/a11y_labels.dart';
class AppProgressIndicator extends StatelessWidget { class AppProgressIndicator extends StatelessWidget {
final double size; final double size;
final double strokeWidth; final double strokeWidth;
final Color? color; final Color? color;
final String semanticsLabel;
const AppProgressIndicator._({ const AppProgressIndicator._({
required this.size, required this.size,
required this.strokeWidth, required this.strokeWidth,
this.color, this.color,
this.semanticsLabel = A11yLabels.loading,
}); });
const AppProgressIndicator.small({Color? color}) const AppProgressIndicator.small({Color? color, String? semanticsLabel})
: this._(size: 16, strokeWidth: 2, color: color); : this._(
size: 16,
strokeWidth: 2,
color: color,
semanticsLabel: semanticsLabel ?? A11yLabels.loading,
);
const AppProgressIndicator.medium({Color? color}) const AppProgressIndicator.medium({Color? color, String? semanticsLabel})
: this._(size: 24, strokeWidth: 2.5, color: color); : this._(
size: 24,
strokeWidth: 2.5,
color: color,
semanticsLabel: semanticsLabel ?? A11yLabels.loading,
);
const AppProgressIndicator.large({Color? color}) const AppProgressIndicator.large({Color? color, String? semanticsLabel})
: this._(size: 40, strokeWidth: 3, color: color); : this._(
size: 40,
strokeWidth: 3,
color: color,
semanticsLabel: semanticsLabel ?? A11yLabels.loading,
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -29,6 +48,7 @@ class AppProgressIndicator extends StatelessWidget {
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: strokeWidth, strokeWidth: strokeWidth,
valueColor: AlwaysStoppedAnimation<Color>(resolved), valueColor: AlwaysStoppedAnimation<Color>(resolved),
semanticsLabel: semanticsLabel,
), ),
); );
} }
+8 -4
View File
@@ -105,10 +105,14 @@ class _InlineErrorWrapper extends StatelessWidget {
child, child,
if (err != null) ...[ if (err != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Semantics(
err, liveRegion: true,
textAlign: TextAlign.center, container: true,
style: _asyncErrorTextStyle(context), child: Text(
err,
textAlign: TextAlign.center,
style: _asyncErrorTextStyle(context),
),
), ),
], ],
], ],
+1
View File
@@ -20,6 +20,7 @@ Future<String?> showEmojiPicker(
title: Row( title: Row(
children: [ children: [
IconButton( IconButton(
tooltip: 'Zurück',
onPressed: () => Navigator.of(pickerCtx).pop(), onPressed: () => Navigator.of(pickerCtx).pop(),
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
), ),
+1
View File
@@ -249,6 +249,7 @@ class _FileViewerState extends State<FileViewer> {
photoViewController.rotation += pi / 2; photoViewController.rotation += pi / 2;
}); });
}, },
tooltip: 'Drehen',
icon: const Icon(Icons.rotate_right), icon: const Icon(Icons.rotate_right),
), ),
], ],
+21 -13
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import '../model/account_data.dart'; import '../model/account_data.dart';
import 'a11y/a11y_labels.dart';
import 'user_avatar.dart'; import 'user_avatar.dart';
class LargeProfilePictureView extends StatelessWidget { class LargeProfilePictureView extends StatelessWidget {
@@ -15,18 +16,25 @@ class LargeProfilePictureView extends StatelessWidget {
}); });
@override @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) {
appBar: AppBar(title: Text(isGroup ? 'Gruppenbild' : 'Profilbild')), final label = isGroup ? A11yLabels.groupPicture : A11yLabels.profilePicture;
body: PhotoView( return Scaffold(
minScale: 0.5, appBar: AppBar(title: Text(label)),
maxScale: 3.0, body: Semantics(
imageProvider: Image.network( image: true,
avatarUrl(id: id, isGroup: isGroup, size: 1024), label: label,
headers: {'Authorization': AccountData().getBasicAuthHeader()}, child: PhotoView(
).image, minScale: 0.5,
backgroundDecoration: BoxDecoration( maxScale: 3.0,
color: Theme.of(context).colorScheme.surface, imageProvider: Image.network(
avatarUrl(id: id, isGroup: isGroup, size: 1024),
headers: {'Authorization': AccountData().getBasicAuthHeader()},
).image,
backgroundDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
),
),
), ),
), );
); }
} }
+27 -7
View File
@@ -10,6 +10,7 @@ import 'package:http/http.dart' as http;
import '../model/account_data.dart'; import '../model/account_data.dart';
import '../model/endpoint_data.dart'; import '../model/endpoint_data.dart';
import '../push/push_avatar.dart'; import '../push/push_avatar.dart';
import 'a11y/a11y_labels.dart';
import 'avatar_disk_cache.dart'; import 'avatar_disk_cache.dart';
class UserAvatar extends StatefulWidget { class UserAvatar extends StatefulWidget {
@@ -17,6 +18,10 @@ class UserAvatar extends StatefulWidget {
final bool isGroup; final bool isGroup;
final int size; 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 /// Server-side pixel size requested for user avatars. `null` lets the
/// widget pick `(size * 4).clamp(64, 1024)` — enough headroom for typical /// widget pick `(size * 4).clamp(64, 1024)` — enough headroom for typical
/// device pixel ratios. Group avatars ignore this (Spreed serves one /// device pixel ratios. Group avatars ignore this (Spreed serves one
@@ -28,6 +33,7 @@ class UserAvatar extends StatefulWidget {
this.isGroup = false, this.isGroup = false,
this.size = 20, this.size = 20,
this.requestSize, this.requestSize,
this.semanticLabel,
super.key, super.key,
}); });
@@ -225,7 +231,9 @@ class _UserAvatarState extends State<UserAvatar> {
final pending = _pendingAvatars.putIfAbsent(url, () { final pending = _pendingAvatars.putIfAbsent(url, () {
final future = _fetch(url); final future = _fetch(url);
future.whenComplete(() { future.whenComplete(() {
if (identical(_pendingAvatars[url], future)) _pendingAvatars.remove(url); if (identical(_pendingAvatars[url], future)) {
_pendingAvatars.remove(url);
}
}); });
return future; return future;
}); });
@@ -345,12 +353,24 @@ class _UserAvatarState extends State<UserAvatar> {
); );
} }
return CircleAvatar( return Semantics(
radius: radius, image: true,
backgroundColor: theme.primaryColor, label:
foregroundColor: Colors.white, widget.semanticLabel ??
child: ClipOval( (widget.isGroup
child: SizedBox(width: radius * 2, height: radius * 2, child: content), ? 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,
),
),
), ),
); );
} }
+63
View File
@@ -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:0008: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:0009: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:0010: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:0011:15');
});
});
group('A11yLabels.moreAppointments', () {
test('pluralisiert die Overflow-Beschriftung', () {
expect(A11yLabels.moreAppointments(3), '+3 weitere Termine');
});
});
}
@@ -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<CircularProgressIndicator>(
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<CircularProgressIndicator>(
find.byType(CircularProgressIndicator),
);
expect(indicator.semanticsLabel, 'Wird gesendet');
});
}