diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml
index a077fff057..b40f3e9973 100644
--- a/docs/docs_screenshots/pubspec.yaml
+++ b/docs/docs_screenshots/pubspec.yaml
@@ -19,7 +19,11 @@ dependencies:
sdk: flutter
record: ^6.2.0
stream_chat_flutter: ^10.2.0
- stream_core_flutter: ^0.4.1
+ stream_core_flutter:
+ git:
+ url: https://github.com/GetStream/stream-core-flutter.git
+ ref: 5b5c51ad270ef9ff21efa9eb20e490f19e40ac87
+ path: packages/stream_core_flutter
dev_dependencies:
alchemist: ^0.14.0
diff --git a/docs/docs_screenshots/test/src/mocks.dart b/docs/docs_screenshots/test/src/mocks.dart
index 1274584355..6fc2e4d967 100644
--- a/docs/docs_screenshots/test/src/mocks.dart
+++ b/docs/docs_screenshots/test/src/mocks.dart
@@ -140,6 +140,8 @@ void setupMockChannel({
when(channel.getRemainingCooldown).thenReturn(0);
when(() => channel.getRemainingCooldown(lastMessageAt: any(named: 'lastMessageAt'))).thenReturn(0);
when(() => channel.isDistinct).thenReturn(false);
+ when(() => channel.isGroup).thenReturn(true);
+ when(() => channel.isOneToOne).thenReturn(false);
when(() => channel.isMuted).thenReturn(false);
when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false));
when(() => channel.isPinned).thenReturn(false);
diff --git a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_tile_custom.png b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_tile_custom.png
index 18bcb409cf..951f41fab1 100644
Binary files a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_tile_custom.png and b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_tile_custom.png differ
diff --git a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_unread_banner.png b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_unread_banner.png
index 0f521a6acb..1e8186c604 100644
Binary files a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_unread_banner.png and b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_unread_banner.png differ
diff --git a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view.png b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view.png
index 93cfb4513e..b815fa071b 100644
Binary files a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view.png and b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view.png differ
diff --git a/melos.yaml b/melos.yaml
index cf3dc86834..2f6c122842 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -104,7 +104,11 @@ command:
stream_chat_persistence: ^10.2.0
streaming_shared_preferences: ^2.0.0
svg_icon_widget: ^0.0.1
- stream_core_flutter: ^0.4.1
+ stream_core_flutter:
+ git:
+ url: https://github.com/GetStream/stream-core-flutter.git
+ ref: 5b5c51ad270ef9ff21efa9eb20e490f19e40ac87
+ path: packages/stream_core_flutter
stream_thumbnail: ^0.1.0
synchronized: ^3.4.0
thumblr: ^0.0.4
diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md
index 1c4cc102ca..5ae65aee35 100644
--- a/packages/stream_chat_flutter/CHANGELOG.md
+++ b/packages/stream_chat_flutter/CHANGELOG.md
@@ -12,8 +12,12 @@
✅ Added
-- Added an `AccessibilityTranslations` namespace on `Translations`, accessed via `context.translations.accessibility`, holding all screen-reader labels, tooltips, hints, and live-region announcements used by the composer, voice recording, attachment picker, message actions, channel header, media gallery, and poll creator. Getter suffixes follow Flutter's `MaterialLocalizations` convention (`Tooltip`, `Label`, `Hint`, `TapHint`, `Announcement`). Added a `DateTime.toA11yTimestamp()` extension for locale-aware long-form timestamps in accessibility labels.
+- Added `AccessibilityTranslations` on `Translations` (accessed via `context.translations.accessibility`) — screen-reader labels, tooltips, hints, and live-region announcements. Includes `formatDateTime`, `formatDuration`, and `formatRecentDateTime` formatters.
+- Added `AccessibleMessagePreviewFormatter` — an optional interface extending `MessagePreviewFormatter` with `formatMessageSemanticsLabel` and `formatDraftMessageSemanticsLabel`. `StreamMessagePreviewText`, `StreamDraftMessagePreviewText`, and `StreamSendingIndicator` now emit natural-language screen-reader labels for the channel list.
+- Added optional `semanticsLabel` to `StreamChannelAvatar`. Group channels emit a default `"Group"` label; direct-message avatars stay silent.
+- Added optional `semanticsLabel` to `StreamTimestamp`. Defaults to a bucketed natural-language phrasing via `AccessibilityTranslations.formatRecentDateTime`.
- Added a `LastMessagePredicate` typedef for the `ChannelLastMessageText.lastMessagePredicate` filter.
+- Added optional `semanticsLabel` to `StreamUserAvatar`, `StreamUserAvatarGroup`, and `StreamUserAvatarStack`.
- Made `StreamVideoPlayer` overridable via the `StreamComponentFactory` (`streamChatComponentBuilders(videoPlayer: ...)`), with the previous implementation now available as `DefaultStreamVideoPlayer`.
🐞 Fixed
@@ -24,6 +28,11 @@
- Fixed `StreamMessageListView` firing `markThreadRead` on a reply-less parent, which produced a guaranteed 404 every time the thread view was opened before the first reply.
- Fixed dismissing the `StreamMessageListView` unread indicator being ignored while a channel is receiving a rapid burst of messages.
- Fixed `StreamTypingIndicator` rebuilding on every typing event by comparing the typing users by id, so it only rebuilds when the set of typing users changes.
+- Fixed `StreamChannelListTile` announcing avatar initials and unlabeled fragments — now merges into a single accessible row summary with labeled avatar, muted / pinned icons, and unread badge.
+- Fixed `StreamThreadListTile` announcing content as flat comma-separated fragments — now merges into a single accessible row summary and labels the unread badge with the localized `"N unread messages"` phrasing.
+- Renamed `attachmentPickerTooltip` to state-agnostic "Toggle attachment picker" so screen readers no longer announce "Open attachment picker" while the picker is already expanded.
+- Fixed `voiceRecordingText` casing to sentence case (`"Voice recording"`).
+- Fixed `formatRecentDateTime` hardcoding 24-hour time — displayed and announced timestamps now follow the locale's convention (e.g. 24-hour in Germany, 12-hour in India).
- Replaced the `get_thumbnail_video` dependency with Stream's own `stream_thumbnail` plugin, resolving the iOS duplicate-`VideoThumbnailPlugin`-symbol crash when an app also uses `video_thumbnail`/`video_editor` ([#2360](https://github.com/GetStream/stream-chat-flutter/issues/2360)).
## 10.1.0
diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_draft_message_preview_text.dart b/packages/stream_chat_flutter/lib/src/channel/stream_draft_message_preview_text.dart
index 2ac3eb91be..8b0e6b1a9b 100644
--- a/packages/stream_chat_flutter/lib/src/channel/stream_draft_message_preview_text.dart
+++ b/packages/stream_chat_flutter/lib/src/channel/stream_draft_message_preview_text.dart
@@ -37,12 +37,26 @@ class StreamDraftMessagePreviewText extends StatelessWidget {
showCaption: showCaption,
);
+ // Prefer a hand-crafted a11y label when the formatter opts into
+ // [AccessibleMessagePreviewFormatter]; fall back to the visual
+ // TextSpan stripped of inline icon placeholders.
+ final a11yLabel = switch (formatter) {
+ final AccessibleMessagePreviewFormatter it => it.formatDraftMessageSemanticsLabel(
+ context,
+ draftMessage,
+ currentUser: currentUser,
+ showCaption: showCaption,
+ ),
+ _ => previewTextSpan.toPlainText(includePlaceholders: false),
+ };
+
return Text.rich(
maxLines: 1,
previewTextSpan,
style: textStyle,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.start,
+ semanticsLabel: a11yLabel,
);
}
}
diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart
index 5bf38854c3..6bfc539da3 100644
--- a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart
+++ b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart
@@ -49,12 +49,29 @@ class StreamMessagePreviewText extends StatelessWidget {
showCaption: showCaption,
);
+ // Prefer a hand-crafted a11y label when the formatter opts into
+ // [AccessibleMessagePreviewFormatter]; fall back to the visual
+ // TextSpan stripped of inline icon placeholders so custom formatters
+ // that only implement [MessagePreviewFormatter] still get a
+ // reasonable — if less rich — screen-reader announcement.
+ final a11yLabel = switch (formatter) {
+ final AccessibleMessagePreviewFormatter it => it.formatMessageSemanticsLabel(
+ context,
+ previewMessage,
+ channel: channel,
+ currentUser: currentUser,
+ showCaption: showCaption,
+ ),
+ _ => previewTextSpan.toPlainText(includePlaceholders: false),
+ };
+
return Text.rich(
maxLines: 1,
previewTextSpan,
style: textStyle,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.start,
+ semanticsLabel: a11yLabel,
);
}
}
diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart
index 7e3974a44b..4a004c46fd 100644
--- a/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart
+++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart
@@ -2,6 +2,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/components/avatar/stream_user_avatar.dart';
import 'package:stream_chat_flutter/src/components/avatar/stream_user_avatar_group.dart';
+import 'package:stream_chat_flutter/src/utils/extensions.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_core_flutter/chat.dart';
@@ -54,6 +55,7 @@ class StreamChannelAvatar extends StatelessWidget {
super.key,
this.size,
required this.channel,
+ this.semanticsLabel,
});
/// The channel whose avatar is displayed.
@@ -64,17 +66,27 @@ class StreamChannelAvatar extends StatelessWidget {
/// If null, defaults to [StreamAvatarGroupSize.lg].
final StreamAvatarGroupSize? size;
+ /// Screen-reader label for the avatar.
+ ///
+ /// When null (the default), a `"Group"` label is emitted for group
+ /// channels and direct-message avatars stay silent. When non-null, the
+ /// given string replaces the default; an empty string leaves the avatar
+ /// silent.
+ final String? semanticsLabel;
+
@override
Widget build(BuildContext context) {
assert(channel.state != null, 'Channel ${channel.id} is not initialized');
final effectiveSize = size ?? StreamAvatarGroupSize.lg;
+ final effectiveLabel = semanticsLabel ?? _defaultSemanticsLabel(context);
return BetterStreamBuilder(
stream: channel.imageStream,
initialData: channel.image,
builder: (context, channelImage) => StreamAvatar(
imageUrl: channelImage,
+ semanticsLabel: effectiveLabel,
size: _avatarSizeForAvatarGroupSize(effectiveSize),
placeholder: (_) => const _StreamChannelAvatarPlaceholder(),
),
@@ -85,13 +97,14 @@ class StreamChannelAvatar extends StatelessWidget {
final users = members.map((it) => it.user!).toList();
final currentUserId = channel.client.state.currentUser?.id;
- if (channel.isDistinct && users.length == 2) {
+ if (channel.isOneToOne) {
final otherUser = users.firstWhere(
(u) => u.id != currentUserId,
orElse: () => users.first,
);
return StreamUserAvatar(
user: otherUser,
+ semanticsLabel: effectiveLabel,
size: _avatarSizeForAvatarGroupSize(effectiveSize),
// TODO: make this configurable when the online state is shown.
showOnlineIndicator: otherUser.online,
@@ -100,6 +113,7 @@ class StreamChannelAvatar extends StatelessWidget {
return StreamUserAvatarGroup(
size: effectiveSize,
+ semanticsLabel: effectiveLabel,
users: users.sortedBy((it) => it.id == currentUserId ? 1 : 0),
);
},
@@ -107,6 +121,15 @@ class StreamChannelAvatar extends StatelessWidget {
);
}
+ // Computes the default screen-reader label for the avatar based on the
+ // channel type. Returns "Group" for group channels; direct-message
+ // avatars stay silent so the counterpart's identity — announced by the
+ // row's title — isn't duplicated.
+ String? _defaultSemanticsLabel(BuildContext context) {
+ if (!channel.isGroup) return null;
+ return context.translations.accessibility.channelGroupLabel;
+ }
+
// Maps [StreamAvatarGroupSize] to corresponding [StreamAvatarSize].
//
// Used when displaying a single channel image avatar.
diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart
index 3be8c9a204..dbb74b1345 100644
--- a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart
+++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart
@@ -68,6 +68,7 @@ class StreamUserAvatar extends StatelessWidget {
required this.user,
this.showBorder = true,
this.showOnlineIndicator = true,
+ this.semanticsLabel,
});
/// The user whose avatar is displayed.
@@ -90,6 +91,12 @@ class StreamUserAvatar extends StatelessWidget {
/// [StreamAvatarSize.lg].
final StreamAvatarSize? size;
+ /// Screen-reader label for the avatar.
+ ///
+ /// When null (the default), the avatar is silent. When non-null, the
+ /// avatar is exposed as a labeled image node.
+ final String? semanticsLabel;
+
@override
Widget build(BuildContext context) {
final avatarTheme = context.streamAvatarTheme;
@@ -109,6 +116,7 @@ class StreamUserAvatar extends StatelessWidget {
showBorder: showBorder,
backgroundColor: effectiveBackgroundColor,
foregroundColor: effectiveForegroundColor,
+ semanticsLabel: semanticsLabel,
placeholder: (_) => _StreamUserAvatarPlaceholder(user: user, size: effectiveSize),
);
@@ -145,6 +153,12 @@ class StreamUserAvatar extends StatelessWidget {
// Displays user initials or a fallback person icon when no name is available.
// Shows full initials (up to 2 characters) for medium and larger sizes,
// and only the first initial for extra-small and small sizes.
+//
+// The initials are decorative — a visual identifier for sighted users, not
+// information a screen reader can act on. Wrapped in [ExcludeSemantics] so
+// consumers who leave [StreamUserAvatar.semanticsLabel] null get a silent
+// avatar by default; passing a `semanticsLabel` re-emits a labeled image
+// node via the base [StreamAvatar]'s semantics wrap.
class _StreamUserAvatarPlaceholder extends StatelessWidget {
const _StreamUserAvatarPlaceholder({
required this.user,
@@ -157,13 +171,16 @@ class _StreamUserAvatarPlaceholder extends StatelessWidget {
@override
Widget build(BuildContext context) {
final userInitials = user.name.initials;
+ final Widget content;
if (userInitials != null && userInitials.isNotEmpty) {
- return switch (size) {
+ content = switch (size) {
.md || .lg || .xl || .xxl => Text(userInitials),
.xs || .sm => Text(userInitials.characters.first),
};
+ } else {
+ content = Icon(context.streamIcons.user);
}
- return Icon(context.streamIcons.user);
+ return ExcludeSemantics(child: content);
}
}
diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_group.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_group.dart
index 7466d519a0..9f0890e00a 100644
--- a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_group.dart
+++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_group.dart
@@ -55,6 +55,7 @@ class StreamUserAvatarGroup extends StatelessWidget {
super.key,
required this.users,
this.size,
+ this.semanticsLabel,
});
/// The list of users whose avatars are displayed.
@@ -65,10 +66,18 @@ class StreamUserAvatarGroup extends StatelessWidget {
/// If null, defaults to [StreamAvatarGroupSize.lg].
final StreamAvatarGroupSize? size;
+ /// Screen-reader label for the avatar group.
+ ///
+ /// When null (the default), each child avatar carries its own
+ /// [StreamUserAvatar.semanticsLabel]. When non-null, the group is exposed
+ /// as a single labeled image node.
+ final String? semanticsLabel;
+
@override
Widget build(BuildContext context) {
return StreamAvatarGroup(
size: size,
+ semanticsLabel: semanticsLabel,
children: users.map(
(user) => StreamUserAvatar(
user: user,
diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_stack.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_stack.dart
index c7b91f210a..2f2a80219c 100644
--- a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_stack.dart
+++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_stack.dart
@@ -71,6 +71,7 @@ class StreamUserAvatarStack extends StatelessWidget {
this.size,
this.overlap = 0.33,
this.max = 5,
+ this.semanticsLabel,
}) : assert(max >= 2, 'max must be at least 2');
/// The list of users whose avatars are displayed.
@@ -96,12 +97,20 @@ class StreamUserAvatarStack extends StatelessWidget {
/// Must be at least 2. Defaults to 5.
final int max;
+ /// Screen-reader label for the avatar stack.
+ ///
+ /// When null (the default), each child avatar carries its own
+ /// [StreamUserAvatar.semanticsLabel]. When non-null, the stack is exposed
+ /// as a single labeled image node.
+ final String? semanticsLabel;
+
@override
Widget build(BuildContext context) {
return StreamAvatarStack(
max: max,
size: size,
overlap: overlap,
+ semanticsLabel: semanticsLabel,
children: users.map(
(user) => StreamUserAvatar(
user: user,
diff --git a/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart b/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart
index 18f09227ee..dc59abe07a 100644
--- a/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart
+++ b/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart
@@ -30,12 +30,14 @@ class StreamSendingIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = context.streamColorScheme;
+ final a11y = context.translations.accessibility;
if (isMessageRead) {
return Icon(
context.streamIcons.checks,
size: size,
color: colorScheme.accentPrimary,
+ semanticLabel: a11y.messageReadStatusLabel,
);
}
@@ -44,6 +46,7 @@ class StreamSendingIndicator extends StatelessWidget {
context.streamIcons.checks,
size: size,
color: colorScheme.textSecondary,
+ semanticLabel: a11y.messageDeliveredStatusLabel,
);
}
@@ -52,6 +55,7 @@ class StreamSendingIndicator extends StatelessWidget {
context.streamIcons.checkmark,
size: size,
color: colorScheme.textSecondary,
+ semanticLabel: a11y.messageSentStatusLabel,
);
}
@@ -60,6 +64,7 @@ class StreamSendingIndicator extends StatelessWidget {
context.streamIcons.clock,
size: size,
color: colorScheme.textSecondary,
+ semanticLabel: a11y.messageSendingStatusLabel,
);
}
diff --git a/packages/stream_chat_flutter/lib/src/localization/accessibility_translations.dart b/packages/stream_chat_flutter/lib/src/localization/accessibility_translations.dart
index 2069985bfa..02085f6504 100644
--- a/packages/stream_chat_flutter/lib/src/localization/accessibility_translations.dart
+++ b/packages/stream_chat_flutter/lib/src/localization/accessibility_translations.dart
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:jiffy/jiffy.dart';
import 'package:stream_chat_flutter/src/localization/translations.dart';
+import 'package:stream_chat_flutter/src/utils/date_formatter.dart';
/// Defines the accessibility (a11y) resource values used by the Stream Chat
/// widgets.
@@ -158,6 +159,71 @@ abstract class AccessibilityTranslations {
/// deselected on tap.
String get deselectMediaTapHint;
+ /// The screen-reader prefix for own last-message previews, e.g. `"You"`.
+ ///
+ /// Combined with the message body as `"You\n2 photos"`.
+ String get outgoingMessagePreviewLabel;
+
+ /// The screen-reader prefix for group-channel last-message previews from
+ /// another user.
+ ///
+ /// Combined with the body as `"Alice\n2 photos"`, or `"Message\n2 photos"`
+ /// when [senderName] is null.
+ String incomingMessagePreviewLabel({String? senderName});
+
+ /// The screen-reader type label for a poll last-message preview, e.g.
+ /// `"Poll"`.
+ ///
+ /// Prepended to the question so `"ee"` becomes `"Poll\nee"`.
+ String get pollPreviewLabel;
+
+ /// The screen-reader prefix for draft last-message previews, e.g.
+ /// `"Draft"`.
+ ///
+ /// Combined with the draft body as `"Draft\nReply in progress"`.
+ String get draftPreviewLabel;
+
+ /// The screen-reader label for a message-delivery indicator while the
+ /// message is sending, e.g. `"Sending"`.
+ String get messageSendingStatusLabel;
+
+ /// The screen-reader label for a message-delivery indicator once the
+ /// message has been server-acknowledged but not delivered, e.g. `"Sent"`.
+ String get messageSentStatusLabel;
+
+ /// The screen-reader label for a message-delivery indicator once the
+ /// message has been delivered but not read, e.g. `"Delivered"`.
+ String get messageDeliveredStatusLabel;
+
+ /// The screen-reader label for a message-delivery indicator once the
+ /// message has been read by the recipient, e.g. `"Read"`.
+ String get messageReadStatusLabel;
+
+ /// The screen-reader phrasing for a batch of unread messages, e.g.
+ /// `"9 unread messages"` / `"1 unread message"`.
+ ///
+ /// Only invoked when `count > 0`.
+ String unreadMessagesLabel({required int count});
+
+ /// The screen-reader label for the avatar on a group channel, e.g.
+ /// `"Group"`.
+ String get channelGroupLabel;
+
+ /// The screen-reader prefix for system / channel-event last-message
+ /// previews, e.g. `"System"`.
+ ///
+ /// Combined with the event text as `"System\nAlice was added to the
+ /// channel"`.
+ String get systemMessagePreviewLabel;
+
+ /// The screen-reader label for the muted-channel indicator on a channel
+ /// list row.
+ String get channelMutedLabel;
+
+ /// The screen-reader label for the pinned-channel indicator on a channel
+ /// list row.
+ String get channelPinnedLabel;
+
/// The tooltip for the save-poll button in a poll creator.
String get savePollTooltip;
@@ -246,13 +312,17 @@ abstract class AccessibilityTranslations {
/// 10:30 AM' in United States English.
String formatDateTime(DateTime dateTime);
- /// Formats `duration` as a spelled-out natural-language string suitable
- /// for screen-reader announcements, e.g. '1 minute 23 seconds' in
- /// United States English.
+ /// Formats `date` as a locale-aware bucketed natural-language timestamp
+ /// for screen-reader announcements, e.g. `"Just now"`, `"Today at 2:30 PM"`,
+ /// `"Yesterday at 2:30 PM"`, `"Saturday at 2:30 PM"`, `"Jan 15 at 2:30 PM"`,
+ /// or `"Jan 15, 2024 at 2:30 PM"` for older dates.
///
- /// Colon-delimited clock strings ('01:23') are read inconsistently by
- /// TalkBack and VoiceOver; spelled-out units are read unambiguously and
- /// match how a sighted user would say the duration aloud.
+ /// Ordinals and compact abbreviations (`"15th"`, `"1m"`) are avoided —
+ /// some TTS engines mispronounce them.
+ String formatRecentDateTime(DateTime date);
+
+ /// Formats `duration` as a spelled-out natural-language string for
+ /// screen-reader announcements, e.g. `"1 minute 23 seconds"`.
String formatDuration(Duration duration);
}
@@ -264,7 +334,7 @@ abstract class AccessibilityTranslations {
/// * [Translations.accessibility], which returns an instance of this
/// class when no [StreamChatLocalizations] delegate is registered.
class DefaultAccessibilityTranslations extends AccessibilityTranslations {
- /// Creates a default English [AccessibilityTranslations] impl.
+ /// Creates a default English [AccessibilityTranslations] implementation.
const DefaultAccessibilityTranslations({super.localeName = 'en'});
@override
@@ -310,7 +380,7 @@ class DefaultAccessibilityTranslations extends AccessibilityTranslations {
}
@override
- String get attachmentPickerTooltip => 'Open attachment picker';
+ String get attachmentPickerTooltip => 'Toggle attachment picker';
@override
String get attachmentPickerOpenHint => 'double tap to open attachment picker';
@@ -391,6 +461,54 @@ class DefaultAccessibilityTranslations extends AccessibilityTranslations {
@override
String get deselectMediaTapHint => 'deselect';
+ @override
+ String get outgoingMessagePreviewLabel => 'You';
+
+ @override
+ String incomingMessagePreviewLabel({String? senderName}) {
+ return senderName ?? 'Message';
+ }
+
+ @override
+ String get pollPreviewLabel => 'Poll';
+
+ @override
+ String get draftPreviewLabel => 'Draft';
+
+ @override
+ String get messageSendingStatusLabel => 'Sending';
+
+ @override
+ String get messageSentStatusLabel => 'Sent';
+
+ @override
+ String get messageDeliveredStatusLabel => 'Delivered';
+
+ @override
+ String get messageReadStatusLabel => 'Read';
+
+ @override
+ String unreadMessagesLabel({required int count}) {
+ return Intl.plural(
+ count,
+ one: '$count unread message',
+ other: '$count unread messages',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String get channelGroupLabel => 'Group';
+
+ @override
+ String get systemMessagePreviewLabel => 'System';
+
+ @override
+ String get channelMutedLabel => 'muted';
+
+ @override
+ String get channelPinnedLabel => 'pinned';
+
@override
String get savePollTooltip => 'Save poll';
@@ -454,14 +572,22 @@ class DefaultAccessibilityTranslations extends AccessibilityTranslations {
@override
String attachmentsAddedAnnouncement({required int count}) {
- if (count == 1) return '1 attachment added';
- return '$count attachments added';
+ return Intl.plural(
+ count,
+ one: '$count attachment added',
+ other: '$count attachments added',
+ locale: localeName,
+ );
}
@override
String attachmentsRemovedAnnouncement({required int count}) {
- if (count == 1) return '1 attachment removed';
- return '$count attachments removed';
+ return Intl.plural(
+ count,
+ one: '$count attachment removed',
+ other: '$count attachments removed',
+ locale: localeName,
+ );
}
@override
@@ -470,6 +596,23 @@ class DefaultAccessibilityTranslations extends AccessibilityTranslations {
return '${jiffy.EEEE}, ${jiffy.yMMMMd}, ${jiffy.jm}';
}
+ @override
+ String formatRecentDateTime(DateTime date) {
+ if (date.isWithinLastMinute) return 'Just now';
+
+ final localDate = date.toLocal();
+ final jiffyDate = Jiffy.parseFromDateTime(localDate);
+ final time = jiffyDate.jm;
+
+ if (localDate.isToday) return 'Today at $time';
+ if (localDate.isYesterday) return 'Yesterday at $time';
+ if (localDate.isWithinLastWeek) return '${jiffyDate.EEEE} at $time';
+ if (localDate.isInSameYear) {
+ return '${jiffyDate.format(pattern: 'MMM d')} at $time';
+ }
+ return '${jiffyDate.format(pattern: 'MMM d, yyyy')} at $time';
+ }
+
@override
String formatDuration(Duration duration) {
final hours = duration.inHours;
diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart
index 74f8736128..34e9c2e2ab 100644
--- a/packages/stream_chat_flutter/lib/src/localization/translations.dart
+++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart
@@ -1490,7 +1490,7 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments
String get emptyMessagePreviewText => '';
@override
- String get voiceRecordingText => 'Voice Recording';
+ String get voiceRecordingText => 'Voice recording';
@override
String get audioAttachmentText => 'Audio';
diff --git a/packages/stream_chat_flutter/lib/src/misc/timestamp.dart b/packages/stream_chat_flutter/lib/src/misc/timestamp.dart
index c9739a28bb..d7e43a6732 100644
--- a/packages/stream_chat_flutter/lib/src/misc/timestamp.dart
+++ b/packages/stream_chat_flutter/lib/src/misc/timestamp.dart
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
-import 'package:stream_chat_flutter/src/utils/date_formatter.dart';
+import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamTimestamp}
/// Represents a timestamp, that's used primarily for showing the time of a
@@ -16,6 +16,7 @@ class StreamTimestamp extends StatelessWidget {
this.style,
this.textAlign,
this.textDirection,
+ this.semanticsLabel,
}) : formatter = formatter ?? formatDate;
/// The date to show in the timestamp.
@@ -33,8 +34,20 @@ class StreamTimestamp extends StatelessWidget {
/// The direction of the text.
final TextDirection? textDirection;
+ /// Screen-reader label for the timestamp.
+ ///
+ /// When null (the default), a bucketed natural-language phrasing is used
+ /// via [AccessibilityTranslations.formatRecentDateTime], so screen readers
+ /// get a clear time reference even when the visible text is abbreviated
+ /// (`"2h"`, `"Sat"`, `"1/15"`). When non-null, the given string is used
+ /// verbatim.
+ final String? semanticsLabel;
+
@override
Widget build(BuildContext context) {
+ final a11y = context.translations.accessibility;
+ final semanticsLabel = this.semanticsLabel ?? a11y.formatRecentDateTime(date);
+
return Text(
formatter(context, date),
maxLines: 1,
@@ -42,6 +55,7 @@ class StreamTimestamp extends StatelessWidget {
textAlign: textAlign,
textDirection: textDirection,
overflow: TextOverflow.ellipsis,
+ semanticsLabel: semanticsLabel,
);
}
}
diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart
index 3bab543055..0b60b4709c 100644
--- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart
+++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart
@@ -248,6 +248,7 @@ class StreamChannelListTile extends StatelessWidget {
Widget build(BuildContext context) {
final icons = context.streamIcons;
final spacing = context.streamSpacing;
+ final a11y = context.translations.accessibility;
final channelListItemTheme = StreamChannelListItemTheme.of(context);
final defaults = _StreamChannelListItemThemeDefaults(context);
@@ -258,8 +259,8 @@ class StreamChannelListTile extends StatelessWidget {
final effectiveAttributePosition = channelListItemTheme.attributePosition ?? defaults.attributePosition;
final channelAttributes = [
- if (isMuted) Icon(icons.mute),
- if (isPinned) Icon(icons.pin),
+ if (isMuted) Icon(icons.mute, semanticLabel: a11y.channelMutedLabel),
+ if (isPinned) Icon(icons.pin, semanticLabel: a11y.channelPinnedLabel),
];
Widget? attributesRow;
@@ -274,7 +275,7 @@ class StreamChannelListTile extends StatelessWidget {
final titleTrailing = effectiveAttributePosition == .inlineTitle ? attributesRow : null;
final subtitleTrailing = effectiveAttributePosition == .trailingBottom ? attributesRow : null;
- return Padding(
+ final listItem = Padding(
padding: EdgeInsets.all(spacing.xxs),
child: StreamListTileTheme(
data: StreamListTileThemeData(
@@ -318,6 +319,8 @@ class StreamChannelListTile extends StatelessWidget {
),
),
);
+
+ return MergeSemantics(child: listItem);
}
}
@@ -342,6 +345,7 @@ class _TitleRow extends StatelessWidget {
Widget build(BuildContext context) {
final spacing = context.streamSpacing;
final colorScheme = context.streamColorScheme;
+ final a11y = context.translations.accessibility;
return Row(
mainAxisSize: .min,
@@ -374,7 +378,11 @@ class _TitleRow extends StatelessWidget {
spacing: spacing.xs,
children: [
if (timestamp case final timestamp?) DefaultTextStyle.merge(style: timestampStyle, child: timestamp),
- if (unreadCount > 0) StreamBadgeNotification(label: '$unreadCount'),
+ if (unreadCount > 0)
+ StreamBadgeNotification(
+ label: '$unreadCount',
+ semanticLabel: a11y.unreadMessagesLabel(count: unreadCount),
+ ),
],
),
],
@@ -652,6 +660,8 @@ class _ChannelLastMessageWithStatusState extends State<_ChannelLastMessageWithSt
message: latestLastMessage,
);
}
+
+ deliveryPrefix = ExcludeSemantics(child: deliveryPrefix);
}
return Row(
diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart
index 89dbf8140a..e4c60d6cc8 100644
--- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart
+++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart
@@ -105,7 +105,7 @@ class _DefaultStreamThreadListTile extends StatelessWidget {
channel?.formatName(currentUser: currentUser) ?? avatarUser?.name ?? context.translations.noTitleText;
final participantUsers = thread.threadParticipants.map((it) => it.user).nonNulls.toList(growable: false);
- return Padding(
+ final listItem = Padding(
padding: EdgeInsets.all(spacing.xxs),
child: StreamListTileTheme(
data: StreamListTileThemeData(
@@ -172,6 +172,8 @@ class _DefaultStreamThreadListTile extends StatelessWidget {
),
),
);
+
+ return MergeSemantics(child: listItem);
}
}
@@ -283,12 +285,16 @@ class ThreadUnreadCount extends StatelessWidget {
@override
Widget build(BuildContext context) {
+ final a11y = context.translations.accessibility;
return Badge(
textStyle: style,
textColor: style?.color,
backgroundColor: backgroundColor,
largeSize: 20,
- label: Text('$unreadCount'),
+ label: Text(
+ '$unreadCount',
+ semanticsLabel: a11y.unreadMessagesLabel(count: unreadCount),
+ ),
);
}
}
diff --git a/packages/stream_chat_flutter/lib/src/utils/date_formatter.dart b/packages/stream_chat_flutter/lib/src/utils/date_formatter.dart
index e50261ee2d..f27fe173ba 100644
--- a/packages/stream_chat_flutter/lib/src/utils/date_formatter.dart
+++ b/packages/stream_chat_flutter/lib/src/utils/date_formatter.dart
@@ -26,16 +26,16 @@ String formatDate(BuildContext context, DateTime date) {
///
/// Output examples:
/// - `Just now`
-/// - `Today at 9:41`
-/// - `Yesterday at 9:41`
-/// - `Saturday at 9:41`
-/// - `Jan 1st at 9:41`
+/// - `Today at 9:41 AM`
+/// - `Yesterday at 9:41 AM`
+/// - `Saturday at 9:41 AM`
+/// - `Jan 1st at 9:41 AM`
String formatRecentDateTime(BuildContext context, DateTime date) {
if (date.isWithinLastMinute) return context.translations.justNowLabel;
final localDate = date.toLocal();
final jiffyDate = Jiffy.parseFromDateTime(localDate);
- final time = jiffyDate.format(pattern: 'H:mm');
+ final time = jiffyDate.jm;
if (localDate.isToday) return '${context.translations.todayLabel} at $time';
if (localDate.isYesterday) return '${context.translations.yesterdayLabel} at $time';
diff --git a/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart b/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart
index bc52b3f986..0dccaf31cd 100644
--- a/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart
+++ b/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart
@@ -1,4 +1,5 @@
import 'package:flutter/widgets.dart';
+import 'package:stream_chat_flutter/src/localization/translations.dart';
import 'package:stream_chat_flutter/src/utils/extensions.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_core_flutter/chat.dart';
@@ -53,6 +54,8 @@ import 'package:stream_core_flutter/chat.dart';
/// See also:
///
/// * [StreamMessagePreviewFormatter], the default implementation.
+/// * [AccessibleMessagePreviewFormatter], the optional a11y-aware
+/// extension of this interface.
/// * [StreamChatConfigurationData.messagePreviewFormatter], which configures
/// the formatter used across the Stream Chat widget tree.
abstract interface class MessagePreviewFormatter {
@@ -65,8 +68,7 @@ abstract interface class MessagePreviewFormatter {
///
/// The output adapts to the message kind (regular, deleted, system, poll,
/// location) and to the surrounding [channel] — for example, group channels
- /// prepend a bold "You:" or "FirstName:" prefix. Mentions in the message
- /// text are automatically bolded.
+ /// prepend a bold "You:" or "FirstName:" prefix.
///
/// [showCaption] controls how attachment and location previews label
/// themselves: when `true` (the default) they display the message text; when
@@ -74,10 +76,10 @@ abstract interface class MessagePreviewFormatter {
/// type-based label such as "Photo", "2 Videos", or "Location". Pure text
/// messages always show their text.
///
- /// The returned span carries structural style only (bold mentions, bold
- /// sender prefix, tinted deleted-message text). Base text color and font
- /// should be applied by the caller via [Text.rich]'s `style` parameter or
- /// an ambient [DefaultTextStyle]; inline icons pick up their color from the
+ /// The returned span carries structural style only (bold sender prefix,
+ /// tinted deleted-message text). Base text color and font should be
+ /// applied by the caller via [Text.rich]'s `style` parameter or an
+ /// ambient [DefaultTextStyle]; inline icons pick up their color from the
/// ambient [IconTheme].
TextSpan formatMessage(
BuildContext context,
@@ -92,8 +94,7 @@ abstract interface class MessagePreviewFormatter {
///
/// The prefix is rendered in bold using the theme's accent color. The
/// draft body is formatted using the same rules as a regular message —
- /// plain text, attachments, and polls all get a rich preview — with
- /// mentions bolded automatically.
+ /// plain text, attachments, and polls all get a rich preview.
///
/// [currentUser] is forwarded to body formatters that need viewer context
/// (for example, [formatPollMessage] to resolve the current user's vote).
@@ -114,7 +115,45 @@ abstract interface class MessagePreviewFormatter {
});
}
-/// The default implementation of [MessagePreviewFormatter].
+/// A [MessagePreviewFormatter] that also emits plain-text a11y labels
+/// suitable for [Text.semanticsLabel] / [Semantics.label].
+///
+/// Implementing this interface is optional; consumers implementing only
+/// [MessagePreviewFormatter] continue to work with a visual-derived a11y
+/// fallback.
+///
+/// See also:
+///
+/// * [StreamMessagePreviewFormatter], which implements this interface.
+abstract interface class AccessibleMessagePreviewFormatter implements MessagePreviewFormatter {
+ /// Formats [message] as a plain-text natural-language string for screen
+ /// readers — the accessible counterpart to [formatMessage].
+ ///
+ /// [showCaption] mirrors [formatMessage]: when `true` (the default),
+ /// attachment and location labels include the message text as a caption;
+ /// when `false` they fall back to a type-only label.
+ String formatMessageSemanticsLabel(
+ BuildContext context,
+ Message message, {
+ ChannelModel? channel,
+ User? currentUser,
+ bool showCaption = true,
+ });
+
+ /// Formats [draftMessage] as a plain-text natural-language string for
+ /// screen readers — the accessible counterpart to [formatDraftMessage].
+ ///
+ /// [showCaption] mirrors [formatDraftMessage].
+ String formatDraftMessageSemanticsLabel(
+ BuildContext context,
+ DraftMessage draftMessage, {
+ User? currentUser,
+ bool showCaption = true,
+ });
+}
+
+/// The default implementation of [MessagePreviewFormatter] and
+/// [AccessibleMessagePreviewFormatter].
///
/// The preview is assembled in two layers, each produced by a separate
/// `format*` method so subclasses can override a specific piece without
@@ -127,8 +166,8 @@ abstract interface class MessagePreviewFormatter {
/// [formatGroupMessage] ("FirstName: "), or [formatDirectMessage] (no
/// prefix by default).
///
-/// Mentions are applied to the body automatically and do not need to be
-/// handled in overrides.
+/// Each visual `format*` method has a paired `format*SemanticsLabel`
+/// variant that returns the equivalent screen-reader label.
///
/// {@tool snippet}
///
@@ -161,7 +200,7 @@ abstract interface class MessagePreviewFormatter {
///
/// * [MessagePreviewFormatter], the public interface.
/// * [formatMessage], which composes the final preview span.
-class StreamMessagePreviewFormatter implements MessagePreviewFormatter {
+class StreamMessagePreviewFormatter implements AccessibleMessagePreviewFormatter {
/// Creates a [StreamMessagePreviewFormatter].
const StreamMessagePreviewFormatter();
@@ -387,7 +426,7 @@ class StreamMessagePreviewFormatter implements MessagePreviewFormatter {
/// * Multiple same-type attachments → type-specific icon + pluralized
/// count ("2 Photos", "3 Videos", "4 files").
/// * Mixed-type attachments → file icon + "N files".
- /// * Voice recording → voice icon + "Voice Recording (mm:ss)" when no
+ /// * Voice recording → voice icon + "Voice recording (mm:ss)" when no
/// caption is available.
/// * Giphy → file icon + caption, or "Giphy" when no caption is set.
/// * Unknown / custom types → unsupported-attachment icon + caption (if
@@ -492,6 +531,194 @@ class StreamMessagePreviewFormatter implements MessagePreviewFormatter {
String getDraftPrefix(BuildContext context) {
return '${context.translations.draftLabel}:';
}
+
+ @override
+ String formatMessageSemanticsLabel(
+ BuildContext context,
+ Message message, {
+ ChannelModel? channel,
+ User? currentUser,
+ bool showCaption = true,
+ }) {
+ final body = _formatContentSemanticsLabel(
+ context,
+ message,
+ currentUser: currentUser,
+ showCaption: showCaption,
+ );
+
+ // Deleted and system messages describe state / channel events, not
+ // something the sender authored — the speaker prefix would read as
+ // "You, Message deleted", which sounds like the user said the literal
+ // phrase "Message deleted". Return the body verbatim in these cases.
+ if (message.isDeleted || message.isSystem) return body;
+
+ if (channel == null) return body;
+
+ final a11y = context.translations.accessibility;
+
+ if (message.user?.id == currentUser?.id) {
+ return '${a11y.outgoingMessagePreviewLabel}\n$body';
+ }
+
+ // 1-on-1 channel — sender identity is implicit from the channel.
+ if (channel.memberCount <= 2) return body;
+
+ // Announce the full sender name — screen readers aren't width-bound
+ // like the visual preview, and a surname disambiguates when multiple
+ // members share a first name. When the sender has no derivable name,
+ // skip the prefix entirely — a bare "Message, ..." token carries no
+ // information.
+ final authorName = message.user?.name.trim().nullIfEmpty;
+ if (authorName == null) return body;
+
+ return '${a11y.incomingMessagePreviewLabel(senderName: authorName)}\n$body';
+ }
+
+ // Dispatches to the `format*SemanticsLabel` method that matches the kind of
+ // [message]. Mirrors [_formatContent] but returns a plain-text a11y label
+ // instead of a visual TextSpan.
+ String _formatContentSemanticsLabel(
+ BuildContext context,
+ Message message, {
+ User? currentUser,
+ bool showCaption = true,
+ }) {
+ if (message.isDeleted) {
+ return formatDeletedMessageSemanticsLabel(context, message);
+ }
+
+ if (message.isSystem) {
+ return formatSystemMessageSemanticsLabel(context, message);
+ }
+
+ if (message.poll case final poll?) {
+ return formatPollMessageSemanticsLabel(context, poll, currentUser);
+ }
+
+ if (message.sharedLocation case final location?) {
+ return formatLocationMessageSemanticsLabel(context, message, location, showCaption: showCaption);
+ }
+
+ final regular = formatRegularMessageSemanticsLabel(context, message, showCaption: showCaption);
+ if (regular != null) return regular;
+
+ return formatEmptyMessageSemanticsLabel(context, message);
+ }
+
+ /// Formats a regular [message] — plain text, attachments, or both — as a
+ /// plain-text a11y label. Returns `null` when the message has neither
+ /// text nor attachments.
+ ///
+ /// [showCaption] gates whether the message text is used as an attachment
+ /// caption; set to `false` for tight contexts (e.g. quoted replies) so
+ /// the label falls back to a type-only fragment.
+ @protected
+ String? formatRegularMessageSemanticsLabel(
+ BuildContext context,
+ Message message, {
+ bool showCaption = true,
+ }) {
+ final messageText = message.text?.trim().nullIfEmpty;
+ final attachments = message.attachments;
+
+ if (attachments.isNotEmpty) {
+ final caption = showCaption ? messageText : null;
+ return formatMessageAttachmentsSemanticsLabel(context, caption, attachments);
+ }
+
+ return messageText;
+ }
+
+ /// Formats a deleted [message] as a plain-text a11y label — the localized
+ /// "Message deleted" text.
+ @protected
+ String formatDeletedMessageSemanticsLabel(BuildContext context, Message message) {
+ return context.translations.messageDeletedLabel;
+ }
+
+ /// Formats a system [message] as a plain-text a11y label —
+ /// `"System\n
"`.
+ ///
+ /// Falls back to the localized `"System message"` label when the event
+ /// text is missing.
+ @protected
+ String formatSystemMessageSemanticsLabel(BuildContext context, Message message) {
+ final body = message.text?.trim().nullIfEmpty;
+ if (body == null) return context.translations.systemMessageLabel;
+ final a11y = context.translations.accessibility;
+ return '${a11y.systemMessagePreviewLabel}\n$body';
+ }
+
+ /// Formats an empty [message] as a plain-text a11y label — the localized
+ /// "no content" fallback.
+ @protected
+ String formatEmptyMessageSemanticsLabel(BuildContext context, Message message) {
+ return context.translations.emptyMessagePreviewText;
+ }
+
+ /// Formats a [poll] message as a plain-text a11y label — `"Poll\n"`.
+ @protected
+ String formatPollMessageSemanticsLabel(BuildContext context, Poll poll, User? currentUser) {
+ final a11y = context.translations.accessibility;
+ final name = poll.name.trim();
+ if (name.isEmpty) return a11y.pollPreviewLabel;
+ return '${a11y.pollPreviewLabel}\n$name';
+ }
+
+ /// Formats a shared-[location] message as a plain-text a11y label — the
+ /// localized `"Location"` / `"Live location"` label, optionally suffixed
+ /// with the message text when a caption is present and [showCaption] is
+ /// `true`.
+ @protected
+ String formatLocationMessageSemanticsLabel(
+ BuildContext context,
+ Message message,
+ Location location, {
+ bool showCaption = true,
+ }) {
+ final typeLabel = context.translations.locationLabel(isLive: location.isLive);
+ final caption = showCaption ? message.text?.trim().nullIfEmpty : null;
+ if (caption == null) return typeLabel;
+ return '$typeLabel\n$caption';
+ }
+
+ /// Formats non-empty [attachments] as a plain-text a11y label —
+ /// `"[\n]"`.
+ ///
+ /// `typeLabel` is a translated, count-aware type label (`"Photo"`,
+ /// `"2 photos"`, `"Video"`, `"3 files"`, `"Voice recording"`, etc.);
+ /// mixed-type groups collapse to `"N files"`. `extra` is the caption or,
+ /// when absent, the attachment's intrinsic text — filename, URL-preview
+ /// title, or voice-recording duration.
+ @protected
+ String formatMessageAttachmentsSemanticsLabel(
+ BuildContext context,
+ String? caption,
+ Iterable attachments,
+ ) {
+ final preview = _resolveAttachmentSemanticsPreview(context, attachments, caption);
+ if (preview.extra == null) return preview.typeLabel;
+ return '${preview.typeLabel}\n${preview.extra}';
+ }
+
+ @override
+ String formatDraftMessageSemanticsLabel(
+ BuildContext context,
+ DraftMessage draftMessage, {
+ User? currentUser,
+ bool showCaption = true,
+ }) {
+ final body = _formatContentSemanticsLabel(
+ context,
+ draftMessage.toMessage(),
+ currentUser: currentUser,
+ showCaption: showCaption,
+ );
+
+ final a11y = context.translations.accessibility;
+ return '${a11y.draftPreviewLabel}\n$body';
+ }
}
// ---------------------------------------------------------------------------
@@ -701,6 +928,119 @@ _AttachmentPreview _resolveMultipleAttachmentsPreview(
};
}
+// ---------------------------------------------------------------------------
+// Attachment presentation (a11y)
+//
+// Mirror of the visual attachment resolvers above, producing a plain-text
+// `(typeLabel, extra)` pair used by [formatMessageAttachmentsSemanticsLabel]:
+//
+// * [_resolveAttachmentSemanticsPreview] — dispatcher.
+// * [_resolveSingleAttachmentSemanticsPreview] — one attachment; adds
+// the attachment-intrinsic
+// extra (filename, link
+// title, voice duration)
+// when no caption exists.
+// * [_resolveMultipleAttachmentsSemanticsPreview] — 2+ attachments; a
+// type-only label plus
+// the caption when set.
+// ---------------------------------------------------------------------------
+
+// A resolved a11y attachment preview: the localized [typeLabel] ("Photo",
+// "2 photos", "Voice recording", ...) and an optional [extra] appended
+// after a newline (caption, filename, or spelled-out duration).
+typedef _AttachmentSemanticsPreview = ({String typeLabel, String? extra});
+
+// Resolves the type label + extra to announce for a message with
+// [attachments], optionally falling back to [caption] when provided.
+//
+// Thin dispatcher that picks between the single- and multi-attachment
+// resolvers. Assumes [attachments] is non-empty.
+_AttachmentSemanticsPreview _resolveAttachmentSemanticsPreview(
+ BuildContext context,
+ Iterable attachments,
+ String? caption,
+) {
+ if (attachments.length > 1) {
+ return _resolveMultipleAttachmentsSemanticsPreview(context, attachments, caption);
+ }
+ return _resolveSingleAttachmentSemanticsPreview(context, attachments.first, caption);
+}
+
+// Resolves the a11y preview for a message with a single [attachment].
+//
+// The type label is always emitted so screen-reader users hear the kind of
+// attachment ("Photo", "File", "Voice recording", ...) even when a caption
+// is present. The [extra] slot prefers [caption], then falls back to
+// attachment-intrinsic text (filename, OG title, spelled-out voice
+// duration) that would otherwise be lost.
+_AttachmentSemanticsPreview _resolveSingleAttachmentSemanticsPreview(
+ BuildContext context,
+ Attachment attachment,
+ String? caption,
+) {
+ final translations = context.translations;
+ final a11y = translations.accessibility;
+ final typeLabel = _semanticsTypeLabelFor(translations, attachment.type, 1);
+
+ final extra =
+ caption ??
+ switch (attachment.type) {
+ // Spell the voice-recording duration out ("1 minute 23 seconds") rather
+ // than a colon-separated clock string ("1:23") — screen readers announce
+ // ":" inconsistently.
+ AttachmentType.voiceRecording => a11y.formatDuration(attachment.duration),
+ AttachmentType.file || AttachmentType.urlPreview => attachment.title?.trim().nullIfEmpty,
+ _ => null,
+ };
+
+ return (typeLabel: typeLabel, extra: extra);
+}
+
+// Resolves the a11y preview for a message with two or more [attachments].
+//
+// Same-type groups get a pluralized count via
+// [Translations.photosAttachmentCountText] / etc.; mixed-type groups
+// collapse to a generic "N files" label. Attachment-intrinsic text
+// (filename, duration) is not appended for multi-attachment groups — with
+// no caption the group is announced as type-only.
+//
+// Assumes [attachments] has at least two entries.
+_AttachmentSemanticsPreview _resolveMultipleAttachmentsSemanticsPreview(
+ BuildContext context,
+ Iterable attachments,
+ String? caption,
+) {
+ final translations = context.translations;
+
+ final first = attachments.first;
+ final count = attachments.length;
+
+ final hasMixedTypes = attachments.any((it) => it.type != first.type);
+ if (hasMixedTypes) return (typeLabel: translations.filesAttachmentCountText(count), extra: caption);
+
+ return (typeLabel: _semanticsTypeLabelFor(translations, first.type, count), extra: caption);
+}
+
+// Returns the localized, count-aware type label for the given attachment
+// [type] — used by both single- and multi-attachment a11y resolvers.
+//
+// Types with a plural form (image / video / file) honor [count]; others
+// return their singular label regardless.
+String _semanticsTypeLabelFor(Translations t, AttachmentType? type, int count) {
+ return switch (type) {
+ AttachmentType.image => t.photosAttachmentCountText(count),
+ AttachmentType.video => t.videosAttachmentCountText(count),
+ AttachmentType.file => t.filesAttachmentCountText(count),
+ AttachmentType.audio => t.audioAttachmentText,
+ AttachmentType.voiceRecording => t.voiceRecordingText,
+ AttachmentType.urlPreview => t.linkAttachmentText,
+ // Giphy has no dedicated translation — the visual formatter uses the
+ // literal 'Giphy' brand name.
+ AttachmentType.giphy => 'Giphy',
+ _ => t.filesAttachmentCountText(count),
+ };
+}
+
// Small [String] helpers used by the formatter.
extension on String {
// Returns the string, or `null` when it is empty. Lets call sites collapse
diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml
index 84512aaf2f..f304c120f9 100644
--- a/packages/stream_chat_flutter/pubspec.yaml
+++ b/packages/stream_chat_flutter/pubspec.yaml
@@ -55,7 +55,19 @@ dependencies:
share_plus: ">=12.0.2 <14.0.0"
shimmer: ^3.0.0
stream_chat_flutter_core: ^10.2.0
- stream_core_flutter: ^0.4.1
+ stream_core_flutter:
+ # The ignore below silences `invalid_dependency` because we occasionally
+ # pin stream_core_flutter to a git ref to iterate on it alongside this
+ # SDK between its releases.
+ #
+ # **Note:** Before publishing stream_chat_flutter, this MUST be swapped
+ # back to a pub version constraint — git deps are not allowed on pub.dev
+ # and will block the release.
+ # ignore: invalid_dependency
+ git:
+ url: https://github.com/GetStream/stream-core-flutter.git
+ ref: 5b5c51ad270ef9ff21efa9eb20e490f19e40ac87
+ path: packages/stream_core_flutter
stream_thumbnail: ^0.1.0
svg_icon_widget: ^0.0.1
synchronized: ^3.4.0
diff --git a/packages/stream_chat_flutter/test/src/channel/stream_draft_message_preview_text_test.dart b/packages/stream_chat_flutter/test/src/channel/stream_draft_message_preview_text_test.dart
index 653c79629f..c622dc714f 100644
--- a/packages/stream_chat_flutter/test/src/channel/stream_draft_message_preview_text_test.dart
+++ b/packages/stream_chat_flutter/test/src/channel/stream_draft_message_preview_text_test.dart
@@ -52,6 +52,59 @@ void main() {
});
});
+ group('Accessibility label (formatDraftMessageSemanticsLabel)', () {
+ testWidgets('text draft — "Draft, {body}"', (tester) async {
+ final handle = tester.ensureSemantics();
+ final draftMessage = DraftMessage(text: 'Reply in progress');
+
+ await pumpDraftMessagePreview(tester, draftMessage);
+
+ expect(find.bySemanticsLabel('Draft\nReply in progress'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('attachment draft — "Draft, {type}, {caption}"', (tester) async {
+ final handle = tester.ensureSemantics();
+ final draftMessage = DraftMessage(
+ text: 'Beach day',
+ attachments: [Attachment(type: AttachmentType.image)],
+ );
+
+ await pumpDraftMessagePreview(tester, draftMessage);
+
+ expect(find.bySemanticsLabel('Draft\nPhoto\nBeach day'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('multi-attachment draft — "Draft, {count} {type}, {caption}"', (tester) async {
+ final handle = tester.ensureSemantics();
+ final draftMessage = DraftMessage(
+ text: 'Beach day',
+ attachments: [
+ Attachment(type: AttachmentType.image),
+ Attachment(type: AttachmentType.image),
+ ],
+ );
+
+ await pumpDraftMessagePreview(tester, draftMessage);
+
+ expect(find.bySemanticsLabel('Draft\n2 photos\nBeach day'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('poll draft — "Draft, Poll, {name}"', (tester) async {
+ final handle = tester.ensureSemantics();
+ final draftMessage = DraftMessage(
+ poll: Poll(id: 'p1', name: "What's for lunch?", options: const []),
+ );
+
+ await pumpDraftMessagePreview(tester, draftMessage);
+
+ expect(find.bySemanticsLabel("Draft\nPoll\nWhat's for lunch?"), findsOneWidget);
+ handle.dispose();
+ });
+ });
+
group('Custom MessagePreviewFormatter', () {
const customFormatter = _CustomMessagePreviewFormatter();
diff --git a/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart b/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart
index 630f8533d7..0b96c22f69 100644
--- a/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart
+++ b/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart
@@ -8,19 +8,6 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import '../mocks.dart';
void main() {
- late MockClient client;
- late MockClientState clientState;
- late OwnUser currentUser;
-
- setUp(() {
- client = MockClient();
- clientState = MockClientState();
- currentUser = OwnUser(id: 'test-user-id', name: 'Test User');
-
- when(() => client.state).thenReturn(clientState);
- when(() => clientState.currentUser).thenReturn(currentUser);
- });
-
Future pumpMessagePreview(
WidgetTester tester,
Message message, {
@@ -28,6 +15,7 @@ void main() {
TextStyle? textStyle,
ChannelModel? channel,
StreamChatConfigurationData? configData,
+ bool showCaption = true,
}) async {
final client = MockClient();
final clientState = MockClientState();
@@ -49,6 +37,7 @@ void main() {
language: language,
textStyle: textStyle,
channel: channel,
+ showCaption: showCaption,
),
),
),
@@ -326,7 +315,7 @@ void main() {
await pumpMessagePreview(tester, message);
expect(_findIcons(tester), hasLength(1));
- expect(_extractText(tester), contains('Voice Recording'));
+ expect(_extractText(tester), contains('Voice recording'));
expect(_extractText(tester), contains('00:00'));
});
@@ -345,7 +334,7 @@ void main() {
await pumpMessagePreview(tester, message);
expect(_findIcons(tester), hasLength(1));
- expect(_extractText(tester), contains('Voice Recording'));
+ expect(_extractText(tester), contains('Voice recording'));
expect(_extractText(tester), contains('02:05'));
});
@@ -1027,6 +1016,625 @@ void main() {
expect(find.text('💬 Hey there'), findsOneWidget);
});
});
+
+ group('Accessibility label (formatMessageSemanticsLabel)', () {
+ testWidgets('group channel — full sender name prefix for other users', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ text: 'Hello there',
+ user: User(id: 'other', name: 'Jane Doe'),
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('Jane Doe\nHello there'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('group channel — drops the prefix entirely when sender is unknown', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ // No `user` on the message — the formatter can't derive a sender
+ // name. Rather than emit a bare "Message" prefix that carries no
+ // information, drop the prefix and read the body alone.
+ final message = Message(text: 'Hello there');
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('Hello there'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('group channel — "You, body" for own messages', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ text: 'Hello everyone',
+ user: User(id: 'test-user-id', name: 'Test User'),
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nHello everyone'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('1-on-1 channel — no speaker prefix (sender is implicit) for other user', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ text: 'Hi',
+ user: User(id: 'other', name: 'Jane'),
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('Hi'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('1-on-1 channel — "You, body" for own messages', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ text: 'See you tomorrow',
+ user: User(id: 'test-user-id', name: 'Test User'),
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nSee you tomorrow'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('attachment message — icon placeholder is stripped from the label', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ user: User(id: 'other', name: 'Jane Doe'),
+ attachments: [
+ Attachment(type: 'image'),
+ Attachment(type: 'image'),
+ ],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ // Without `includePlaceholders: false`, the inline camera icon
+ // (WidgetSpan) would leak an object-replacement character into the
+ // label — screen readers would announce it as "object" or silence.
+ expect(find.bySemanticsLabel('Jane Doe\n2 photos'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('poll message — "Poll" type prefix restores context lost by the icon', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ user: User(id: 'test-user-id', name: 'Test User'),
+ poll: Poll(
+ id: 'p1',
+ name: "What's for lunch?",
+ options: const [],
+ ),
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel("You\nPoll\nWhat's for lunch?"), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('poll message from other user — "Poll" prefix included', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ user: User(id: 'other', name: 'Jane Doe'),
+ poll: Poll(id: 'p2', name: 'ee', options: const []),
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ // "Jane Doe, Poll, ee" — SR user now knows "ee" is a poll title from
+ // Jane, not a plain-text message that says "ee".
+ expect(find.bySemanticsLabel('Jane Doe\nPoll\nee'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('single image attachment — icon placeholder stripped', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [Attachment(type: 'image')],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nPhoto'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('single video attachment — icon placeholder stripped', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [Attachment(type: 'video')],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nVideo'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('file attachment with title — "File" type prefix restores context', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [Attachment(type: 'file', title: 'report.pdf')],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nFile\nreport.pdf'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('image with caption text — "Photo" type prefix + caption', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ text: 'Check this out',
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [Attachment(type: 'image')],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nPhoto\nCheck this out'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('multiple images with caption — pluralized type prefix + caption', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ text: 'Beach day',
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [
+ Attachment(type: 'image'),
+ Attachment(type: 'image'),
+ Attachment(type: 'image'),
+ ],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\n3 photos\nBeach day'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('video with caption — "Video" type prefix + caption', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ text: 'From the concert',
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [Attachment(type: 'video')],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nVideo\nFrom the concert'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('mixed-type attachments with caption — "N files" prefix + caption', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ text: 'Mixed bag',
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [
+ Attachment(type: 'image'),
+ Attachment(type: 'video'),
+ ],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\n2 files\nMixed bag'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('mixed-type attachments without caption — no prefix (fallback has type)', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [
+ Attachment(type: 'image'),
+ Attachment(type: 'video'),
+ ],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ // Fallback body is "2 files" — already includes the type word; no
+ // prefix needed. Avoids the "2 files, 2 files" redundancy.
+ expect(find.bySemanticsLabel('You\n2 files'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('voice recording — duration spelled out for screen readers', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [
+ Attachment(
+ type: 'voiceRecording',
+ extraData: const {'duration': 83.0},
+ ),
+ ],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ // "1 minute 23 seconds" — spelled out, not "1:23" which reads
+ // inconsistently across screen readers.
+ expect(
+ find.bySemanticsLabel('You\nVoice recording\n1 minute, 23 seconds'),
+ findsOneWidget,
+ );
+ handle.dispose();
+ });
+
+ testWidgets('shared location without caption — "Location" fallback only', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ user: User(id: 'test-user-id', name: 'Test User'),
+ sharedLocation: Location(latitude: 0, longitude: 0),
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nLocation'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('shared location with caption — "Location, {caption}"', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ text: 'Meeting spot',
+ user: User(id: 'test-user-id', name: 'Test User'),
+ sharedLocation: Location(latitude: 0, longitude: 0),
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nLocation\nMeeting spot'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('deleted message — no speaker prefix; deletion IS the content', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ text: 'gone',
+ type: 'deleted',
+ user: User(id: 'other', name: 'Jane Doe'),
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ // "Jane, Message deleted" would sound like Jane said the literal
+ // phrase "Message deleted". Return the deletion notice verbatim.
+ expect(find.bySemanticsLabel('Message deleted'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('system message — "System, " prefix disambiguates the event from an authored message', (
+ tester,
+ ) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ text: 'Alice was added to the channel',
+ type: 'system',
+ user: User(id: 'alice', name: 'Alice'),
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ // Without the "System" prefix a DM row titled "Alice" would read
+ // "Alice, ..., Alice was added to the channel" — sounding like
+ // Alice authored that sentence herself.
+ expect(
+ find.bySemanticsLabel('System\nAlice was added to the channel'),
+ findsOneWidget,
+ );
+ handle.dispose();
+ });
+
+ testWidgets('showCaption: false — attachment a11y drops the caption, mirroring the visual', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ text: 'Beach day',
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [
+ Attachment(type: AttachmentType.image),
+ Attachment(type: AttachmentType.image),
+ ],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel, showCaption: false);
+
+ // Visual falls back to "2 photos" (no caption); a11y must match so
+ // dense contexts like quoted-reply previews stay consistent.
+ expect(find.bySemanticsLabel('You\n2 photos'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('showCaption: false — location a11y drops the caption', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ text: 'Meet me here',
+ user: User(id: 'other', name: 'Alice'),
+ sharedLocation: Location(latitude: 0, longitude: 0),
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel, showCaption: false);
+
+ expect(find.bySemanticsLabel('Location'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('audio attachment — "Audio" type label, no caption', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [Attachment(type: AttachmentType.audio)],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nAudio'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('audio attachment with caption — "Audio, {caption}"', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ text: 'Track name',
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [Attachment(type: AttachmentType.audio)],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nAudio\nTrack name'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('url preview — "Link, {og title}" when caption absent', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [Attachment(type: AttachmentType.urlPreview, title: 'Article title')],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nLink\nArticle title'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('url preview with caption — caption wins over og title', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ text: 'Check this out',
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [Attachment(type: AttachmentType.urlPreview, title: 'Article title')],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nLink\nCheck this out'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('url preview without title or caption — bare "Link"', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [Attachment(type: AttachmentType.urlPreview)],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nLink'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('giphy attachment — "Giphy" fallback when caption absent', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [Attachment(type: AttachmentType.giphy)],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nGiphy'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('giphy attachment with caption — "Giphy, {caption}"', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ text: 'Reaction meme',
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [Attachment(type: AttachmentType.giphy)],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nGiphy\nReaction meme'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('voice recording with caption — caption wins over duration', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ text: 'Listen to this',
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [
+ Attachment(
+ type: AttachmentType.voiceRecording,
+ extraData: const {'duration': 83.0},
+ ),
+ ],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ // Caption preempts the spelled-out duration extra.
+ expect(find.bySemanticsLabel('You\nVoice recording\nListen to this'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('file without title or caption — bare "File"', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [Attachment(type: AttachmentType.file)],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nFile'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('file with caption — caption wins over title', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ text: 'Docs for review',
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [Attachment(type: AttachmentType.file, title: 'report.pdf')],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nFile\nDocs for review'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('live location — distinct "Live location" fallback', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ user: User(id: 'test-user-id', name: 'Test User'),
+ sharedLocation: Location(latitude: 0, longitude: 0, endAt: DateTime.now().add(const Duration(minutes: 5))),
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ // Localized label is title-cased in en — `Live Location` (matches
+ // the existing visible-side `locationLabel(isLive: true)` output).
+ expect(find.bySemanticsLabel('You\nLive Location'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('poll with empty name — bare "Poll" prefix, no body', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 3);
+ final message = Message(
+ user: User(id: 'test-user-id', name: 'Test User'),
+ poll: Poll(id: 'p1', name: '', options: const []),
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ expect(find.bySemanticsLabel('You\nPoll'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('system message without text — falls back to localized label', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ type: 'system',
+ user: User(id: 'alice', name: 'Alice'),
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel);
+
+ // No event body → fall back to the localized "System Message" label
+ // without the "System, " prefix (there's nothing to disambiguate).
+ expect(find.bySemanticsLabel('System Message'), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('showCaption: false on voice recording — falls back to duration', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ text: 'Listen to this',
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [
+ Attachment(
+ type: AttachmentType.voiceRecording,
+ extraData: const {'duration': 83.0},
+ ),
+ ],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel, showCaption: false);
+
+ // With caption suppressed, the intrinsic duration fills the "extra" slot.
+ expect(
+ find.bySemanticsLabel('You\nVoice recording\n1 minute, 23 seconds'),
+ findsOneWidget,
+ );
+ handle.dispose();
+ });
+
+ testWidgets('showCaption: false on file — falls back to filename', (tester) async {
+ final handle = tester.ensureSemantics();
+ final channel = ChannelModel(id: 'c', type: 'messaging', memberCount: 2);
+ final message = Message(
+ text: 'Please review',
+ user: User(id: 'test-user-id', name: 'Test User'),
+ attachments: [Attachment(type: AttachmentType.file, title: 'report.pdf')],
+ );
+
+ await pumpMessagePreview(tester, message, channel: channel, showCaption: false);
+
+ // With caption suppressed, the intrinsic filename fills the "extra" slot.
+ expect(find.bySemanticsLabel('You\nFile\nreport.pdf'), findsOneWidget);
+ handle.dispose();
+ });
+ });
}
// ---------------------------------------------------------------------------
diff --git a/packages/stream_chat_flutter/test/src/misc/timestamp_test.dart b/packages/stream_chat_flutter/test/src/misc/timestamp_test.dart
index e48ab1953b..be6e196b93 100644
--- a/packages/stream_chat_flutter/test/src/misc/timestamp_test.dart
+++ b/packages/stream_chat_flutter/test/src/misc/timestamp_test.dart
@@ -1,10 +1,54 @@
import 'package:alchemist/alchemist.dart';
+import 'package:clock/clock.dart';
import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter/src/misc/timestamp.dart';
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
import 'package:stream_core_flutter/chat.dart';
void main() {
+ group('StreamTimestamp a11y', () {
+ testWidgets('semanticsLabel: null (default) — bucketed natural-language phrasing', (tester) async {
+ final handle = tester.ensureSemantics();
+
+ // Pin "now" so the bucketing is deterministic — the date passed to the
+ // widget is 2 days before now, so it falls in the "within last week"
+ // bucket and announces as a weekday name.
+ await withClock(Clock.fixed(DateTime(2026, 6, 15, 12)), () async {
+ await tester.pumpWidget(
+ _wrapWithMaterialApp(
+ StreamTimestamp(date: DateTime(2026, 6, 13, 14, 30)),
+ ),
+ );
+ });
+
+ // The a11y label is bucketed via formatRecentDateTime — for a within-
+ // last-week date, the weekday name appears with a locale-aware time
+ // ("2:30 PM" in the default English locale).
+ expect(find.bySemanticsLabel(RegExp('at 2:30 PM')), findsOneWidget);
+ handle.dispose();
+ });
+
+ testWidgets('semanticsLabel explicit — overrides the default', (tester) async {
+ final handle = tester.ensureSemantics();
+
+ await tester.pumpWidget(
+ _wrapWithMaterialApp(
+ StreamTimestamp(
+ date: DateTime(2026, 1, 15, 10, 30),
+ semanticsLabel: 'sent 2 hours ago',
+ ),
+ ),
+ );
+
+ expect(find.bySemanticsLabel('sent 2 hours ago'), findsOneWidget);
+ // The default bucketed label is NOT applied when the override is set.
+ expect(find.bySemanticsLabel(RegExp('at 10:30')), findsNothing);
+
+ handle.dispose();
+ });
+ });
+
for (final brightness in Brightness.values) {
goldenTest(
'[${brightness.name}] -> StreamTimestamp looks fine',
diff --git a/packages/stream_chat_flutter/test/src/mocks.dart b/packages/stream_chat_flutter/test/src/mocks.dart
index 3c51630318..9b1ac43b9a 100644
--- a/packages/stream_chat_flutter/test/src/mocks.dart
+++ b/packages/stream_chat_flutter/test/src/mocks.dart
@@ -27,6 +27,8 @@ class MockChannel extends Mock implements Channel {
when(deleteDraft).thenAnswer((_) async => EmptyResponse());
when(() => deleteDraft(parentId: any(named: 'parentId'))).thenAnswer((_) async => EmptyResponse());
when(() => currentUserLastMessageAtStream).thenAnswer((_) => Stream.value(null));
+ when(() => isGroup).thenReturn(false);
+ when(() => isOneToOne).thenReturn(false);
}
@override
diff --git a/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart
index d1b20e1c03..074f266843 100644
--- a/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart
+++ b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart
@@ -342,4 +342,167 @@ void main() {
},
);
});
+
+ group('StreamChannelListTile a11y', () {
+ Widget wrap(Widget child) => MaterialApp(
+ theme: ThemeData(extensions: [StreamTheme()]),
+ home: Scaffold(body: child),
+ );
+
+ testWidgets('merges children into a single accessible node with the composed label', (tester) async {
+ final handle = tester.ensureSemantics();
+
+ await tester.pumpWidget(
+ wrap(
+ StreamChannelListTile(
+ avatar: const _SilentAvatar(),
+ title: const Text('General'),
+ subtitle: const Text('Alice: 2 photos'),
+ timestamp: const Text('2h'),
+ unreadCount: 2,
+ isMuted: true,
+ onTap: () {},
+ ),
+ ),
+ );
+
+ // A silent avatar (a DM avatar wraps itself in ExcludeSemantics) must
+ // contribute nothing to the merged label.
+ expect(find.bySemanticsLabel('AVATAR-SENTINEL'), findsNothing);
+
+ // The tile's composed label carries the name, muted state, unread
+ // count, preview, and timestamp — merged into one accessible node.
+ // MergeSemantics joins the child labels with newlines, so match with
+ // `dotAll: true` so `.` crosses the line breaks.
+ expect(
+ find.bySemanticsLabel(
+ RegExp(
+ 'General.*muted.*2h.*2 unread.*Alice: 2 photos',
+ dotAll: true,
+ ),
+ ),
+ findsOneWidget,
+ );
+
+ handle.dispose();
+ });
+
+ testWidgets('a labeled avatar contributes its label to the composed row announcement', (tester) async {
+ final handle = tester.ensureSemantics();
+
+ await tester.pumpWidget(
+ wrap(
+ StreamChannelListTile(
+ avatar: const _LabeledAvatar('Group'),
+ title: const Text('Team'),
+ subtitle: const Text('Alice: sup'),
+ timestamp: const Text('2h'),
+ onTap: () {},
+ ),
+ ),
+ );
+
+ // A group avatar's semantics label (emitted by StreamChannelAvatar for
+ // groups) is picked up by the row's MergeSemantics wrap and prepended
+ // to the announcement, restoring the "this is a group" context that
+ // the multi-avatar visual conveys to sighted users.
+ expect(
+ find.bySemanticsLabel(
+ RegExp('Group.*Team.*Alice: sup', dotAll: true),
+ ),
+ findsOneWidget,
+ );
+
+ handle.dispose();
+ });
+
+ testWidgets('unreadCount == 0 omits the "unread" state from the label', (tester) async {
+ final handle = tester.ensureSemantics();
+
+ await tester.pumpWidget(
+ wrap(
+ StreamChannelListTile(
+ avatar: const SizedBox.shrink(),
+ title: const Text('General'),
+ subtitle: const Text('Hi'),
+ timestamp: const Text('2h'),
+ onTap: () {},
+ ),
+ ),
+ );
+
+ expect(find.bySemanticsLabel(RegExp('General')), findsOneWidget);
+ expect(find.bySemanticsLabel(RegExp('unread')), findsNothing);
+
+ handle.dispose();
+ });
+
+ testWidgets('isPinned adds the "pinned" fragment to the merged label', (tester) async {
+ final handle = tester.ensureSemantics();
+
+ await tester.pumpWidget(
+ wrap(
+ StreamChannelListTile(
+ avatar: const _SilentAvatar(),
+ title: const Text('General'),
+ subtitle: const Text('Hi'),
+ timestamp: const Text('2h'),
+ isPinned: true,
+ onTap: () {},
+ ),
+ ),
+ );
+
+ expect(
+ find.bySemanticsLabel(RegExp('General.*pinned.*2h.*Hi', dotAll: true)),
+ findsOneWidget,
+ );
+ handle.dispose();
+ });
+
+ testWidgets('muted + pinned combined announce in order', (tester) async {
+ final handle = tester.ensureSemantics();
+
+ await tester.pumpWidget(
+ wrap(
+ StreamChannelListTile(
+ avatar: const _SilentAvatar(),
+ title: const Text('General'),
+ subtitle: const Text('Hi'),
+ timestamp: const Text('2h'),
+ isMuted: true,
+ isPinned: true,
+ onTap: () {},
+ ),
+ ),
+ );
+
+ expect(
+ find.bySemanticsLabel(RegExp('General.*muted.*pinned.*2h.*Hi', dotAll: true)),
+ findsOneWidget,
+ );
+ handle.dispose();
+ });
+ });
+}
+
+// Simulates a DM avatar — silent (wraps its sentinel text in
+// ExcludeSemantics). Keeps the a11y assertions independent of any real
+// avatar widget's internals.
+class _SilentAvatar extends StatelessWidget {
+ const _SilentAvatar();
+
+ @override
+ Widget build(BuildContext context) => const ExcludeSemantics(child: Text('AVATAR-SENTINEL'));
+}
+
+// Simulates a group avatar — participates in semantics with a label,
+// matching what StreamChannelAvatar emits for group channels.
+class _LabeledAvatar extends StatelessWidget {
+ const _LabeledAvatar(this.label);
+
+ final String label;
+
+ @override
+ Widget build(BuildContext context) => Semantics(label: label, child: const SizedBox.shrink());
}
diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png
index 85a20970c4..5c5981c0f8 100644
Binary files a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png and b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png differ
diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png
index f531ec7135..55b5a010ca 100644
Binary files a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png and b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png differ
diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart
index 45d55299f4..690e21f151 100644
--- a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart
+++ b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart
@@ -74,6 +74,69 @@ void main() {
);
}
+ group('StreamThreadListTile a11y', () {
+ testWidgets(
+ 'merges children into a single accessible node with the composed label',
+ (tester) async {
+ final handle = tester.ensureSemantics();
+
+ await tester.pumpWidget(
+ _wrapWithMaterialApp(
+ StreamThreadListTile(thread: thread, currentUser: user2),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ // The tile's composed label carries the channel name, unread
+ // badge, message preview, reply count, and timestamp — merged
+ // into one accessible node. MergeSemantics joins the child
+ // labels with newlines, so match with `dotAll: true` so `.`
+ // crosses breaks.
+ expect(
+ find.bySemanticsLabel(
+ RegExp(
+ 'Group ride.*3 unread messages.*group ride this Saturday.*1 reply',
+ dotAll: true,
+ ),
+ ),
+ findsOneWidget,
+ );
+
+ handle.dispose();
+ },
+ );
+
+ testWidgets(
+ 'unread count == 0 omits the unread label from the announcement',
+ (tester) async {
+ final handle = tester.ensureSemantics();
+
+ final threadWithoutUnread = thread.copyWith(
+ read: [
+ Read(user: user2, lastRead: createdAt, unreadMessages: 0),
+ ],
+ );
+
+ await tester.pumpWidget(
+ _wrapWithMaterialApp(
+ StreamThreadListTile(
+ thread: threadWithoutUnread,
+ currentUser: user2,
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ expect(find.bySemanticsLabel(RegExp('Group ride')), findsOneWidget);
+ expect(find.bySemanticsLabel(RegExp('unread')), findsNothing);
+
+ handle.dispose();
+ },
+ );
+ });
+
group('Formatter Tests', () {
testWidgets(
'StreamThreadListTile displays custom formatted timestamp',
diff --git a/packages/stream_chat_flutter/test/src/utils/date_formatter_test.dart b/packages/stream_chat_flutter/test/src/utils/date_formatter_test.dart
index f0b5edcedc..b05258160c 100644
--- a/packages/stream_chat_flutter/test/src/utils/date_formatter_test.dart
+++ b/packages/stream_chat_flutter/test/src/utils/date_formatter_test.dart
@@ -27,31 +27,31 @@ void main() {
});
});
- testWidgets('formats same-day dates as Today at H:mm', (tester) async {
+ testWidgets('formats same-day dates as Today at time', (tester) async {
await withClock(Clock.fixed(referenceDate), () async {
await pumpFormatted(tester, DateTime(2026, 4, 7, 9, 41));
- expect(find.text('Today at 9:41'), findsOneWidget);
+ expect(find.text('Today at 9:41 AM'), findsOneWidget);
});
});
- testWidgets('formats previous-day dates as Yesterday at H:mm', (tester) async {
+ testWidgets('formats previous-day dates as Yesterday at time', (tester) async {
await withClock(Clock.fixed(referenceDate), () async {
await pumpFormatted(tester, DateTime(2026, 4, 6, 9, 41));
- expect(find.text('Yesterday at 9:41'), findsOneWidget);
+ expect(find.text('Yesterday at 9:41 AM'), findsOneWidget);
});
});
- testWidgets('formats recent dates within a week as Weekday at H:mm', (tester) async {
+ testWidgets('formats recent dates within a week as Weekday at time', (tester) async {
await withClock(Clock.fixed(referenceDate), () async {
await pumpFormatted(tester, DateTime(2026, 4, 4, 9, 41));
- expect(find.text('Saturday at 9:41'), findsOneWidget);
+ expect(find.text('Saturday at 9:41 AM'), findsOneWidget);
});
});
- testWidgets('formats older dates as MMM do at H:mm', (tester) async {
+ testWidgets('formats older dates as MMM do at time', (tester) async {
await withClock(Clock.fixed(referenceDate), () async {
await pumpFormatted(tester, DateTime(2026, 1, 1, 9, 41));
- expect(find.text('Jan 1st at 9:41'), findsOneWidget);
+ expect(find.text('Jan 1st at 9:41 AM'), findsOneWidget);
});
});
});
diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md
index 67b72baa9c..6eae290847 100644
--- a/packages/stream_chat_localizations/CHANGELOG.md
+++ b/packages/stream_chat_localizations/CHANGELOG.md
@@ -2,7 +2,10 @@
✅ Added
-- Added `AccessibilityTranslations`, a nested `translations.accessibility` namespace holding screen-reader-only strings — tooltips, semantic labels, hints, and live-region announcements for the composer, attachment picker, voice recording, message actions, channel header, media gallery, and poll creator. Every supported locale (11 in total) has its own full native-language implementation — no reliance on English fallback. Screen readers speak durations as natural language ("1 minute 23 seconds") and timestamps as locale-aware spelled-out dates ("March 15, 2026, 10:30 AM"), instead of ambiguous clock strings. Public `formatDuration(Duration)` and `formatDateTime(DateTime?)` formatters on the namespace mirror Flutter's `MaterialLocalizations.formatFullDate`/`formatDecimal` pattern. Translations for `de/es/it/pt/ca/no/hi/ja/ko` are best-effort; native-speaker corrections welcome.
+- Added `AccessibilityTranslations`, a nested `translations.accessibility` namespace of screen-reader-only strings — tooltips, semantic labels, hints, live-region announcements, and `formatDuration` / `formatDateTime` / `formatRecentDateTime` formatters. Every supported locale ships a full native-language implementation.
+- Added channel-list a11y strings: `unreadMessagesLabel`, `channelGroupLabel`, `systemMessagePreviewLabel`, `channelMutedLabel`, `channelPinnedLabel`.
+- Added message-preview a11y strings: `outgoingMessagePreviewLabel`, `incomingMessagePreviewLabel`, `pollPreviewLabel`, `draftPreviewLabel`, and the `messageSending` / `messageSent` / `messageDelivered` / `messageRead` status labels.
+- Updated `attachmentPickerTooltip` in every locale to a state-agnostic `"Toggle attachment picker"` phrasing.
## 10.1.0
diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart
index f22f493fae..c5c6a84874 100644
--- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart
+++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart
@@ -717,7 +717,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations {
String get emptyMessagePreviewText => '';
@override
- String get voiceRecordingText => 'Voice Recording';
+ String get voiceRecordingText => 'Voice recording';
@override
String get audioAttachmentText => 'Audio';
diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart
index c70cab1900..8603ad9864 100644
--- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart
+++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart
@@ -889,7 +889,7 @@ class _AccessibilityTranslationsCa extends AccessibilityTranslations {
"Pausa l'enregistrament de veu, ${formatDuration(duration)}";
@override
- String get attachmentPickerTooltip => 'Obre el selector de fitxers adjunts';
+ String get attachmentPickerTooltip => 'Alterna el selector de fitxers adjunts';
@override
String get attachmentPickerOpenHint => 'toca dos vegades per obrir el selector de fitxers adjunts';
@@ -970,6 +970,54 @@ class _AccessibilityTranslationsCa extends AccessibilityTranslations {
@override
String get deselectMediaTapHint => 'desselecciona';
+ @override
+ String get outgoingMessagePreviewLabel => 'Tu';
+
+ @override
+ String incomingMessagePreviewLabel({String? senderName}) {
+ return senderName ?? 'Missatge';
+ }
+
+ @override
+ String get pollPreviewLabel => 'Enquesta';
+
+ @override
+ String get draftPreviewLabel => 'Esborrany';
+
+ @override
+ String get messageSendingStatusLabel => "S'està enviant";
+
+ @override
+ String get messageSentStatusLabel => 'Enviat';
+
+ @override
+ String get messageDeliveredStatusLabel => 'Lliurat';
+
+ @override
+ String get messageReadStatusLabel => 'Llegit';
+
+ @override
+ String unreadMessagesLabel({required int count}) {
+ return Intl.plural(
+ count,
+ one: '$count missatge no llegit',
+ other: '$count missatges no llegits',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String get channelGroupLabel => 'Grup';
+
+ @override
+ String get systemMessagePreviewLabel => 'Sistema';
+
+ @override
+ String get channelMutedLabel => 'silenciat';
+
+ @override
+ String get channelPinnedLabel => 'fixat';
+
@override
String get savePollTooltip => "Desa l'enquesta";
@@ -1034,14 +1082,39 @@ class _AccessibilityTranslationsCa extends AccessibilityTranslations {
@override
String attachmentsAddedAnnouncement({required int count}) {
- if (count == 1) return '1 fitxer adjunt afegit';
- return '$count fitxers adjunts afegits';
+ return Intl.plural(
+ count,
+ one: '$count fitxer adjunt afegit',
+ other: '$count fitxers adjunts afegits',
+ locale: localeName,
+ );
}
@override
String attachmentsRemovedAnnouncement({required int count}) {
- if (count == 1) return '1 fitxer adjunt eliminat';
- return '$count fitxers adjunts eliminats';
+ return Intl.plural(
+ count,
+ one: '$count fitxer adjunt eliminat',
+ other: '$count fitxers adjunts eliminats',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String formatRecentDateTime(DateTime date) {
+ if (date.isWithinLastMinute) return 'Ara mateix';
+
+ final localDate = date.toLocal();
+ final jiffyDate = Jiffy.parseFromDateTime(localDate);
+ final time = jiffyDate.jm;
+
+ if (localDate.isToday) return 'Avui a les $time';
+ if (localDate.isYesterday) return 'Ahir a les $time';
+ if (localDate.isWithinLastWeek) return '${jiffyDate.EEEE} a les $time';
+ if (localDate.isInSameYear) {
+ return '${jiffyDate.format(pattern: 'd MMM')} a les $time';
+ }
+ return '${jiffyDate.format(pattern: 'd MMM yyyy')} a les $time';
}
@override
diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart
index 8f8a032164..b8b8043611 100644
--- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart
+++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart
@@ -887,7 +887,7 @@ class _AccessibilityTranslationsDe extends AccessibilityTranslations {
'Sprachaufnahme pausieren, ${formatDuration(duration)}';
@override
- String get attachmentPickerTooltip => 'Anhang-Auswahl öffnen';
+ String get attachmentPickerTooltip => 'Anhang-Auswahl umschalten';
@override
String get attachmentPickerOpenHint => 'zum Öffnen der Anhang-Auswahl doppeltippen';
@@ -968,6 +968,54 @@ class _AccessibilityTranslationsDe extends AccessibilityTranslations {
@override
String get deselectMediaTapHint => 'abwählen';
+ @override
+ String get outgoingMessagePreviewLabel => 'Sie';
+
+ @override
+ String incomingMessagePreviewLabel({String? senderName}) {
+ return senderName ?? 'Nachricht';
+ }
+
+ @override
+ String get pollPreviewLabel => 'Umfrage';
+
+ @override
+ String get draftPreviewLabel => 'Entwurf';
+
+ @override
+ String get messageSendingStatusLabel => 'Wird gesendet';
+
+ @override
+ String get messageSentStatusLabel => 'Gesendet';
+
+ @override
+ String get messageDeliveredStatusLabel => 'Zugestellt';
+
+ @override
+ String get messageReadStatusLabel => 'Gelesen';
+
+ @override
+ String unreadMessagesLabel({required int count}) {
+ return Intl.plural(
+ count,
+ one: '$count ungelesene Nachricht',
+ other: '$count ungelesene Nachrichten',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String get channelGroupLabel => 'Gruppe';
+
+ @override
+ String get systemMessagePreviewLabel => 'System';
+
+ @override
+ String get channelMutedLabel => 'stummgeschaltet';
+
+ @override
+ String get channelPinnedLabel => 'angeheftet';
+
@override
String get savePollTooltip => 'Umfrage speichern';
@@ -1032,14 +1080,39 @@ class _AccessibilityTranslationsDe extends AccessibilityTranslations {
@override
String attachmentsAddedAnnouncement({required int count}) {
- if (count == 1) return '1 Anhang hinzugefügt';
- return '$count Anhänge hinzugefügt';
+ return Intl.plural(
+ count,
+ one: '$count Anhang hinzugefügt',
+ other: '$count Anhänge hinzugefügt',
+ locale: localeName,
+ );
}
@override
String attachmentsRemovedAnnouncement({required int count}) {
- if (count == 1) return '1 Anhang entfernt';
- return '$count Anhänge entfernt';
+ return Intl.plural(
+ count,
+ one: '$count Anhang entfernt',
+ other: '$count Anhänge entfernt',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String formatRecentDateTime(DateTime date) {
+ if (date.isWithinLastMinute) return 'Gerade eben';
+
+ final localDate = date.toLocal();
+ final jiffyDate = Jiffy.parseFromDateTime(localDate);
+ final time = jiffyDate.jm;
+
+ if (localDate.isToday) return 'Heute um $time';
+ if (localDate.isYesterday) return 'Gestern um $time';
+ if (localDate.isWithinLastWeek) return '${jiffyDate.EEEE} um $time';
+ if (localDate.isInSameYear) {
+ return '${jiffyDate.format(pattern: 'd. MMM')} um $time';
+ }
+ return '${jiffyDate.format(pattern: 'd. MMM yyyy')} um $time';
}
@override
diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart
index 3f46979c95..314e2c3c28 100644
--- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart
+++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart
@@ -701,7 +701,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations {
String get emptyMessagePreviewText => '';
@override
- String get voiceRecordingText => 'Voice Recording';
+ String get voiceRecordingText => 'Voice recording';
@override
String get audioAttachmentText => 'Audio';
@@ -887,7 +887,7 @@ class _AccessibilityTranslationsEn extends AccessibilityTranslations {
'Pause voice recording, ${formatDuration(duration)}';
@override
- String get attachmentPickerTooltip => 'Open attachment picker';
+ String get attachmentPickerTooltip => 'Toggle attachment picker';
@override
String get attachmentPickerOpenHint => 'double tap to open attachment picker';
@@ -968,6 +968,54 @@ class _AccessibilityTranslationsEn extends AccessibilityTranslations {
@override
String get deselectMediaTapHint => 'deselect';
+ @override
+ String get outgoingMessagePreviewLabel => 'You';
+
+ @override
+ String incomingMessagePreviewLabel({String? senderName}) {
+ return senderName ?? 'Message';
+ }
+
+ @override
+ String get pollPreviewLabel => 'Poll';
+
+ @override
+ String get draftPreviewLabel => 'Draft';
+
+ @override
+ String get messageSendingStatusLabel => 'Sending';
+
+ @override
+ String get messageSentStatusLabel => 'Sent';
+
+ @override
+ String get messageDeliveredStatusLabel => 'Delivered';
+
+ @override
+ String get messageReadStatusLabel => 'Read';
+
+ @override
+ String unreadMessagesLabel({required int count}) {
+ return Intl.plural(
+ count,
+ one: '$count unread message',
+ other: '$count unread messages',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String get channelGroupLabel => 'Group';
+
+ @override
+ String get systemMessagePreviewLabel => 'System';
+
+ @override
+ String get channelMutedLabel => 'muted';
+
+ @override
+ String get channelPinnedLabel => 'pinned';
+
@override
String get savePollTooltip => 'Save poll';
@@ -1031,14 +1079,22 @@ class _AccessibilityTranslationsEn extends AccessibilityTranslations {
@override
String attachmentsAddedAnnouncement({required int count}) {
- if (count == 1) return '1 attachment added';
- return '$count attachments added';
+ return Intl.plural(
+ count,
+ one: '$count attachment added',
+ other: '$count attachments added',
+ locale: localeName,
+ );
}
@override
String attachmentsRemovedAnnouncement({required int count}) {
- if (count == 1) return '1 attachment removed';
- return '$count attachments removed';
+ return Intl.plural(
+ count,
+ one: '$count attachment removed',
+ other: '$count attachments removed',
+ locale: localeName,
+ );
}
@override
@@ -1047,6 +1103,23 @@ class _AccessibilityTranslationsEn extends AccessibilityTranslations {
return '${jiffy.EEEE}, ${jiffy.yMMMMd}, ${jiffy.jm}';
}
+ @override
+ String formatRecentDateTime(DateTime date) {
+ if (date.isWithinLastMinute) return 'Just now';
+
+ final localDate = date.toLocal();
+ final jiffyDate = Jiffy.parseFromDateTime(localDate);
+ final time = jiffyDate.jm;
+
+ if (localDate.isToday) return 'Today at $time';
+ if (localDate.isYesterday) return 'Yesterday at $time';
+ if (localDate.isWithinLastWeek) return '${jiffyDate.EEEE} at $time';
+ if (localDate.isInSameYear) {
+ return '${jiffyDate.format(pattern: 'MMM d')} at $time';
+ }
+ return '${jiffyDate.format(pattern: 'MMM d, yyyy')} at $time';
+ }
+
@override
String formatDuration(Duration duration) {
final hours = duration.inHours;
diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart
index b3ed541147..bbd8d3aee3 100644
--- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart
+++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart
@@ -891,7 +891,7 @@ class _AccessibilityTranslationsEs extends AccessibilityTranslations {
'Pausar grabación de voz, ${formatDuration(duration)}';
@override
- String get attachmentPickerTooltip => 'Abrir selector de archivos adjuntos';
+ String get attachmentPickerTooltip => 'Alternar selector de archivos adjuntos';
@override
String get attachmentPickerOpenHint => 'toca dos veces para abrir el selector de archivos adjuntos';
@@ -972,6 +972,54 @@ class _AccessibilityTranslationsEs extends AccessibilityTranslations {
@override
String get deselectMediaTapHint => 'deseleccionar';
+ @override
+ String get outgoingMessagePreviewLabel => 'Tú';
+
+ @override
+ String incomingMessagePreviewLabel({String? senderName}) {
+ return senderName ?? 'Mensaje';
+ }
+
+ @override
+ String get pollPreviewLabel => 'Encuesta';
+
+ @override
+ String get draftPreviewLabel => 'Borrador';
+
+ @override
+ String get messageSendingStatusLabel => 'Enviando';
+
+ @override
+ String get messageSentStatusLabel => 'Enviado';
+
+ @override
+ String get messageDeliveredStatusLabel => 'Entregado';
+
+ @override
+ String get messageReadStatusLabel => 'Leído';
+
+ @override
+ String unreadMessagesLabel({required int count}) {
+ return Intl.plural(
+ count,
+ one: '$count mensaje sin leer',
+ other: '$count mensajes sin leer',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String get channelGroupLabel => 'Grupo';
+
+ @override
+ String get systemMessagePreviewLabel => 'Sistema';
+
+ @override
+ String get channelMutedLabel => 'silenciado';
+
+ @override
+ String get channelPinnedLabel => 'fijado';
+
@override
String get savePollTooltip => 'Guardar encuesta';
@@ -1036,14 +1084,39 @@ class _AccessibilityTranslationsEs extends AccessibilityTranslations {
@override
String attachmentsAddedAnnouncement({required int count}) {
- if (count == 1) return '1 archivo adjunto añadido';
- return '$count archivos adjuntos añadidos';
+ return Intl.plural(
+ count,
+ one: '$count archivo adjunto añadido',
+ other: '$count archivos adjuntos añadidos',
+ locale: localeName,
+ );
}
@override
String attachmentsRemovedAnnouncement({required int count}) {
- if (count == 1) return '1 archivo adjunto eliminado';
- return '$count archivos adjuntos eliminados';
+ return Intl.plural(
+ count,
+ one: '$count archivo adjunto eliminado',
+ other: '$count archivos adjuntos eliminados',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String formatRecentDateTime(DateTime date) {
+ if (date.isWithinLastMinute) return 'Ahora mismo';
+
+ final localDate = date.toLocal();
+ final jiffyDate = Jiffy.parseFromDateTime(localDate);
+ final time = jiffyDate.jm;
+
+ if (localDate.isToday) return 'Hoy a las $time';
+ if (localDate.isYesterday) return 'Ayer a las $time';
+ if (localDate.isWithinLastWeek) return '${jiffyDate.EEEE} a las $time';
+ if (localDate.isInSameYear) {
+ return '${jiffyDate.format(pattern: 'd MMM')} a las $time';
+ }
+ return '${jiffyDate.format(pattern: 'd MMM yyyy')} a las $time';
}
@override
diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart
index 4cf3cc7c25..27c33b0a91 100644
--- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart
+++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart
@@ -893,7 +893,7 @@ class _AccessibilityTranslationsFr extends AccessibilityTranslations {
"Mettre en pause l'enregistrement vocal, ${formatDuration(duration)}";
@override
- String get attachmentPickerTooltip => 'Ouvrir le sélecteur de pièces jointes';
+ String get attachmentPickerTooltip => 'Basculer le sélecteur de pièces jointes';
@override
String get attachmentPickerOpenHint => 'toucher deux fois pour ouvrir le sélecteur de pièces jointes';
@@ -974,6 +974,54 @@ class _AccessibilityTranslationsFr extends AccessibilityTranslations {
@override
String get deselectMediaTapHint => 'désélectionner';
+ @override
+ String get outgoingMessagePreviewLabel => 'Vous';
+
+ @override
+ String incomingMessagePreviewLabel({String? senderName}) {
+ return senderName ?? 'Message';
+ }
+
+ @override
+ String get pollPreviewLabel => 'Sondage';
+
+ @override
+ String get draftPreviewLabel => 'Brouillon';
+
+ @override
+ String get messageSendingStatusLabel => "En cours d'envoi";
+
+ @override
+ String get messageSentStatusLabel => 'Envoyé';
+
+ @override
+ String get messageDeliveredStatusLabel => 'Distribué';
+
+ @override
+ String get messageReadStatusLabel => 'Lu';
+
+ @override
+ String unreadMessagesLabel({required int count}) {
+ return Intl.plural(
+ count,
+ one: '$count message non lu',
+ other: '$count messages non lus',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String get channelGroupLabel => 'Groupe';
+
+ @override
+ String get systemMessagePreviewLabel => 'Système';
+
+ @override
+ String get channelMutedLabel => 'en sourdine';
+
+ @override
+ String get channelPinnedLabel => 'épinglé';
+
@override
String get savePollTooltip => 'Enregistrer le sondage';
@@ -1038,14 +1086,39 @@ class _AccessibilityTranslationsFr extends AccessibilityTranslations {
@override
String attachmentsAddedAnnouncement({required int count}) {
- if (count == 1) return '1 pièce jointe ajoutée';
- return '$count pièces jointes ajoutées';
+ return Intl.plural(
+ count,
+ one: '$count pièce jointe ajoutée',
+ other: '$count pièces jointes ajoutées',
+ locale: localeName,
+ );
}
@override
String attachmentsRemovedAnnouncement({required int count}) {
- if (count == 1) return '1 pièce jointe supprimée';
- return '$count pièces jointes supprimées';
+ return Intl.plural(
+ count,
+ one: '$count pièce jointe supprimée',
+ other: '$count pièces jointes supprimées',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String formatRecentDateTime(DateTime date) {
+ if (date.isWithinLastMinute) return "À l'instant";
+
+ final localDate = date.toLocal();
+ final jiffyDate = Jiffy.parseFromDateTime(localDate);
+ final time = jiffyDate.jm;
+
+ if (localDate.isToday) return "Aujourd'hui à $time";
+ if (localDate.isYesterday) return 'Hier à $time';
+ if (localDate.isWithinLastWeek) return '${jiffyDate.EEEE} à $time';
+ if (localDate.isInSameYear) {
+ return '${jiffyDate.format(pattern: 'd MMM')} à $time';
+ }
+ return '${jiffyDate.format(pattern: 'd MMM yyyy')} à $time';
}
@override
diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart
index 865f3e512b..43e87f711b 100644
--- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart
+++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart
@@ -888,7 +888,7 @@ class _AccessibilityTranslationsHi extends AccessibilityTranslations {
'वॉइस रिकॉर्डिंग रोकें, ${formatDuration(duration)}';
@override
- String get attachmentPickerTooltip => 'अटैचमेंट चयनकर्ता खोलें';
+ String get attachmentPickerTooltip => 'अटैचमेंट चयनकर्ता टॉगल करें';
@override
String get attachmentPickerOpenHint => 'अटैचमेंट चयनकर्ता खोलने के लिए दो बार टैप करें';
@@ -969,6 +969,54 @@ class _AccessibilityTranslationsHi extends AccessibilityTranslations {
@override
String get deselectMediaTapHint => 'चयन हटाएँ';
+ @override
+ String get outgoingMessagePreviewLabel => 'आप';
+
+ @override
+ String incomingMessagePreviewLabel({String? senderName}) {
+ return senderName ?? 'संदेश';
+ }
+
+ @override
+ String get pollPreviewLabel => 'पोल';
+
+ @override
+ String get draftPreviewLabel => 'मसौदा';
+
+ @override
+ String get messageSendingStatusLabel => 'भेजा जा रहा है';
+
+ @override
+ String get messageSentStatusLabel => 'भेजा गया';
+
+ @override
+ String get messageDeliveredStatusLabel => 'पहुंचा दिया';
+
+ @override
+ String get messageReadStatusLabel => 'पढ़ा गया';
+
+ @override
+ String unreadMessagesLabel({required int count}) {
+ return Intl.plural(
+ count,
+ one: '$count अपठित संदेश',
+ other: '$count अपठित संदेश',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String get channelGroupLabel => 'ग्रुप';
+
+ @override
+ String get systemMessagePreviewLabel => 'सिस्टम';
+
+ @override
+ String get channelMutedLabel => 'म्यूट';
+
+ @override
+ String get channelPinnedLabel => 'पिन किया गया';
+
@override
String get savePollTooltip => 'पोल सहेजें';
@@ -1033,12 +1081,39 @@ class _AccessibilityTranslationsHi extends AccessibilityTranslations {
@override
String attachmentsAddedAnnouncement({required int count}) {
- return '$count अटैचमेंट जोड़े गए';
+ return Intl.plural(
+ count,
+ one: '$count अटैचमेंट जोड़ा गया',
+ other: '$count अटैचमेंट जोड़े गए',
+ locale: localeName,
+ );
}
@override
String attachmentsRemovedAnnouncement({required int count}) {
- return '$count अटैचमेंट हटाए गए';
+ return Intl.plural(
+ count,
+ one: '$count अटैचमेंट हटाया गया',
+ other: '$count अटैचमेंट हटाए गए',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String formatRecentDateTime(DateTime date) {
+ if (date.isWithinLastMinute) return 'अभी';
+
+ final localDate = date.toLocal();
+ final jiffyDate = Jiffy.parseFromDateTime(localDate);
+ final time = jiffyDate.jm;
+
+ if (localDate.isToday) return 'आज $time पर';
+ if (localDate.isYesterday) return 'कल $time पर';
+ if (localDate.isWithinLastWeek) return '${jiffyDate.EEEE} $time पर';
+ if (localDate.isInSameYear) {
+ return '${jiffyDate.format(pattern: 'd MMM')} $time पर';
+ }
+ return '${jiffyDate.format(pattern: 'd MMM yyyy')} $time पर';
}
@override
diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart
index 583c2159e7..4b5950db7c 100644
--- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart
+++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart
@@ -901,7 +901,7 @@ class _AccessibilityTranslationsIt extends AccessibilityTranslations {
'Metti in pausa registrazione vocale, ${formatDuration(duration)}';
@override
- String get attachmentPickerTooltip => 'Apri selettore allegati';
+ String get attachmentPickerTooltip => 'Attiva/disattiva selettore allegati';
@override
String get attachmentPickerOpenHint => 'tocca due volte per aprire il selettore allegati';
@@ -982,6 +982,54 @@ class _AccessibilityTranslationsIt extends AccessibilityTranslations {
@override
String get deselectMediaTapHint => 'deseleziona';
+ @override
+ String get outgoingMessagePreviewLabel => 'Tu';
+
+ @override
+ String incomingMessagePreviewLabel({String? senderName}) {
+ return senderName ?? 'Messaggio';
+ }
+
+ @override
+ String get pollPreviewLabel => 'Sondaggio';
+
+ @override
+ String get draftPreviewLabel => 'Bozza';
+
+ @override
+ String get messageSendingStatusLabel => 'In invio';
+
+ @override
+ String get messageSentStatusLabel => 'Inviato';
+
+ @override
+ String get messageDeliveredStatusLabel => 'Consegnato';
+
+ @override
+ String get messageReadStatusLabel => 'Letto';
+
+ @override
+ String unreadMessagesLabel({required int count}) {
+ return Intl.plural(
+ count,
+ one: '$count messaggio non letto',
+ other: '$count messaggi non letti',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String get channelGroupLabel => 'Gruppo';
+
+ @override
+ String get systemMessagePreviewLabel => 'Sistema';
+
+ @override
+ String get channelMutedLabel => 'silenziato';
+
+ @override
+ String get channelPinnedLabel => 'fissato';
+
@override
String get savePollTooltip => 'Salva sondaggio';
@@ -1046,14 +1094,39 @@ class _AccessibilityTranslationsIt extends AccessibilityTranslations {
@override
String attachmentsAddedAnnouncement({required int count}) {
- if (count == 1) return '1 allegato aggiunto';
- return '$count allegati aggiunti';
+ return Intl.plural(
+ count,
+ one: '$count allegato aggiunto',
+ other: '$count allegati aggiunti',
+ locale: localeName,
+ );
}
@override
String attachmentsRemovedAnnouncement({required int count}) {
- if (count == 1) return '1 allegato rimosso';
- return '$count allegati rimossi';
+ return Intl.plural(
+ count,
+ one: '$count allegato rimosso',
+ other: '$count allegati rimossi',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String formatRecentDateTime(DateTime date) {
+ if (date.isWithinLastMinute) return 'Proprio ora';
+
+ final localDate = date.toLocal();
+ final jiffyDate = Jiffy.parseFromDateTime(localDate);
+ final time = jiffyDate.jm;
+
+ if (localDate.isToday) return 'Oggi alle $time';
+ if (localDate.isYesterday) return 'Ieri alle $time';
+ if (localDate.isWithinLastWeek) return '${jiffyDate.EEEE} alle $time';
+ if (localDate.isInSameYear) {
+ return '${jiffyDate.format(pattern: 'd MMM')} alle $time';
+ }
+ return '${jiffyDate.format(pattern: 'd MMM yyyy')} alle $time';
}
@override
diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart
index 6ee7d8fb72..ff9329598b 100644
--- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart
+++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart
@@ -866,7 +866,7 @@ class _AccessibilityTranslationsJa extends AccessibilityTranslations {
String voiceRecordingPreviewPauseLabel({required Duration duration}) => 'ボイス録音を一時停止、${formatDuration(duration)}';
@override
- String get attachmentPickerTooltip => '添付ファイル選択を開く';
+ String get attachmentPickerTooltip => '添付ファイル選択を切り替え';
@override
String get attachmentPickerOpenHint => 'ダブルタップして添付ファイル選択を開く';
@@ -947,6 +947,54 @@ class _AccessibilityTranslationsJa extends AccessibilityTranslations {
@override
String get deselectMediaTapHint => '選択解除';
+ @override
+ String get outgoingMessagePreviewLabel => '自分';
+
+ @override
+ String incomingMessagePreviewLabel({String? senderName}) {
+ return senderName ?? 'メッセージ';
+ }
+
+ @override
+ String get pollPreviewLabel => '投票';
+
+ @override
+ String get draftPreviewLabel => '下書き';
+
+ @override
+ String get messageSendingStatusLabel => '送信中';
+
+ @override
+ String get messageSentStatusLabel => '送信済み';
+
+ @override
+ String get messageDeliveredStatusLabel => '配信済み';
+
+ @override
+ String get messageReadStatusLabel => '既読';
+
+ @override
+ String unreadMessagesLabel({required int count}) {
+ return Intl.plural(
+ count,
+ one: '$count件の未読メッセージ',
+ other: '$count件の未読メッセージ',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String get channelGroupLabel => 'グループ';
+
+ @override
+ String get systemMessagePreviewLabel => 'システム';
+
+ @override
+ String get channelMutedLabel => 'ミュート済み';
+
+ @override
+ String get channelPinnedLabel => '固定済み';
+
@override
String get savePollTooltip => '投票を保存';
@@ -1010,12 +1058,39 @@ class _AccessibilityTranslationsJa extends AccessibilityTranslations {
@override
String attachmentsAddedAnnouncement({required int count}) {
- return '$count件の添付ファイルを追加しました';
+ return Intl.plural(
+ count,
+ one: '$count件の添付ファイルを追加しました',
+ other: '$count件の添付ファイルを追加しました',
+ locale: localeName,
+ );
}
@override
String attachmentsRemovedAnnouncement({required int count}) {
- return '$count件の添付ファイルを削除しました';
+ return Intl.plural(
+ count,
+ one: '$count件の添付ファイルを削除しました',
+ other: '$count件の添付ファイルを削除しました',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String formatRecentDateTime(DateTime date) {
+ if (date.isWithinLastMinute) return 'たった今';
+
+ final localDate = date.toLocal();
+ final jiffyDate = Jiffy.parseFromDateTime(localDate);
+ final time = jiffyDate.jm;
+
+ if (localDate.isToday) return '今日 $time';
+ if (localDate.isYesterday) return '昨日 $time';
+ if (localDate.isWithinLastWeek) return '${jiffyDate.EEEE} $time';
+ if (localDate.isInSameYear) {
+ return '${jiffyDate.format(pattern: 'M月d日')} $time';
+ }
+ return '${jiffyDate.format(pattern: 'yyyy年M月d日')} $time';
}
@override
diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart
index 93bcfc7dcc..57df0252c8 100644
--- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart
+++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart
@@ -869,7 +869,7 @@ class _AccessibilityTranslationsKo extends AccessibilityTranslations {
String voiceRecordingPreviewPauseLabel({required Duration duration}) => '음성 녹음 일시정지, ${formatDuration(duration)}';
@override
- String get attachmentPickerTooltip => '첨부 파일 선택 열기';
+ String get attachmentPickerTooltip => '첨부 파일 선택 전환';
@override
String get attachmentPickerOpenHint => '두 번 탭하여 첨부 파일 선택 열기';
@@ -950,6 +950,54 @@ class _AccessibilityTranslationsKo extends AccessibilityTranslations {
@override
String get deselectMediaTapHint => '선택 해제';
+ @override
+ String get outgoingMessagePreviewLabel => '나';
+
+ @override
+ String incomingMessagePreviewLabel({String? senderName}) {
+ return senderName ?? '메시지';
+ }
+
+ @override
+ String get pollPreviewLabel => '투표';
+
+ @override
+ String get draftPreviewLabel => '임시 저장';
+
+ @override
+ String get messageSendingStatusLabel => '보내는 중';
+
+ @override
+ String get messageSentStatusLabel => '보냄';
+
+ @override
+ String get messageDeliveredStatusLabel => '전달됨';
+
+ @override
+ String get messageReadStatusLabel => '읽음';
+
+ @override
+ String unreadMessagesLabel({required int count}) {
+ return Intl.plural(
+ count,
+ one: '읽지 않은 메시지 $count개',
+ other: '읽지 않은 메시지 $count개',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String get channelGroupLabel => '그룹';
+
+ @override
+ String get systemMessagePreviewLabel => '시스템';
+
+ @override
+ String get channelMutedLabel => '음소거됨';
+
+ @override
+ String get channelPinnedLabel => '고정됨';
+
@override
String get savePollTooltip => '투표 저장';
@@ -1013,12 +1061,39 @@ class _AccessibilityTranslationsKo extends AccessibilityTranslations {
@override
String attachmentsAddedAnnouncement({required int count}) {
- return '첨부 파일 $count개가 추가되었습니다';
+ return Intl.plural(
+ count,
+ one: '첨부 파일 $count개가 추가되었습니다',
+ other: '첨부 파일 $count개가 추가되었습니다',
+ locale: localeName,
+ );
}
@override
String attachmentsRemovedAnnouncement({required int count}) {
- return '첨부 파일 $count개가 삭제되었습니다';
+ return Intl.plural(
+ count,
+ one: '첨부 파일 $count개가 삭제되었습니다',
+ other: '첨부 파일 $count개가 삭제되었습니다',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String formatRecentDateTime(DateTime date) {
+ if (date.isWithinLastMinute) return '방금';
+
+ final localDate = date.toLocal();
+ final jiffyDate = Jiffy.parseFromDateTime(localDate);
+ final time = jiffyDate.jm;
+
+ if (localDate.isToday) return '오늘 $time';
+ if (localDate.isYesterday) return '어제 $time';
+ if (localDate.isWithinLastWeek) return '${jiffyDate.EEEE} $time';
+ if (localDate.isInSameYear) {
+ return '${jiffyDate.format(pattern: 'M월 d일')} $time';
+ }
+ return '${jiffyDate.format(pattern: 'yyyy년 M월 d일')} $time';
}
@override
diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart
index f3843457c0..a42ae1d24c 100644
--- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart
+++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart
@@ -872,7 +872,7 @@ class _AccessibilityTranslationsNo extends AccessibilityTranslations {
'Sett taleopptak på pause, ${formatDuration(duration)}';
@override
- String get attachmentPickerTooltip => 'Åpne vedleggsvelger';
+ String get attachmentPickerTooltip => 'Veksle vedleggsvelger';
@override
String get attachmentPickerOpenHint => 'dobbeltrykk for å åpne vedleggsvelger';
@@ -953,6 +953,54 @@ class _AccessibilityTranslationsNo extends AccessibilityTranslations {
@override
String get deselectMediaTapHint => 'fjern valg';
+ @override
+ String get outgoingMessagePreviewLabel => 'Du';
+
+ @override
+ String incomingMessagePreviewLabel({String? senderName}) {
+ return senderName ?? 'Melding';
+ }
+
+ @override
+ String get pollPreviewLabel => 'Avstemning';
+
+ @override
+ String get draftPreviewLabel => 'Utkast';
+
+ @override
+ String get messageSendingStatusLabel => 'Sender';
+
+ @override
+ String get messageSentStatusLabel => 'Sendt';
+
+ @override
+ String get messageDeliveredStatusLabel => 'Levert';
+
+ @override
+ String get messageReadStatusLabel => 'Lest';
+
+ @override
+ String unreadMessagesLabel({required int count}) {
+ return Intl.plural(
+ count,
+ one: '$count ulest melding',
+ other: '$count uleste meldinger',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String get channelGroupLabel => 'Gruppe';
+
+ @override
+ String get systemMessagePreviewLabel => 'System';
+
+ @override
+ String get channelMutedLabel => 'dempet';
+
+ @override
+ String get channelPinnedLabel => 'festet';
+
@override
String get savePollTooltip => 'Lagre avstemning';
@@ -1016,14 +1064,39 @@ class _AccessibilityTranslationsNo extends AccessibilityTranslations {
@override
String attachmentsAddedAnnouncement({required int count}) {
- if (count == 1) return '1 vedlegg lagt til';
- return '$count vedlegg lagt til';
+ return Intl.plural(
+ count,
+ one: '$count vedlegg lagt til',
+ other: '$count vedlegg lagt til',
+ locale: localeName,
+ );
}
@override
String attachmentsRemovedAnnouncement({required int count}) {
- if (count == 1) return '1 vedlegg fjernet';
- return '$count vedlegg fjernet';
+ return Intl.plural(
+ count,
+ one: '$count vedlegg fjernet',
+ other: '$count vedlegg fjernet',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String formatRecentDateTime(DateTime date) {
+ if (date.isWithinLastMinute) return 'Akkurat nå';
+
+ final localDate = date.toLocal();
+ final jiffyDate = Jiffy.parseFromDateTime(localDate);
+ final time = jiffyDate.jm;
+
+ if (localDate.isToday) return 'I dag kl. $time';
+ if (localDate.isYesterday) return 'I går kl. $time';
+ if (localDate.isWithinLastWeek) return '${jiffyDate.EEEE} kl. $time';
+ if (localDate.isInSameYear) {
+ return '${jiffyDate.format(pattern: 'd. MMM')} kl. $time';
+ }
+ return '${jiffyDate.format(pattern: 'd. MMM yyyy')} kl. $time';
}
@override
diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart
index 9ec8070983..d9b1ef95c6 100644
--- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart
+++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart
@@ -890,7 +890,7 @@ class _AccessibilityTranslationsPt extends AccessibilityTranslations {
'Pausar gravação de voz, ${formatDuration(duration)}';
@override
- String get attachmentPickerTooltip => 'Abrir seletor de anexos';
+ String get attachmentPickerTooltip => 'Alternar seletor de anexos';
@override
String get attachmentPickerOpenHint => 'toque duas vezes para abrir o seletor de anexos';
@@ -971,6 +971,54 @@ class _AccessibilityTranslationsPt extends AccessibilityTranslations {
@override
String get deselectMediaTapHint => 'desmarcar';
+ @override
+ String get outgoingMessagePreviewLabel => 'Você';
+
+ @override
+ String incomingMessagePreviewLabel({String? senderName}) {
+ return senderName ?? 'Mensagem';
+ }
+
+ @override
+ String get pollPreviewLabel => 'Enquete';
+
+ @override
+ String get draftPreviewLabel => 'Rascunho';
+
+ @override
+ String get messageSendingStatusLabel => 'Enviando';
+
+ @override
+ String get messageSentStatusLabel => 'Enviado';
+
+ @override
+ String get messageDeliveredStatusLabel => 'Entregue';
+
+ @override
+ String get messageReadStatusLabel => 'Lido';
+
+ @override
+ String unreadMessagesLabel({required int count}) {
+ return Intl.plural(
+ count,
+ one: '$count mensagem não lida',
+ other: '$count mensagens não lidas',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String get channelGroupLabel => 'Grupo';
+
+ @override
+ String get systemMessagePreviewLabel => 'Sistema';
+
+ @override
+ String get channelMutedLabel => 'silenciado';
+
+ @override
+ String get channelPinnedLabel => 'fixado';
+
@override
String get savePollTooltip => 'Salvar enquete';
@@ -1035,14 +1083,39 @@ class _AccessibilityTranslationsPt extends AccessibilityTranslations {
@override
String attachmentsAddedAnnouncement({required int count}) {
- if (count == 1) return '1 anexo adicionado';
- return '$count anexos adicionados';
+ return Intl.plural(
+ count,
+ one: '$count anexo adicionado',
+ other: '$count anexos adicionados',
+ locale: localeName,
+ );
}
@override
String attachmentsRemovedAnnouncement({required int count}) {
- if (count == 1) return '1 anexo removido';
- return '$count anexos removidos';
+ return Intl.plural(
+ count,
+ one: '$count anexo removido',
+ other: '$count anexos removidos',
+ locale: localeName,
+ );
+ }
+
+ @override
+ String formatRecentDateTime(DateTime date) {
+ if (date.isWithinLastMinute) return 'Agora mesmo';
+
+ final localDate = date.toLocal();
+ final jiffyDate = Jiffy.parseFromDateTime(localDate);
+ final time = jiffyDate.jm;
+
+ if (localDate.isToday) return 'Hoje às $time';
+ if (localDate.isYesterday) return 'Ontem às $time';
+ if (localDate.isWithinLastWeek) return '${jiffyDate.EEEE} às $time';
+ if (localDate.isInSameYear) {
+ return '${jiffyDate.format(pattern: 'd MMM')} às $time';
+ }
+ return '${jiffyDate.format(pattern: 'd MMM yyyy')} às $time';
}
@override
diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart
index 4cd86c602d..ba6054b00a 100644
--- a/packages/stream_chat_localizations/test/translations_test.dart
+++ b/packages/stream_chat_localizations/test/translations_test.dart
@@ -1,3 +1,4 @@
+import 'package:clock/clock.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
@@ -470,6 +471,37 @@ void main() {
expect(a11y.formatDuration(const Duration(seconds: 45)), isNotNull);
expect(a11y.formatDuration(const Duration(hours: 1, minutes: 2, seconds: 3)), isNotNull);
expect(a11y.formatDateTime(DateTime(2026, 3, 15, 10, 30)), isNotNull);
+ // formatRecentDateTime — every bucket exercised so each branch is covered.
+ // Pin the clock to mid-year so day-arithmetic doesn't leak between year
+ // buckets on year-boundary run dates.
+ await withClock(Clock.fixed(DateTime(2026, 6, 15, 12)), () async {
+ final now = clock.now();
+ expect(a11y.formatRecentDateTime(now), isNotNull);
+ expect(a11y.formatRecentDateTime(now.subtract(const Duration(hours: 3))), isNotNull);
+ expect(a11y.formatRecentDateTime(now.subtract(const Duration(days: 1))), isNotNull);
+ expect(a11y.formatRecentDateTime(now.subtract(const Duration(days: 3))), isNotNull);
+ expect(a11y.formatRecentDateTime(now.subtract(const Duration(days: 30))), isNotNull);
+ expect(a11y.formatRecentDateTime(now.subtract(const Duration(days: 400))), isNotNull);
+ });
+ // Message preview prefixes
+ expect(a11y.outgoingMessagePreviewLabel, isNotNull);
+ expect(a11y.incomingMessagePreviewLabel(), isNotNull);
+ expect(a11y.incomingMessagePreviewLabel(senderName: 'Alice'), isNotNull);
+ expect(a11y.pollPreviewLabel, isNotNull);
+ expect(a11y.draftPreviewLabel, isNotNull);
+ expect(a11y.systemMessagePreviewLabel, isNotNull);
+ // Delivery status
+ expect(a11y.messageSendingStatusLabel, isNotNull);
+ expect(a11y.messageSentStatusLabel, isNotNull);
+ expect(a11y.messageDeliveredStatusLabel, isNotNull);
+ expect(a11y.messageReadStatusLabel, isNotNull);
+ // Channel list row
+ expect(a11y.channelGroupLabel, isNotNull);
+ expect(a11y.channelMutedLabel, isNotNull);
+ expect(a11y.channelPinnedLabel, isNotNull);
+ // singular vs. plural — both branches exercised
+ expect(a11y.unreadMessagesLabel(count: 1), isNotNull);
+ expect(a11y.unreadMessagesLabel(count: 5), isNotNull);
});
}
@@ -489,4 +521,31 @@ void main() {
'GlobalStreamChatLocalizations.delegate($supportedLocales locales)',
);
});
+
+ // formatRecentDateTime renders the time via Jiffy's locale-aware `jm`, so the
+ // spoken clock follows the locale convention: 24-hour in most of Europe,
+ // 12-hour in English and Hindi. The wider suite above never pins the Jiffy
+ // locale, so this is the only place the locale-awareness is exercised.
+ test('formatRecentDateTime uses a locale-aware clock', () async {
+ await withClock(Clock.fixed(DateTime(2026, 6, 15, 20)), () async {
+ final recent = DateTime(2026, 6, 15, 15); // today at 15:00 / 3:00 PM
+
+ Future recentIn(String language) async {
+ await Jiffy.setLocale(language);
+ final a11y = (await GlobalStreamChatLocalizations.delegate.load(Locale(language))).accessibility;
+ return a11y.formatRecentDateTime(recent);
+ }
+
+ // 24-hour locales keep the wall-clock hour and never emit a meridiem.
+ for (final language in ['de', 'fr', 'it']) {
+ final formatted = await recentIn(language);
+ expect(formatted, contains('15:00'), reason: '$language should use a 24-hour clock');
+ expect(formatted.toUpperCase(), isNot(contains('PM')), reason: '$language should omit the meridiem');
+ }
+
+ // 12-hour locales fold to 3:00 with a meridiem.
+ expect(await recentIn('en'), contains('3:00 PM'));
+ expect(await recentIn('hi'), allOf(contains('3:00'), contains('pm')));
+ });
+ });
}