implemented comprehensive accessibility (A11y) support across the app
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -27,7 +27,11 @@ class JoinChat extends SearchDelegate<String> {
|
||||
},
|
||||
),
|
||||
if (query.isNotEmpty)
|
||||
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
|
||||
IconButton(
|
||||
tooltip: 'Leeren',
|
||||
onPressed: () => query = '',
|
||||
icon: const Icon(Icons.clear),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
|
||||
@@ -25,7 +25,11 @@ class SearchChat extends SearchDelegate<GetRoomResponseObject?> {
|
||||
@override
|
||||
List<Widget>? 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
|
||||
|
||||
@@ -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<ChatBubble>
|
||||
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<ChatBubble>
|
||||
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;
|
||||
}(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Widget>((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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -268,6 +268,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Antwort verwerfen',
|
||||
onPressed: () {
|
||||
chatBloc.setReferenceMessageId(null);
|
||||
_setDraftReply(null);
|
||||
|
||||
@@ -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<ChatTile> {
|
||||
Icons.star,
|
||||
color: Colors.amberAccent,
|
||||
size: 15,
|
||||
semanticLabel: A11yLabels.favorite,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -123,7 +125,11 @@ class _ChatTileState extends State<ChatTile> {
|
||||
),
|
||||
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<ChatTile> {
|
||||
),
|
||||
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: () {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user