Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 3 additions & 3 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1460,10 +1460,10 @@ class UpdateMachine {
}

void _reportToUserErrorConnectingToServer(Object error) {
final localizations = GlobalLocalizations.zulipLocalizations;
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
reportErrorToUserBriefly(
localizations.errorConnectingToServerShort,
details: localizations.errorConnectingToServerDetails(
zulipLocalizations.errorConnectingToServerShort,
details: zulipLocalizations.errorConnectingToServerDetails(
store.realmUrl.toString(), error.toString()));
}

Expand Down
104 changes: 100 additions & 4 deletions lib/widgets/action_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,102 @@ class BottomSheetEmptyContentPlaceholder extends StatelessWidget {
}
}

/// A bottom sheet that resizes, scrolls, and dismisses in response to dragging.
///
/// [header] is assumed to occupy the full width its parent allows.
/// (This is important for the clipping/shadow effect when [contentSliver]
/// scrolls under the header.)
///
/// The sheet's initial height and minimum height before dismissing
/// are set proportionally to the screen's height.
/// The screen's height is read from the parent's max-height constraint,
/// so the caller should not introduce widgets that interfere with that.
/// (Non-layout wrapper widgets such as [InheritedWidget]s are OK.)
///
/// The sheet's dismissal works like this:
/// - A "Close" button is offered.
/// - A drag-down or fling on the header or the [contentSliver]
/// causes those areas to shrink past a threshold at which the sheet
/// decides to dismiss.
/// - The [enableDrag] param of upstream's [showModalBottomSheet]
/// only seems to affect gesture handling on the Close button and its padding
/// (which are not part of the resizable/scrollable area):
/// - When true, the Close button responds to a downward fling by
/// sliding the sheet downward and dismissing it
/// (i.e. not by the usual behavior where the header- and-content height
/// shrinks past a threshold, causing dismissal).
/// - When false, the Close button doesn't respond to a downward fling.
class DraggableScrollableModalBottomSheet extends StatelessWidget {
const DraggableScrollableModalBottomSheet({
super.key,
required this.header,
required this.contentSliver,
});

final Widget header;
final Widget contentSliver;

@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
expand: false,
builder: (context, controller) {
final backgroundColor = Theme.of(context).bottomSheetTheme.backgroundColor!;

// The "inset shadow" effect in Figma is a bit awkwardly
// implemented here, and there might be a better factoring:
// 1. This effect leans on the abstraction that [contentSliver]
// is simply a scrollable area in its own viewport.
// We'd normally just wrap that viewport in [InsetShadowBox].
// 2. Really, though, the scrollable includes the header,
// pinned to the viewport top. We do this to support resizing
// (and dismiss-on-min-height) on gestures in the header, too,
// uniformly with the content.
// 3. So for the top shadow, we tack a shadow gradient onto the header,
// exploiting the header's pinning behavior to keep it fixed.
// 3. For the bottom, I haven't found a nice sliver-based implementation
// that supports pinning a shadow overlay at the viewport bottom.
// So for the bottom we use [InsetShadowBox] around the viewport,
// with just `bottom:` and no `top:`.

final headerWithShadow = Column(
mainAxisSize: MainAxisSize.min,
children: [
ColoredBox(
color: backgroundColor,
child: header),
SizedBox(height: 8, width: double.infinity,
child: DecoratedBox(decoration: fadeToTransparencyDecoration(
FadeToTransparencyDirection.down, backgroundColor))),
]);

return Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: InsetShadowBox(
bottom: 8,
color: backgroundColor,
child: CustomScrollView(
// The iOS default "bouncing" effect would look uncoordinated
// in the common case where overscroll co-occurs with
// shrinking the sheet past the threshold where it dismisses.
physics: ClampingScrollPhysics(),
controller: controller,
slivers: [
PinnedHeaderSliver(child: headerWithShadow),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've managed it here, modulo with a header instead of a drag handle,
by making sure the scrollable area extends through the top of the
sheet. (Done with a CustomScrollView, pinning the header to the
viewport top.)

Neat!

SliverPadding(
padding: EdgeInsets.only(bottom: 8),
sliver: contentSliver),
]))),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.close))
]);
});
}
}

/// A button in an action sheet.
///
/// When built from server data, the action sheet ignores changes in that data;
Expand Down Expand Up @@ -504,11 +600,11 @@ class CopyChannelLinkButton extends ActionSheetMenuItemButton {

@override
void onPressed() async {
final localizations = ZulipLocalizations.of(pageContext);
final zulipLocalizations = ZulipLocalizations.of(pageContext);
final store = PerAccountStoreWidget.of(pageContext);

PlatformActions.copyWithPopup(context: pageContext,
successContent: Text(localizations.successChannelLinkCopied),
successContent: Text(zulipLocalizations.successChannelLinkCopied),
data: ClipboardData(text: narrowLink(store, ChannelNarrow(channelId)).toString()));
}
}
Expand Down Expand Up @@ -915,11 +1011,11 @@ class CopyTopicLinkButton extends ActionSheetMenuItemButton {
}

@override void onPressed() async {
final localizations = ZulipLocalizations.of(pageContext);
final zulipLocalizations = ZulipLocalizations.of(pageContext);
final store = PerAccountStoreWidget.of(pageContext);

PlatformActions.copyWithPopup(context: pageContext,
successContent: Text(localizations.successTopicLinkCopied),
successContent: Text(zulipLocalizations.successTopicLinkCopied),
data: ClipboardData(text: narrowLink(store, narrow).toString()));
}
}
Expand Down
6 changes: 3 additions & 3 deletions lib/widgets/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -112,17 +112,17 @@ class ZulipApp extends StatefulWidget {
return;
}

final localizations = ZulipLocalizations.of(navigatorKey.currentContext!);
final zulipLocalizations = ZulipLocalizations.of(navigatorKey.currentContext!);
final newSnackBar = scaffoldMessenger!.showSnackBar(
snackBarAnimationStyle: AnimationStyle(
duration: const Duration(milliseconds: 200),
reverseDuration: const Duration(milliseconds: 50)),
SnackBar(
content: Text(message),
action: (details == null) ? null : SnackBarAction(
label: localizations.snackBarDetails,
label: zulipLocalizations.snackBarDetails,
onPressed: () => showErrorDialog(context: navigatorKey.currentContext!,
title: localizations.errorDialogTitle,
title: zulipLocalizations.errorDialogTitle,
message: details))));

_snackBarCount++;
Expand Down
20 changes: 10 additions & 10 deletions lib/widgets/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,8 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
@override
ComposeAutocompleteView initViewModel(BuildContext context, ComposeAutocompleteQuery query) {
final store = PerAccountStoreWidget.of(context);
final localizations = ZulipLocalizations.of(context);
return query.initViewModel(store: store, localizations: localizations,
final zulipLocalizations = ZulipLocalizations.of(context);
return query.initViewModel(store: store, localizations: zulipLocalizations,
narrow: narrow);
}

Expand Down Expand Up @@ -273,18 +273,18 @@ class MentionAutocompleteItem extends StatelessWidget {
}) {
final isDmNarrow = narrow is DmNarrow;
final isChannelWildcardAvailable = store.zulipFeatureLevel >= 247; // TODO(server-9)
final localizations = ZulipLocalizations.of(context);
final zulipLocalizations = ZulipLocalizations.of(context);
return switch (wildcardOption) {
WildcardMentionOption.all || WildcardMentionOption.everyone => isDmNarrow
? localizations.wildcardMentionAllDmDescription
? zulipLocalizations.wildcardMentionAllDmDescription
: isChannelWildcardAvailable
? localizations.wildcardMentionChannelDescription
: localizations.wildcardMentionStreamDescription,
WildcardMentionOption.channel => localizations.wildcardMentionChannelDescription,
? zulipLocalizations.wildcardMentionChannelDescription
: zulipLocalizations.wildcardMentionStreamDescription,
WildcardMentionOption.channel => zulipLocalizations.wildcardMentionChannelDescription,
WildcardMentionOption.stream => isChannelWildcardAvailable
? localizations.wildcardMentionChannelDescription
: localizations.wildcardMentionStreamDescription,
WildcardMentionOption.topic => localizations.wildcardMentionTopicDescription,
? zulipLocalizations.wildcardMentionChannelDescription
: zulipLocalizations.wildcardMentionStreamDescription,
WildcardMentionOption.topic => zulipLocalizations.wildcardMentionTopicDescription,
};
}

Expand Down
138 changes: 58 additions & 80 deletions lib/widgets/emoji_reaction.dart
Original file line number Diff line number Diff line change
Expand Up @@ -753,32 +753,18 @@ class _ViewReactionsState extends State<ViewReactions> with PerAccountStoreAware

@override
Widget build(BuildContext context) {
// TODO could pull out this layout/appearance code,
// focusing this widget only on state management
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ViewReactionsHeader(
messageId: widget.messageId,
reactionType: reactionType,
emojiCode: emojiCode,
onRequestSelect: _setSelection,
),
// TODO if all reactions (or whole message) disappeared,
// we show a message saying there are no reactions,
// but the layout shifts (the sheet's height changes dramatically);
// we should avoid this.
if (reactionType != null && emojiCode != null) Flexible(
child: ViewReactionsUserList(
messageId: widget.messageId,
reactionType: reactionType!,
emojiCode: emojiCode!,
emojiName: emojiName!)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.close))
]);
return DraggableScrollableModalBottomSheet(
header: ViewReactionsHeader(
messageId: widget.messageId,
reactionType: reactionType,
emojiCode: emojiCode,
onRequestSelect: _setSelection,
),
contentSliver: ViewReactionsUserListSliver(
messageId: widget.messageId,
reactionType: reactionType,
emojiCode: emojiCode,
emojiName: emojiName));
}
}

Expand Down Expand Up @@ -828,26 +814,27 @@ class ViewReactionsHeader extends StatelessWidget {
padding: const EdgeInsets.only(top: 16, bottom: 4),
child: InsetShadowBox(start: 8, end: 8,
color: designVariables.bgContextMenu,
child: SingleChildScrollView(
// TODO(upstream) we want to pass excludeFromSemantics: true
// to the underlying Scrollable to remove an unwanted node
// in accessibility focus traversal when there are many items.
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Semantics(
role: SemanticsRole.tabBar,
container: true,
explicitChildNodes: true,
label: zulipLocalizations.seeWhoReactedSheetHeaderLabel(reactions.total),
child: Row(
children: reactions.aggregated.mapIndexed((i, r) =>
_ViewReactionsEmojiItem(
reactionWithVotes: r,
position: _emojiItemPosition(i, reactions.aggregated.length),
selected: r.reactionType == reactionType && r.emojiCode == emojiCode,
onRequestSelect: onRequestSelect),
).toList()))))));
child: Center(
child: SingleChildScrollView(
// TODO(upstream) we want to pass excludeFromSemantics: true
// to the underlying Scrollable to remove an unwanted node
// in accessibility focus traversal when there are many items.
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Semantics(
role: SemanticsRole.tabBar,
container: true,
explicitChildNodes: true,
label: zulipLocalizations.seeWhoReactedSheetHeaderLabel(reactions.total),
child: Row(
children: reactions.aggregated.mapIndexed((i, r) =>
_ViewReactionsEmojiItem(
reactionWithVotes: r,
position: _emojiItemPosition(i, reactions.aggregated.length),
selected: r.reactionType == reactionType && r.emojiCode == emojiCode,
onRequestSelect: onRequestSelect),
).toList())))))));
}
}

Expand Down Expand Up @@ -955,7 +942,7 @@ class _ViewReactionsEmojiItem extends StatelessWidget {

// I *think* we're following the doc with this but it's hard to tell;
// I've only tested on iOS and I didn't notice a behavior change.
controlsNodes: {ViewReactionsUserList.semanticsIdentifier},
controlsNodes: {ViewReactionsUserListSliver.semanticsIdentifier},

selected: selected,
label: zulipLocalizations.seeWhoReactedSheetEmojiNameWithVoteCount(emojiName, count),
Expand All @@ -965,10 +952,9 @@ class _ViewReactionsEmojiItem extends StatelessWidget {
}
}


@visibleForTesting
class ViewReactionsUserList extends StatelessWidget {
const ViewReactionsUserList({
class ViewReactionsUserListSliver extends StatelessWidget {
const ViewReactionsUserListSliver({
super.key,
required this.messageId,
required this.reactionType,
Expand All @@ -977,17 +963,25 @@ class ViewReactionsUserList extends StatelessWidget {
});

final int messageId;
final ReactionType reactionType;
final String emojiCode;
final String emojiName;
final ReactionType? reactionType;
final String? emojiCode;
final String? emojiName;

static const semanticsIdentifier = 'view-reactions-user-list';

@override
Widget build(BuildContext context) {
final zulipLocalizations = ZulipLocalizations.of(context);
final store = PerAccountStoreWidget.of(context);
final designVariables = DesignVariables.of(context);

if (reactionType == null || emojiCode == null) {
// The emoji selection was cleared,
// which happens when the message is deleted or loses all its reactions.
// The sheet's header will have a message like
// "This message has no reactions."
return SliverPadding(padding: EdgeInsets.zero);
}
assert(emojiName != null);

final message = store.messages[messageId];

Expand All @@ -999,38 +993,22 @@ class ViewReactionsUserList extends StatelessWidget {
// Muted users will be shown as muted.)

if (userIds == null) {
// This reaction lost all its votes, or the message was deleted.
return SizedBox.shrink();
// The selected emoji lost all its votes. This won't show long if at all;
// a different emoji will be automatically selected if there is one.
return SliverPadding(padding: EdgeInsets.zero);
}

Widget result = SizedBox(
height: 400, // TODO(design) tune
child: InsetShadowBox(
top: 8,
bottom: 8,
color: designVariables.bgContextMenu,
// TODO(upstream) we want to pass excludeFromSemantics: true
// to the underlying Scrollable to remove an unwanted node
// in accessibility focus traversal when there are many items.
child: ListView.builder(
padding: EdgeInsets.only(
// The Figma excludes the 8px top padding, which is unusual with the
// shadow effect (our InsetShadowBox). We include it so that the
// first item's touch feedback is shadow-free in the item's initial/
// scrolled-to-top position.
top: 8,
bottom: 8,
),
itemCount: userIds.length,
itemBuilder: (_, index) =>
ViewReactionsUserItem(userId: userIds[index]))));
Widget result = SliverList.builder(
itemCount: userIds.length,
itemBuilder: (_, index) => ViewReactionsUserItem(userId: userIds[index]));

return Semantics(
return SliverSemantics(
identifier: semanticsIdentifier, // See note on `controlsNodes` on the tab.
label: zulipLocalizations.seeWhoReactedSheetUserListLabel(emojiName, userIds.length),
label: zulipLocalizations.seeWhoReactedSheetUserListLabel(emojiName!, userIds.length),
role: SemanticsRole.tabPanel,
container: true,
child: result);
explicitChildNodes: true,
sliver: result);
}
}

Expand Down
Loading