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'))); + }); + }); }