Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/docs_screenshots/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/docs_screenshots/test/src/mocks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 5 additions & 1 deletion melos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -54,6 +55,7 @@ class StreamChannelAvatar extends StatelessWidget {
super.key,
this.size,
required this.channel,
this.semanticsLabel,
});

/// The channel whose avatar is displayed.
Expand All @@ -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(),
),
Expand All @@ -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,
Expand All @@ -100,13 +113,23 @@ class StreamChannelAvatar extends StatelessWidget {

return StreamUserAvatarGroup(
size: effectiveSize,
semanticsLabel: effectiveLabel,
users: users.sortedBy((it) => it.id == currentUserId ? 1 : 0),
);
},
),
);
}

// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -109,6 +116,7 @@ class StreamUserAvatar extends StatelessWidget {
showBorder: showBorder,
backgroundColor: effectiveBackgroundColor,
foregroundColor: effectiveForegroundColor,
semanticsLabel: semanticsLabel,
placeholder: (_) => _StreamUserAvatarPlaceholder(user: user, size: effectiveSize),
);

Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

Expand All @@ -44,6 +46,7 @@ class StreamSendingIndicator extends StatelessWidget {
context.streamIcons.checks,
size: size,
color: colorScheme.textSecondary,
semanticLabel: a11y.messageDeliveredStatusLabel,
);
}

Expand All @@ -52,6 +55,7 @@ class StreamSendingIndicator extends StatelessWidget {
context.streamIcons.checkmark,
size: size,
color: colorScheme.textSecondary,
semanticLabel: a11y.messageSentStatusLabel,
);
}

Expand All @@ -60,6 +64,7 @@ class StreamSendingIndicator extends StatelessWidget {
context.streamIcons.clock,
size: size,
color: colorScheme.textSecondary,
semanticLabel: a11y.messageSendingStatusLabel,
);
}

Expand Down
Loading
Loading