From 4ccd41ca864e02946b3896e46e5aba45759d4cc1 Mon Sep 17 00:00:00 2001 From: mofirojean Date: Wed, 23 Oct 2024 22:21:09 +0100 Subject: [PATCH] i18n: Use translation for "(unknown users)" --- lib/widgets/emoji_reaction.dart | 4 +- lib/widgets/inbox.dart | 8 +- lib/widgets/message_list.dart | 1063 +++++++++++----------- lib/widgets/profile.dart | 3 +- lib/widgets/recent_dm_conversations.dart | 7 +- 5 files changed, 549 insertions(+), 536 deletions(-) diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 98147a54d7..17e700fd41 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import '../api/exception.dart'; import '../api/model/model.dart'; @@ -149,6 +150,7 @@ class ReactionChip extends StatelessWidget { @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); final reactionType = reactionWithVotes.reactionType; final emojiCode = reactionWithVotes.emojiCode; @@ -163,7 +165,7 @@ class ReactionChip extends StatelessWidget { ? userIds.map((id) { return id == store.selfUserId ? 'You' - : store.users[id]?.fullName ?? '(unknown user)'; // TODO(i18n) + : store.users[id]?.fullName ?? zulipLocalizations.unknownUserName; }).join(', ') : userIds.length.toString(); diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 12ec8751a1..68b61691d6 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import '../api/model/model.dart'; import '../model/narrow.dart'; @@ -360,18 +361,21 @@ class _DmItem extends StatelessWidget { @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); final selfUser = store.users[store.selfUserId]!; final designVariables = DesignVariables.of(context); final title = switch (narrow.otherRecipientIds) { // TODO dedupe with [RecentDmConversationsItem] [] => selfUser.fullName, - [var otherUserId] => store.users[otherUserId]?.fullName ?? '(unknown user)', + [var otherUserId] => store.users[otherUserId]?.fullName ?? zulipLocalizations.unknownUserName, // TODO(i18n): List formatting, like you can do in JavaScript: // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Shu']) // // 'Chris、Greg、Alya、Shu' - _ => narrow.otherRecipientIds.map((id) => store.users[id]?.fullName ?? '(unknown user)').join(', '), + _ => narrow.otherRecipientIds.map((id) => + store.users[id]?.fullName ?? zulipLocalizations.unknownUserName + ).join(', '), }; return Material( diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 8c32a12115..726f3394e2 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -28,55 +28,55 @@ import 'theme.dart'; /// Message-list styles that differ between light and dark themes. class MessageListTheme extends ThemeExtension { MessageListTheme.light() : - this._( - dateSeparator: Colors.black, - dateSeparatorText: const HSLColor.fromAHSL(0.75, 0, 0, 0.15).toColor(), - dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(), - messageTimestamp: const HSLColor.fromAHSL(0.8, 0, 0, 0.2).toColor(), - recipientHeaderText: const HSLColor.fromAHSL(1, 0, 0, 0.15).toColor(), - senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.08, 0.65).toColor(), - senderName: const HSLColor.fromAHSL(1, 0, 0, 0.2).toColor(), - streamMessageBgDefault: Colors.white, - streamRecipientHeaderChevronRight: Colors.black.withValues(alpha: 0.3), - - // From the Figma mockup at: - // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=132-9684 - // See discussion about design at: - // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20unread.20marker/near/1658008 - // (Web uses a left-to-right gradient from hsl(217deg 64% 59%) to transparent, - // in both light and dark theme.) - unreadMarker: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor(), - - unreadMarkerGap: Colors.white.withValues(alpha: 0.6), - - // TODO(design) this seems ad-hoc; is there a better color? - unsubscribedStreamRecipientHeaderBg: const Color(0xfff5f5f5), - ); + this._( + dateSeparator: Colors.black, + dateSeparatorText: const HSLColor.fromAHSL(0.75, 0, 0, 0.15).toColor(), + dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(), + messageTimestamp: const HSLColor.fromAHSL(0.8, 0, 0, 0.2).toColor(), + recipientHeaderText: const HSLColor.fromAHSL(1, 0, 0, 0.15).toColor(), + senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.08, 0.65).toColor(), + senderName: const HSLColor.fromAHSL(1, 0, 0, 0.2).toColor(), + streamMessageBgDefault: Colors.white, + streamRecipientHeaderChevronRight: Colors.black.withValues(alpha: 0.3), + + // From the Figma mockup at: + // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=132-9684 + // See discussion about design at: + // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20unread.20marker/near/1658008 + // (Web uses a left-to-right gradient from hsl(217deg 64% 59%) to transparent, + // in both light and dark theme.) + unreadMarker: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor(), + + unreadMarkerGap: Colors.white.withValues(alpha: 0.6), + + // TODO(design) this seems ad-hoc; is there a better color? + unsubscribedStreamRecipientHeaderBg: const Color(0xfff5f5f5), + ); MessageListTheme.dark() : - this._( - dateSeparator: Colors.white, - dateSeparatorText: const HSLColor.fromAHSL(0.75, 0, 0, 1).toColor(), - dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(), - messageTimestamp: const HSLColor.fromAHSL(0.8, 0, 0, 0.85).toColor(), - recipientHeaderText: const HSLColor.fromAHSL(0.8, 0, 0, 1).toColor(), - senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.05, 0.5).toColor(), - senderName: const HSLColor.fromAHSL(0.85, 0, 0, 1).toColor(), - streamMessageBgDefault: const HSLColor.fromAHSL(1, 0, 0, 0.15).toColor(), - streamRecipientHeaderChevronRight: Colors.white.withValues(alpha: 0.3), - - // 0.75 opacity from here: - // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=807-33998&m=dev - // Discussion, some weeks after the discussion linked on the light variant: - // https://github.com/zulip/zulip-flutter/pull/317#issuecomment-1784311663 - // where Vlad includes screenshots that look like they're from there. - unreadMarker: const HSLColor.fromAHSL(0.75, 227, 0.78, 0.59).toColor(), - - unreadMarkerGap: Colors.transparent, - - // TODO(design) this is ad-hoc and untested; is there a better color? - unsubscribedStreamRecipientHeaderBg: const Color(0xff0a0a0a), - ); + this._( + dateSeparator: Colors.white, + dateSeparatorText: const HSLColor.fromAHSL(0.75, 0, 0, 1).toColor(), + dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(), + messageTimestamp: const HSLColor.fromAHSL(0.8, 0, 0, 0.85).toColor(), + recipientHeaderText: const HSLColor.fromAHSL(0.8, 0, 0, 1).toColor(), + senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.05, 0.5).toColor(), + senderName: const HSLColor.fromAHSL(0.85, 0, 0, 1).toColor(), + streamMessageBgDefault: const HSLColor.fromAHSL(1, 0, 0, 0.15).toColor(), + streamRecipientHeaderChevronRight: Colors.white.withValues(alpha: 0.3), + + // 0.75 opacity from here: + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=807-33998&m=dev + // Discussion, some weeks after the discussion linked on the light variant: + // https://github.com/zulip/zulip-flutter/pull/317#issuecomment-1784311663 + // where Vlad includes screenshots that look like they're from there. + unreadMarker: const HSLColor.fromAHSL(0.75, 227, 0.78, 0.59).toColor(), + + unreadMarkerGap: Colors.transparent, + + // TODO(design) this is ad-hoc and untested; is there a better color? + unsubscribedStreamRecipientHeaderBg: const Color(0xff0a0a0a), + ); MessageListTheme._({ required this.dateSeparator, @@ -185,9 +185,9 @@ class MessageListPage extends StatefulWidget { const MessageListPage({super.key, required this.initNarrow}); static Route buildRoute({int? accountId, BuildContext? context, - required Narrow narrow}) { + required Narrow narrow}) { return MaterialAccountWidgetRoute(accountId: accountId, context: context, - page: MessageListPage(initNarrow: narrow)); + page: MessageListPage(initNarrow: narrow)); } /// The [MessageListPageState] above this context in the tree. @@ -247,8 +247,8 @@ class _MessageListPageState extends State implements MessageLis case TopicNarrow(:final streamId): final subscription = store.subscriptions[streamId]; appBarBackgroundColor = subscription != null - ? colorSwatchFor(context, subscription).barBackground - : messageListTheme.unsubscribedStreamRecipientHeaderBg; + ? colorSwatchFor(context, subscription).barBackground + : messageListTheme.unsubscribedStreamRecipientHeaderBg; // All recipient headers will match this color; remove distracting line // (but are recipient headers even needed for topic narrows?) removeAppBarBottomBorder = true; @@ -265,47 +265,47 @@ class _MessageListPageState extends State implements MessageLis // The helper [_getEffectiveCenterTitle] relies on the fact that we // have at most one action here. (actions ??= []).add(IconButton( - icon: const Icon(ZulipIcons.message_feed), - tooltip: zulipLocalizations.channelFeedButtonTooltip, - onPressed: () => Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: ChannelNarrow(streamId))))); + icon: const Icon(ZulipIcons.message_feed), + tooltip: zulipLocalizations.channelFeedButtonTooltip, + onPressed: () => Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: ChannelNarrow(streamId))))); } return Scaffold( - appBar: ZulipAppBar( - title: MessageListAppBarTitle(narrow: narrow), - actions: actions, - backgroundColor: appBarBackgroundColor, - shape: removeAppBarBottomBorder - ? const Border() - : null, // i.e., inherit - ), - // TODO question for Vlad: for a stream view, should we set the Scaffold's - // [backgroundColor] based on stream color, as in this frame: - // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=132%3A9684&mode=dev - // That's not obviously preferred over the default background that - // we matched to the Figma in 21dbae120. See another frame, which uses that: - // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=147%3A9088&mode=dev - body: Builder( - builder: (BuildContext context) => Center( - child: Column(children: [ - MediaQuery.removePadding( - // Scaffold knows about the app bar, and so has run this - // BuildContext, which is under `body`, through - // MediaQuery.removePadding with `removeTop: true`. - context: context, - - // The compose box, when present, pads the bottom inset. - // TODO(#311) If we have a bottom nav, it will pad the bottom - // inset, and this should always be true. - removeBottom: ComposeBox.hasComposeBox(narrow), - - child: Expanded( - child: MessageList(narrow: narrow, onNarrowChanged: _narrowChanged))), - if (ComposeBox.hasComposeBox(narrow)) - ComposeBox(key: _composeBoxKey, narrow: narrow) - ])))); + appBar: ZulipAppBar( + title: MessageListAppBarTitle(narrow: narrow), + actions: actions, + backgroundColor: appBarBackgroundColor, + shape: removeAppBarBottomBorder + ? const Border() + : null, // i.e., inherit + ), + // TODO question for Vlad: for a stream view, should we set the Scaffold's + // [backgroundColor] based on stream color, as in this frame: + // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=132%3A9684&mode=dev + // That's not obviously preferred over the default background that + // we matched to the Figma in 21dbae120. See another frame, which uses that: + // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=147%3A9088&mode=dev + body: Builder( + builder: (BuildContext context) => Center( + child: Column(children: [ + MediaQuery.removePadding( + // Scaffold knows about the app bar, and so has run this + // BuildContext, which is under `body`, through + // MediaQuery.removePadding with `removeTop: true`. + context: context, + + // The compose box, when present, pads the bottom inset. + // TODO(#311) If we have a bottom nav, it will pad the bottom + // inset, and this should always be true. + removeBottom: ComposeBox.hasComposeBox(narrow), + + child: Expanded( + child: MessageList(narrow: narrow, onNarrowChanged: _narrowChanged))), + if (ComposeBox.hasComposeBox(narrow)) + ComposeBox(key: _composeBoxKey, narrow: narrow) + ])))); } } @@ -320,16 +320,17 @@ class MessageListAppBarTitle extends StatelessWidget { // A null [Icon.icon] makes a blank space. final icon = stream != null ? iconDataForStream(stream) : null; return Row( - mainAxisSize: MainAxisSize.min, - // TODO(design): The vertical alignment of the stream privacy icon is a bit ad hoc. - // For screenshots of some experiments, see: - // https://github.com/zulip/zulip-flutter/pull/219#discussion_r1281024746 - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(size: 16, icon), - const SizedBox(width: 4), - Flexible(child: Text(stream?.name ?? '(unknown channel)')), - ]); + mainAxisSize: MainAxisSize.min, + // TODO(design): The vertical alignment of the stream privacy icon is a bit ad hoc. + // For screenshots of some experiments, see: + // https://github.com/zulip/zulip-flutter/pull/219#discussion_r1281024746 + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(size: 16, icon), + const SizedBox(width: 4), + // TODO(i18n): provide translation for 'unknown channel' + Flexible(child: Text(stream?.name ?? '(unknown channel)')), + ]); } Widget _buildTopicRow(BuildContext context, { @@ -339,21 +340,21 @@ class MessageListAppBarTitle extends StatelessWidget { final store = PerAccountStoreWidget.of(context); final designVariables = DesignVariables.of(context); final icon = stream == null ? null - : iconDataForTopicVisibilityPolicy( - store.topicVisibilityPolicy(stream.streamId, topic)); + : iconDataForTopicVisibilityPolicy( + store.topicVisibilityPolicy(stream.streamId, topic)); return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible(child: Text(topic, style: const TextStyle( - fontSize: 13, - ).merge(weightVariableTextStyle(context)))), - if (icon != null) - Padding( - padding: const EdgeInsetsDirectional.only(start: 4), - child: Icon(icon, - // TODO(design) copies the recipient header in web; is there a better color? - color: designVariables.colorMessageHeaderIconInteractive, size: 14)), - ]); + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: Text(topic, style: const TextStyle( + fontSize: 13, + ).merge(weightVariableTextStyle(context)))), + if (icon != null) + Padding( + padding: const EdgeInsetsDirectional.only(start: 4), + child: Icon(icon, + // TODO(design) copies the recipient header in web; is there a better color? + color: designVariables.colorMessageHeaderIconInteractive, size: 14)), + ]); } // TODO(upstream): provide an API for this @@ -404,25 +405,26 @@ class MessageListAppBarTitle extends StatelessWidget { final stream = store.streams[streamId]; final centerTitle = _getEffectiveCenterTitle(theme); return SizedBox( - width: double.infinity, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onLongPress: () => showTopicActionSheet(context, - channelId: streamId, topic: topic), - child: Column( - crossAxisAlignment: centerTitle ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - children: [ - _buildStreamRow(context, stream: stream), - _buildTopicRow(context, stream: stream, topic: topic), - ]))); + width: double.infinity, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPress: () => showTopicActionSheet(context, + channelId: streamId, topic: topic), + child: Column( + crossAxisAlignment: centerTitle ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + _buildStreamRow(context, stream: stream), + _buildTopicRow(context, stream: stream, topic: topic), + ]))); case DmNarrow(:var otherRecipientIds): final store = PerAccountStoreWidget.of(context); if (otherRecipientIds.isEmpty) { return const Text("DMs with yourself"); } else { - final names = otherRecipientIds.map((id) => store.users[id]?.fullName ?? '(unknown user)'); + final names = otherRecipientIds.map((id) => + store.users[id]?.fullName ?? zulipLocalizations.unknownUserName); return Text("DMs with ${names.join(", ")}"); // TODO show avatars } } @@ -539,26 +541,26 @@ class _MessageListState extends State with PerAccountStoreAwareStat // TODO(#311) Remove as unnecessary if we do a bottom nav. // The nav will pad the bottom inset, and an ancestor of this widget // will have a `MediaQuery.removePadding` with `removeBottom: true`. - bottom: false, - - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 760), - child: NotificationListener( - onNotification: _handleScrollMetricsNotification, - child: Stack( - children: [ - _buildListView(context), - Positioned( - bottom: 0, - right: 0, - // TODO(#311) SafeArea shouldn't be needed if we have a - // bottom nav. That will pad the bottom inset. - child: SafeArea( - child: ScrollToBottomButton( - scrollController: scrollController, - visibleValue: _scrollToBottomVisibleValue))), - ]))))); + bottom: false, + + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: NotificationListener( + onNotification: _handleScrollMetricsNotification, + child: Stack( + children: [ + _buildListView(context), + Positioned( + bottom: 0, + right: 0, + // TODO(#311) SafeArea shouldn't be needed if we have a + // bottom nav. That will pad the bottom inset. + child: SafeArea( + child: ScrollToBottomButton( + scrollController: scrollController, + visibleValue: _scrollToBottomVisibleValue))), + ]))))); } Widget _buildListView(BuildContext context) { @@ -566,42 +568,42 @@ class _MessageListState extends State with PerAccountStoreAwareStat const centerSliverKey = ValueKey('center sliver'); Widget sliver = SliverStickyHeaderList( - headerPlacement: HeaderPlacement.scrollingStart, - delegate: SliverChildBuilderDelegate( - // To preserve state across rebuilds for individual [MessageItem] - // widgets as the size of [MessageListView.items] changes we need - // to match old widgets by their key to their new position in - // the list. - // - // The keys are of type [ValueKey] with a value of [Message.id] - // and here we use a O(log n) binary search method. This could - // be improved but for now it only triggers for materialized - // widgets. As a simple test, flinging through Combined feed in - // CZO on a Pixel 5, this only runs about 10 times per rebuild - // and the timing for each call is <100 microseconds. - // - // Non-message items (e.g., start and end markers) that do not - // have state that needs to be preserved have not been given keys - // and will not trigger this callback. - findChildIndexCallback: (Key key) { - final valueKey = key as ValueKey; - final index = model!.findItemWithMessageId(valueKey.value); - if (index == -1) return null; - return length - 1 - (index - 3); - }, - childCount: length + 3, - (context, i) { - // To reinforce that the end of the feed has been reached: - // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603 - if (i == 0) return const SizedBox(height: 36); - - if (i == 1) return MarkAsReadWidget(narrow: widget.narrow); - - if (i == 2) return TypingStatusWidget(narrow: widget.narrow); - - final data = model!.items[length - 1 - (i - 3)]; - return _buildItem(data, i); - })); + headerPlacement: HeaderPlacement.scrollingStart, + delegate: SliverChildBuilderDelegate( + // To preserve state across rebuilds for individual [MessageItem] + // widgets as the size of [MessageListView.items] changes we need + // to match old widgets by their key to their new position in + // the list. + // + // The keys are of type [ValueKey] with a value of [Message.id] + // and here we use a O(log n) binary search method. This could + // be improved but for now it only triggers for materialized + // widgets. As a simple test, flinging through Combined feed in + // CZO on a Pixel 5, this only runs about 10 times per rebuild + // and the timing for each call is <100 microseconds. + // + // Non-message items (e.g., start and end markers) that do not + // have state that needs to be preserved have not been given keys + // and will not trigger this callback. + findChildIndexCallback: (Key key) { + final valueKey = key as ValueKey; + final index = model!.findItemWithMessageId(valueKey.value); + if (index == -1) return null; + return length - 1 - (index - 3); + }, + childCount: length + 3, + (context, i) { + // To reinforce that the end of the feed has been reached: + // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603 + if (i == 0) return const SizedBox(height: 36); + + if (i == 1) return MarkAsReadWidget(narrow: widget.narrow); + + if (i == 2) return TypingStatusWidget(narrow: widget.narrow); + + final data = model!.items[length - 1 - (i - 3)]; + return _buildItem(data, i); + })); if (!ComposeBox.hasComposeBox(widget.narrow)) { // TODO(#311) If we have a bottom nav, it will pad the bottom @@ -613,57 +615,57 @@ class _MessageListState extends State with PerAccountStoreAwareStat // TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or // similar) if that is ever offered: // https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849 - keyboardDismissBehavior: switch (Theme.of(context).platform) { + keyboardDismissBehavior: switch (Theme.of(context).platform) { // This seems to offer the only built-in way to close the keyboard // on iOS. It's not ideal; see TODO above. - TargetPlatform.iOS => ScrollViewKeyboardDismissBehavior.onDrag, + TargetPlatform.iOS => ScrollViewKeyboardDismissBehavior.onDrag, // The Android keyboard seems to have a built-in close button. - _ => ScrollViewKeyboardDismissBehavior.manual, - }, - - controller: scrollController, - semanticChildCount: length + 2, - anchor: 1.0, - center: centerSliverKey, - - slivers: [ - sliver, - - // This is a trivial placeholder that occupies no space. Its purpose is - // to have the key that's passed to [ScrollView.center], and so to cause - // the above [SliverStickyHeaderList] to run from bottom to top. - const SliverToBoxAdapter(key: centerSliverKey), - ]); + _ => ScrollViewKeyboardDismissBehavior.manual, + }, + + controller: scrollController, + semanticChildCount: length + 2, + anchor: 1.0, + center: centerSliverKey, + + slivers: [ + sliver, + + // This is a trivial placeholder that occupies no space. Its purpose is + // to have the key that's passed to [ScrollView.center], and so to cause + // the above [SliverStickyHeaderList] to run from bottom to top. + const SliverToBoxAdapter(key: centerSliverKey), + ]); } Widget _buildItem(MessageListItem data, int i) { switch (data) { case MessageListHistoryStartItem(): return const Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 16.0), - child: Text("No earlier messages."))); // TODO use an icon + child: Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: Text("No earlier messages."))); // TODO use an icon case MessageListLoadingItem(): return const Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 16.0), - child: CircularProgressIndicator())); // TODO perhaps a different indicator + child: Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: CircularProgressIndicator())); // TODO perhaps a different indicator case MessageListRecipientHeaderItem(): final header = RecipientHeader(message: data.message, narrow: widget.narrow); return StickyHeaderItem(allowOverflow: true, - header: header, child: header); + header: header, child: header); case MessageListDateSeparatorItem(): final header = RecipientHeader(message: data.message, narrow: widget.narrow); return StickyHeaderItem(allowOverflow: true, - header: header, - child: DateSeparator(message: data.message)); + header: header, + child: DateSeparator(message: data.message)); case MessageListMessageItem(): final header = RecipientHeader(message: data.message, narrow: widget.narrow); return MessageItem( - key: ValueKey(data.message.id), - header: header, - trailingWhitespace: i == 1 ? 8 : 11, - item: data); + key: ValueKey(data.message.id), + header: header, + trailingWhitespace: i == 1 ? 8 : 11, + item: data); } } } @@ -679,26 +681,26 @@ class ScrollToBottomButton extends StatelessWidget { final durationMsAtSpeedLimit = (1000 * distance / 8000).ceil(); final durationMs = max(300, durationMsAtSpeedLimit); return scrollController.animateTo( - 0, - duration: Duration(milliseconds: durationMs), - curve: Curves.ease); + 0, + duration: Duration(milliseconds: durationMs), + curve: Curves.ease); } @override Widget build(BuildContext context) { return ValueListenableBuilder( - valueListenable: visibleValue, - builder: (BuildContext context, bool value, Widget? child) { - return (value && child != null) ? child : const SizedBox.shrink(); - }, - // TODO: fix hardcoded values for size and style here - child: IconButton( - tooltip: "Scroll to bottom", - icon: const Icon(Icons.expand_circle_down_rounded), - iconSize: 40, - // Web has the same color in light and dark mode. - color: const HSLColor.fromAHSL(0.5, 240, 0.96, 0.68).toColor(), - onPressed: _navigateToBottom)); + valueListenable: visibleValue, + builder: (BuildContext context, bool value, Widget? child) { + return (value && child != null) ? child : const SizedBox.shrink(); + }, + // TODO: fix hardcoded values for size and style here + child: IconButton( + tooltip: "Scroll to bottom", + icon: const Icon(Icons.expand_circle_down_rounded), + iconSize: 40, + // Web has the same color in light and dark mode. + color: const HSLColor.fromAHSL(0.5, 240, 0.96, 0.68).toColor(), + onPressed: _navigateToBottom)); } } @@ -745,20 +747,20 @@ class _TypingStatusWidgetState extends State with PerAccount if (typistIds.isEmpty) return const SizedBox(); final text = switch (typistIds.length) { 1 => localizations.onePersonTyping( - store.users[typistIds.first]?.fullName ?? localizations.unknownUserName), + store.users[typistIds.first]?.fullName ?? localizations.unknownUserName), 2 => localizations.twoPeopleTyping( - store.users[typistIds.first]?.fullName ?? localizations.unknownUserName, - store.users[typistIds.last]?.fullName ?? localizations.unknownUserName), + store.users[typistIds.first]?.fullName ?? localizations.unknownUserName, + store.users[typistIds.last]?.fullName ?? localizations.unknownUserName), _ => localizations.manyPeopleTyping, }; return Padding( - padding: const EdgeInsetsDirectional.only(start: 16, top: 2), - child: Text(text, - style: const TextStyle( - // Web has the same color in light and dark mode. - color: HslColor(0, 0, 53), - fontStyle: FontStyle.italic))); + padding: const EdgeInsetsDirectional.only(start: 16, top: 2), + child: Text(text, + style: const TextStyle( + // Web has the same color in light and dark mode. + color: HslColor(0, 0, 53), + fontStyle: FontStyle.italic))); } } @@ -791,43 +793,43 @@ class _MarkAsReadWidgetState extends State { final messageListTheme = MessageListTheme.of(context); return IgnorePointer( - ignoring: areMessagesRead, - child: MarkAsReadAnimation( - loading: _loading, - hidden: areMessagesRead, - child: SizedBox(width: double.infinity, - // Design referenced from: - // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=132-9684&mode=design&t=jJwHzloKJ0TMOG4M-0 - child: Padding( - // vertical padding adjusted for tap target height (48px) of button - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10 - ((48 - 38) / 2)), - child: FilledButton.icon( - style: FilledButton.styleFrom( - splashFactory: NoSplash.splashFactory, - minimumSize: const Size.fromHeight(38), - textStyle: - // Restate [FilledButton]'s default, which inherits from - // [zulipTypography]… - Theme.of(context).textTheme.labelLarge! - // …then clobber some attributes to follow Figma: - .merge(TextStyle( - fontSize: 18, - letterSpacing: proportionalLetterSpacing(context, - kButtonTextLetterSpacingProportion, baseFontSize: 18), - height: (23 / 18)) - .merge(weightVariableTextStyle(context, wght: 400))), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)), - ).copyWith( - // Give the buttons a constant color regardless of whether their - // state is disabled, pressed, etc. We handle those states - // separately, via MarkAsReadAnimation. - foregroundColor: const WidgetStatePropertyAll(Colors.white), - iconColor: const WidgetStatePropertyAll(Colors.white), - backgroundColor: WidgetStatePropertyAll(messageListTheme.unreadMarker), - ), - onPressed: _loading ? null : () => _handlePress(context), - icon: const Icon(Icons.playlist_add_check), - label: Text(zulipLocalizations.markAllAsReadLabel)))))); + ignoring: areMessagesRead, + child: MarkAsReadAnimation( + loading: _loading, + hidden: areMessagesRead, + child: SizedBox(width: double.infinity, + // Design referenced from: + // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=132-9684&mode=design&t=jJwHzloKJ0TMOG4M-0 + child: Padding( + // vertical padding adjusted for tap target height (48px) of button + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10 - ((48 - 38) / 2)), + child: FilledButton.icon( + style: FilledButton.styleFrom( + splashFactory: NoSplash.splashFactory, + minimumSize: const Size.fromHeight(38), + textStyle: + // Restate [FilledButton]'s default, which inherits from + // [zulipTypography]… + Theme.of(context).textTheme.labelLarge! + // …then clobber some attributes to follow Figma: + .merge(TextStyle( + fontSize: 18, + letterSpacing: proportionalLetterSpacing(context, + kButtonTextLetterSpacingProportion, baseFontSize: 18), + height: (23 / 18)) + .merge(weightVariableTextStyle(context, wght: 400))), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)), + ).copyWith( + // Give the buttons a constant color regardless of whether their + // state is disabled, pressed, etc. We handle those states + // separately, via MarkAsReadAnimation. + foregroundColor: const WidgetStatePropertyAll(Colors.white), + iconColor: const WidgetStatePropertyAll(Colors.white), + backgroundColor: WidgetStatePropertyAll(messageListTheme.unreadMarker), + ), + onPressed: _loading ? null : () => _handlePress(context), + icon: const Icon(Icons.playlist_add_check), + label: Text(zulipLocalizations.markAllAsReadLabel)))))); } } @@ -859,18 +861,18 @@ class _MarkAsReadAnimationState extends State { @override Widget build(BuildContext context) { return GestureDetector( - onTapDown: (_) => _setIsPressed(true), - onTapUp: (_) => _setIsPressed(false), - onTapCancel: () => _setIsPressed(false), - child: AnimatedScale( - scale: _isPressed ? 0.95 : 1, - duration: const Duration(milliseconds: 100), - curve: Curves.easeOut, - child: AnimatedOpacity( - opacity: widget.hidden ? 0 : widget.loading ? 0.5 : 1, - duration: const Duration(milliseconds: 500), - curve: Curves.easeOut, - child: widget.child))); + onTapDown: (_) => _setIsPressed(true), + onTapUp: (_) => _setIsPressed(false), + onTapCancel: () => _setIsPressed(false), + child: AnimatedScale( + scale: _isPressed ? 0.95 : 1, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + child: AnimatedOpacity( + opacity: widget.hidden ? 0 : widget.loading ? 0.5 : 1, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOut, + child: widget.child))); } } @@ -899,7 +901,7 @@ class RecipientHeader extends StatelessWidget { final message = this.message; return switch (message) { StreamMessage() => StreamMessageRecipientHeader(message: message, - showStream: _containsDifferentChannels(narrow)), + showStream: _containsDifferentChannels(narrow)), DmMessage() => DmRecipientHeader(message: message), }; } @@ -923,25 +925,25 @@ class DateSeparator extends StatelessWidget { // TODO(#681) use different color for DM messages return ColoredBox(color: messageListTheme.streamMessageBgDefault, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2), - child: Row(children: [ - Expanded( - child: SizedBox(height: 0, - child: DecoratedBox( - decoration: BoxDecoration( - border: Border( - bottom: line))))), - Padding(padding: const EdgeInsets.fromLTRB(2, 0, 2, textBottomPadding), - child: DateText( - fontSize: 16, - height: (16 / 16), - timestamp: message.timestamp)), - SizedBox(height: 0, width: 12, - child: DecoratedBox( - decoration: BoxDecoration( - border: Border( - bottom: line)))), - ])), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2), + child: Row(children: [ + Expanded( + child: SizedBox(height: 0, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: line))))), + Padding(padding: const EdgeInsets.fromLTRB(2, 0, 2, textBottomPadding), + child: DateText( + fontSize: 16, + height: (16 / 16), + timestamp: message.timestamp)), + SizedBox(height: 0, width: 12, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: line)))), + ])), ); } } @@ -963,16 +965,16 @@ class MessageItem extends StatelessWidget { final message = item.message; final messageListTheme = MessageListTheme.of(context); return StickyHeaderItem( - allowOverflow: !item.isLastInBlock, - header: header, - child: _UnreadMarker( - isRead: message.flags.contains(MessageFlag.read), - child: ColoredBox( - color: messageListTheme.streamMessageBgDefault, - child: Column(children: [ - MessageWithPossibleSender(item: item), - if (trailingWhitespace != null && item.isLastInBlock) SizedBox(height: trailingWhitespace!), - ])))); + allowOverflow: !item.isLastInBlock, + header: header, + child: _UnreadMarker( + isRead: message.flags.contains(MessageFlag.read), + child: ColoredBox( + color: messageListTheme.streamMessageBgDefault, + child: Column(children: [ + MessageWithPossibleSender(item: item), + if (trailingWhitespace != null && item.isLastInBlock) SizedBox(height: trailingWhitespace!), + ])))); } } @@ -987,26 +989,26 @@ class _UnreadMarker extends StatelessWidget { Widget build(BuildContext context) { final messageListTheme = MessageListTheme.of(context); return Stack( - children: [ - child, - Positioned( - top: 0, - left: 0, - bottom: 0, - width: 4, - child: AnimatedOpacity( - opacity: isRead ? 0 : 1, - // Web uses 2s and 0.3s durations, and a CSS ease-out curve. - // See zulip:web/styles/message_row.css . - duration: Duration(milliseconds: isRead ? 2000 : 300), - curve: Curves.easeOut, - child: DecoratedBox( - decoration: BoxDecoration( - color: messageListTheme.unreadMarker, - border: Border(left: BorderSide( - width: 1, - color: messageListTheme.unreadMarkerGap)))))), - ]); + children: [ + child, + Positioned( + top: 0, + left: 0, + bottom: 0, + width: 4, + child: AnimatedOpacity( + opacity: isRead ? 0 : 1, + // Web uses 2s and 0.3s durations, and a CSS ease-out curve. + // See zulip:web/styles/message_row.css . + duration: Duration(milliseconds: isRead ? 2000 : 300), + curve: Curves.easeOut, + child: DecoratedBox( + decoration: BoxDecoration( + color: messageListTheme.unreadMarker, + border: Border(left: BorderSide( + width: 1, + color: messageListTheme.unreadMarkerGap)))))), + ]); } } @@ -1049,79 +1051,80 @@ class StreamMessageRecipientHeader extends StatelessWidget { streamWidget = const SizedBox(width: 16); } else { final stream = store.streams[message.streamId]; + // TODO(i18n): provide translation for 'unknown channel' final streamName = stream?.name - ?? message.displayRecipient - ?? '(unknown channel)'; // TODO(log) + ?? message.displayRecipient + ?? '(unknown channel)'; // TODO(log) streamWidget = GestureDetector( - onTap: () => Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: ChannelNarrow(message.streamId))), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - // Figma specifies 5px horizontal spacing around an icon that's - // 18x18 and includes 1px padding. The icon SVG is flush with - // the edges, so make it 16x16 with 6px horizontal padding. - // Bottom padding added here to shift icon up to - // match alignment with text visually. - padding: const EdgeInsets.only(left: 6, right: 6, bottom: 3), - child: Icon(size: 16, color: iconColor, - // A null [Icon.icon] makes a blank space. - stream != null ? iconDataForStream(stream) : null)), - Padding( - padding: const EdgeInsets.symmetric(vertical: 11), - child: Text(streamName, - style: recipientHeaderTextStyle(context), - overflow: TextOverflow.ellipsis), - ), - Padding( - // Figma has 5px horizontal padding around an 8px wide icon. - // Icon is 16px wide here so horizontal padding is 1px. - padding: const EdgeInsets.symmetric(horizontal: 1), - child: Icon(size: 16, - color: messageListTheme.streamRecipientHeaderChevronRight, - ZulipIcons.chevron_right)), - ])); + onTap: () => Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: ChannelNarrow(message.streamId))), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + // Figma specifies 5px horizontal spacing around an icon that's + // 18x18 and includes 1px padding. The icon SVG is flush with + // the edges, so make it 16x16 with 6px horizontal padding. + // Bottom padding added here to shift icon up to + // match alignment with text visually. + padding: const EdgeInsets.only(left: 6, right: 6, bottom: 3), + child: Icon(size: 16, color: iconColor, + // A null [Icon.icon] makes a blank space. + stream != null ? iconDataForStream(stream) : null)), + Padding( + padding: const EdgeInsets.symmetric(vertical: 11), + child: Text(streamName, + style: recipientHeaderTextStyle(context), + overflow: TextOverflow.ellipsis), + ), + Padding( + // Figma has 5px horizontal padding around an 8px wide icon. + // Icon is 16px wide here so horizontal padding is 1px. + padding: const EdgeInsets.symmetric(horizontal: 1), + child: Icon(size: 16, + color: messageListTheme.streamRecipientHeaderChevronRight, + ZulipIcons.chevron_right)), + ])); } final topicWidget = Padding( - padding: const EdgeInsets.symmetric(vertical: 11), - child: Row( - children: [ - Flexible( - child: Text(topic, - // TODO: Give a way to see the whole topic (maybe a - // long-press interaction?) - overflow: TextOverflow.ellipsis, - style: recipientHeaderTextStyle(context))), - const SizedBox(width: 4), - // TODO(design) copies the recipient header in web; is there a better color? - Icon(size: 14, color: designVariables.colorMessageHeaderIconInteractive, - // A null [Icon.icon] makes a blank space. - iconDataForTopicVisibilityPolicy( - store.topicVisibilityPolicy(message.streamId, topic))), - ])); + padding: const EdgeInsets.symmetric(vertical: 11), + child: Row( + children: [ + Flexible( + child: Text(topic, + // TODO: Give a way to see the whole topic (maybe a + // long-press interaction?) + overflow: TextOverflow.ellipsis, + style: recipientHeaderTextStyle(context))), + const SizedBox(width: 4), + // TODO(design) copies the recipient header in web; is there a better color? + Icon(size: 14, color: designVariables.colorMessageHeaderIconInteractive, + // A null [Icon.icon] makes a blank space. + iconDataForTopicVisibilityPolicy( + store.topicVisibilityPolicy(message.streamId, topic))), + ])); return GestureDetector( - onTap: () => Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: TopicNarrow.ofMessage(message))), - onLongPress: () => showTopicActionSheet(context, - channelId: message.streamId, topic: topic), - child: ColoredBox( - color: backgroundColor, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // TODO(#282): Long stream name will break layout; find a fix. - streamWidget, - Expanded(child: topicWidget), - // TODO topic links? - // Then web also has edit/resolve/mute buttons. Skip those for mobile. - RecipientHeaderDate(message: message), - ]))); + onTap: () => Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: TopicNarrow.ofMessage(message))), + onLongPress: () => showTopicActionSheet(context, + channelId: message.streamId, topic: topic), + child: ColoredBox( + color: backgroundColor, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // TODO(#282): Long stream name will break layout; find a fix. + streamWidget, + Expanded(child: topicWidget), + // TODO topic links? + // Then web also has edit/resolve/mute buttons. Skip those for mobile. + RecipientHeaderDate(message: message), + ]))); } } @@ -1137,10 +1140,10 @@ class DmRecipientHeader extends StatelessWidget { final String title; if (message.allRecipientIds.length > 1) { title = zulipLocalizations.messageListGroupYouAndOthers(message.allRecipientIds - .where((id) => id != store.selfUserId) - .map((id) => store.users[id]?.fullName ?? zulipLocalizations.unknownUserName) - .sorted() - .join(", ")); + .where((id) => id != store.selfUserId) + .map((id) => store.users[id]?.fullName ?? zulipLocalizations.unknownUserName) + .sorted() + .join(", ")); } else { // TODO pick string; web has glitchy "You and $yourname" title = zulipLocalizations.messageListGroupYouWithYourself; @@ -1149,28 +1152,28 @@ class DmRecipientHeader extends StatelessWidget { final messageListTheme = MessageListTheme.of(context); return GestureDetector( - onTap: () => Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: DmNarrow.ofMessage(message, selfUserId: store.selfUserId))), - child: ColoredBox( - color: messageListTheme.dmRecipientHeaderBg, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Icon( - color: messageListTheme.recipientHeaderText, - size: 16, - ZulipIcons.user)), - Expanded( - child: Text(title, - style: recipientHeaderTextStyle(context), - overflow: TextOverflow.ellipsis)), - RecipientHeaderDate(message: message), - ])))); + onTap: () => Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: DmNarrow.ofMessage(message, selfUserId: store.selfUserId))), + child: ColoredBox( + color: messageListTheme.dmRecipientHeaderBg, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Icon( + color: messageListTheme.recipientHeaderText, + size: 16, + ZulipIcons.user)), + Expanded( + child: Text(title, + style: recipientHeaderTextStyle(context), + overflow: TextOverflow.ellipsis)), + RecipientHeaderDate(message: message), + ])))); } } @@ -1191,14 +1194,14 @@ class RecipientHeaderDate extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.fromLTRB(10, 0, 16, 0), - child: DateText( - fontSize: 16, - // In Figma this has a line-height of 19, but using 18 - // here to match the stream/topic text widgets helps - // to align all the text to the same baseline. - height: (18 / 16), - timestamp: message.timestamp)); + padding: const EdgeInsets.fromLTRB(10, 0, 16, 0), + child: DateText( + fontSize: 16, + // In Figma this has a line-height of 19, but using 18 + // here to match the stream/topic text widgets helps + // to align all the text to the same baseline. + height: (18 / 16), + timestamp: message.timestamp)); } } @@ -1219,29 +1222,29 @@ class DateText extends StatelessWidget { final messageListTheme = MessageListTheme.of(context); final zulipLocalizations = ZulipLocalizations.of(context); return Text( - style: TextStyle( - color: messageListTheme.dateSeparatorText, - fontSize: fontSize, - height: height, - // This is equivalent to css `all-small-caps`, see: - // https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-caps#all-small-caps - fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], - ), - formatHeaderDate( - zulipLocalizations, - DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), - now: DateTime.now())); + style: TextStyle( + color: messageListTheme.dateSeparatorText, + fontSize: fontSize, + height: height, + // This is equivalent to css `all-small-caps`, see: + // https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-caps#all-small-caps + fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], + ), + formatHeaderDate( + zulipLocalizations, + DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + now: DateTime.now())); } } @visibleForTesting String formatHeaderDate( - ZulipLocalizations zulipLocalizations, - DateTime dateTime, { - required DateTime now, -}) { + ZulipLocalizations zulipLocalizations, + DateTime dateTime, { + required DateTime now, + }) { assert(!dateTime.isUtc && !now.isUtc, - '`dateTime` and `now` need to be in local time.'); + '`dateTime` and `now` need to be in local time.'); if (dateTime.year == now.year && dateTime.month == now.month && @@ -1250,8 +1253,8 @@ String formatHeaderDate( } final yesterday = now - .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0) - .add(const Duration(days: -1)); + .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0) + .add(const Duration(days: -1)); if (dateTime.year == yesterday.year && dateTime.month == yesterday.month && dateTime.day == yesterday.day) { @@ -1291,48 +1294,48 @@ class MessageWithPossibleSender extends StatelessWidget { Widget? senderRow; if (item.showSender) { final time = _kMessageTimestampFormat - .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); + .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); senderRow = Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: localizedTextBaseline(context), - children: [ - Flexible( - child: GestureDetector( - onTap: () => Navigator.push(context, - ProfilePage.buildRoute(context: context, - userId: message.senderId)), - child: Row( - children: [ - Avatar(size: 32, borderRadius: 3, - userId: message.senderId), - const SizedBox(width: 8), - Flexible( - child: Text(message.senderFullName, // TODO get from user data - style: TextStyle( - fontSize: 18, - height: (22 / 18), - color: messageListTheme.senderName, - ).merge(weightVariableTextStyle(context, wght: 600)), - overflow: TextOverflow.ellipsis)), - if (sender?.isBot ?? false) ...[ - const SizedBox(width: 5), - Icon( - ZulipIcons.bot, - size: 15, - color: messageListTheme.senderBotIcon, - ), - ], - ]))), - const SizedBox(width: 4), - Text(time, - style: TextStyle( - color: messageListTheme.messageTimestamp, - fontSize: 16, - height: (18 / 16), - fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], - ).merge(weightVariableTextStyle(context))), - ]); + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: localizedTextBaseline(context), + children: [ + Flexible( + child: GestureDetector( + onTap: () => Navigator.push(context, + ProfilePage.buildRoute(context: context, + userId: message.senderId)), + child: Row( + children: [ + Avatar(size: 32, borderRadius: 3, + userId: message.senderId), + const SizedBox(width: 8), + Flexible( + child: Text(message.senderFullName, // TODO get from user data + style: TextStyle( + fontSize: 18, + height: (22 / 18), + color: messageListTheme.senderName, + ).merge(weightVariableTextStyle(context, wght: 600)), + overflow: TextOverflow.ellipsis)), + if (sender?.isBot ?? false) ...[ + const SizedBox(width: 5), + Icon( + ZulipIcons.bot, + size: 15, + color: messageListTheme.senderBotIcon, + ), + ], + ]))), + const SizedBox(width: 4), + Text(time, + style: TextStyle( + color: messageListTheme.messageTimestamp, + fontSize: 16, + height: (18 / 16), + fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], + ).merge(weightVariableTextStyle(context))), + ]); } final localizations = ZulipLocalizations.of(context); @@ -1346,41 +1349,41 @@ class MessageWithPossibleSender extends StatelessWidget { } return GestureDetector( - behavior: HitTestBehavior.translucent, - onLongPress: () => showMessageActionSheet(context: context, message: message), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column(children: [ - if (senderRow != null) - Padding(padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), - child: senderRow), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: localizedTextBaseline(context), - children: [ - const SizedBox(width: 16), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - MessageContent(message: message, content: item.content), - if ((message.reactions?.total ?? 0) > 0) - ReactionChipsList(messageId: message.id, reactions: message.reactions!), - if (editStateText != null) - Text(editStateText, - textAlign: TextAlign.end, - style: TextStyle( - color: designVariables.labelEdited, - fontSize: 12, - height: (12 / 12), - letterSpacing: proportionalLetterSpacing( - context, 0.05, baseFontSize: 12))), - ])), - SizedBox(width: 16, - child: message.flags.contains(MessageFlag.starred) - ? Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star) - : null), - ]), - ]))); + behavior: HitTestBehavior.translucent, + onLongPress: () => showMessageActionSheet(context: context, message: message), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column(children: [ + if (senderRow != null) + Padding(padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), + child: senderRow), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: localizedTextBaseline(context), + children: [ + const SizedBox(width: 16), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MessageContent(message: message, content: item.content), + if ((message.reactions?.total ?? 0) > 0) + ReactionChipsList(messageId: message.id, reactions: message.reactions!), + if (editStateText != null) + Text(editStateText, + textAlign: TextAlign.end, + style: TextStyle( + color: designVariables.labelEdited, + fontSize: 12, + height: (12 / 12), + letterSpacing: proportionalLetterSpacing( + context, 0.05, baseFontSize: 12))), + ])), + SizedBox(width: 16, + child: message.flags.contains(MessageFlag.starred) + ? Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star) + : null), + ]), + ]))); } } diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index 57dd76a0ab..373ed1bd5f 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -290,8 +290,9 @@ class _UserWidget extends StatelessWidget { @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); final user = store.users[userId]; - final fullName = user?.fullName ?? '(unknown user)'; + final fullName = user?.fullName ?? zulipLocalizations.unknownUserName; return InkWell( onTap: () => Navigator.push(context, ProfilePage.buildRoute(context: context, diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index c9d3131591..16791fa72a 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -80,6 +80,7 @@ class RecentDmConversationsItem extends StatelessWidget { Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); final selfUser = store.users[store.selfUserId]!; + final zulipLocalizations = ZulipLocalizations.of(context); final designVariables = DesignVariables.of(context); @@ -94,13 +95,15 @@ class RecentDmConversationsItem extends StatelessWidget { // (should we offer a "spam folder" style summary screen of recent // 1:1 DM conversations from muted users?) final otherUser = store.users[otherUserId]; - title = otherUser?.fullName ?? '(unknown user)'; + title = otherUser?.fullName ?? zulipLocalizations.unknownUserName; avatar = AvatarImage(userId: otherUserId, size: _avatarSize); default: // TODO(i18n): List formatting, like you can do in JavaScript: // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya']) // // 'Chris、Greg、Alya' - title = narrow.otherRecipientIds.map((id) => store.users[id]?.fullName ?? '(unknown user)').join(', '); + title = narrow.otherRecipientIds.map((id) => + store.users[id]?.fullName ?? zulipLocalizations.unknownUserName + ).join(', '); avatar = ColoredBox(color: designVariables.groupDmConversationIconBg, child: Center( child: Icon(color: designVariables.groupDmConversationIcon,