Skip to content

Commit 17b508d

Browse files
committed
msglist: Prototype message action sheet with "Share" button
1 parent 1a97f33 commit 17b508d

File tree

3 files changed

+193
-29
lines changed

3 files changed

+193
-29
lines changed

lib/widgets/action_sheet.dart

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:share_plus/share_plus.dart';
3+
4+
import '../api/model/model.dart';
5+
import 'draggable_scrollable_modal_bottom_sheet.dart';
6+
7+
void showMessageActionSheet({required BuildContext context, required Message message}) {
8+
showDraggableScrollableModalBottomSheet(
9+
context: context,
10+
builder: (BuildContext context) {
11+
return Column(
12+
children: [
13+
MenuItemButton(
14+
leadingIcon: Icon(Icons.adaptive.share),
15+
onPressed: () async {
16+
// Close the message action sheet; we're about to show the share
17+
// sheet. (We could do this after the sharing Future settles, but
18+
// on iOS I get impatient with how slowly our action sheet
19+
// dismisses in that case.)
20+
Navigator.of(context).pop();
21+
22+
// TODO: to support iPads, we're asked to give a
23+
// `sharePositionOrigin` param, or risk crashing / hanging:
24+
// https://pub.dev/packages/share_plus#ipad
25+
// Perhaps a wart in the API; discussion:
26+
// https://github.com/zulip/zulip-flutter/pull/12#discussion_r1130146231
27+
// TODO: Share raw Markdown, not HTML
28+
await Share.shareWithResult(message.content);
29+
},
30+
child: const Text('Share'),
31+
),
32+
]
33+
);
34+
});
35+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import 'package:flutter/material.dart';
2+
3+
class _DraggableScrollableLayer extends StatelessWidget {
4+
const _DraggableScrollableLayer({required this.builder});
5+
6+
final WidgetBuilder builder;
7+
8+
@override
9+
Widget build(BuildContext context) {
10+
return DraggableScrollableSheet(
11+
// Match `initial…` to `min…` so that a slight drag downward dismisses
12+
// the sheet instead of just resizing it. Making them equal gives a
13+
// buggy experience for some reason
14+
// ( https://github.com/zulip/zulip-flutter/pull/12#discussion_r1116423455 )
15+
// so we work around by make `initial…` a bit bigger.
16+
minChildSize: 0.25,
17+
initialChildSize: 0.26,
18+
19+
// With `expand: true`, the bottom sheet would then start out occupying
20+
// the whole screen, as if `initialChildSize` was 1.0. That doesn't seem
21+
// like what the docs call for. Maybe a bug. Or maybe it's somehow
22+
// related to the `Stack`?
23+
expand: false,
24+
25+
builder: (BuildContext context, ScrollController scrollController) {
26+
return SingleChildScrollView(
27+
// Prevent overscroll animation on swipe down; it looks
28+
// sloppy when you're swiping to dismiss the sheet.
29+
physics: const ClampingScrollPhysics(),
30+
31+
controller: scrollController,
32+
33+
child: Padding(
34+
// Avoid the drag handle. See comment on
35+
// _DragHandleLayer's SizedBox.height.
36+
padding: const EdgeInsets.only(top: kMinInteractiveDimension),
37+
38+
// Extend DraggableScrollableSheet to full width so the whole
39+
// sheet responds to drag/scroll uniformly.
40+
child: FractionallySizedBox(
41+
widthFactor: 1.0,
42+
child: Builder(builder: builder),
43+
),
44+
),
45+
);
46+
});
47+
}
48+
}
49+
50+
class _DragHandleLayer extends StatelessWidget {
51+
@override
52+
Widget build(BuildContext context) {
53+
ColorScheme colorScheme = Theme.of(context).colorScheme;
54+
return SizedBox(
55+
// In the spec, this is expressed as 22 logical pixels of top/bottom
56+
// padding on the drag handle:
57+
// https://m3.material.io/components/bottom-sheets/specs#e69f3dfb-e443-46ba-b4a8-aabc718cf335
58+
// The drag handle is specified with height 4 logical pixels, so we can
59+
// get the same result by vertically centering the handle in a box with
60+
// height 22 + 4 + 22 = 48. We have another way to say 48 --
61+
// kMinInteractiveDimension -- which is actually not a bad way to
62+
// express it, since the feature was announced as "an optional drag
63+
// handle with an accessible 48dp hit target":
64+
// https://m3.material.io/components/bottom-sheets/overview#2cce5bae-eb83-40b0-8e52-5d0cfaa9b795
65+
// As a bonus, that constant is easy to use at the other layer in the
66+
// Stack where we set the starting position of the sheet's content to
67+
// avoid the drag handle.
68+
height: kMinInteractiveDimension,
69+
70+
child: Center(
71+
child: ClipRRect(
72+
clipBehavior: Clip.hardEdge,
73+
borderRadius: const BorderRadius.all(Radius.circular(2)),
74+
child: SizedBox(
75+
// height / width / color (including opacity) from this table:
76+
// https://m3.material.io/components/bottom-sheets/specs#7c093473-d9e1-48f3-9659-b75519c2a29d
77+
height: 4,
78+
width: 32,
79+
child: ColoredBox(color: colorScheme.onSurfaceVariant.withOpacity(0.40)),
80+
),
81+
)));
82+
}
83+
}
84+
85+
/// Show a modal bottom sheet that drags and scrolls to present lots of content.
86+
///
87+
/// Aims to follow Material 3's "bottom sheet" with a drag handle:
88+
/// https://m3.material.io/components/bottom-sheets/overview
89+
Future<T?> showDraggableScrollableModalBottomSheet<T>({
90+
required BuildContext context,
91+
required WidgetBuilder builder,
92+
}) {
93+
return showModalBottomSheet<T>(
94+
context: context,
95+
96+
// Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect
97+
// on my iPhone 13 Pro but is marked as "much slower":
98+
// https://api.flutter.dev/flutter/dart-ui/Clip.html
99+
clipBehavior: Clip.antiAlias,
100+
101+
// The spec:
102+
// https://m3.material.io/components/bottom-sheets/specs
103+
// defines the container's shape with the design token
104+
// `md.sys.shape.corner.extra-large.top`, which in the table at
105+
// https://m3.material.io/styles/shape/shape-scale-tokens#6f668ba1-b671-4ea2-bcf3-c1cff4f4099e
106+
// maps to:
107+
// 28dp,28dp,0dp,0dp
108+
// SHAPE_FAMILY_ROUNDED_CORNERS
109+
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28.0))),
110+
111+
useSafeArea: true,
112+
isScrollControlled: true,
113+
builder: (BuildContext context) {
114+
// Make the content start below the drag handle in the y-direction, but
115+
// when the content is scrollable, let it scroll under the drag handle in
116+
// the z-direction.
117+
return Stack(
118+
children: [
119+
_DraggableScrollableLayer(builder: builder),
120+
_DragHandleLayer(),
121+
],
122+
);
123+
});
124+
}

lib/widgets/message_list.dart

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import '../model/content.dart';
77
import '../model/message_list.dart';
88
import '../model/narrow.dart';
99
import '../model/store.dart';
10+
import 'action_sheet.dart';
1011
import 'content.dart';
1112
import 'sticky_header.dart';
1213
import 'store.dart';
@@ -300,35 +301,39 @@ class MessageWithSender extends StatelessWidget {
300301
final time = _kMessageTimestampFormat
301302
.format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp));
302303

303-
// TODO clean up this layout, by less precisely imitating web
304-
return Padding(
305-
padding: const EdgeInsets.only(top: 2, bottom: 3, left: 8, right: 8),
306-
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
307-
Padding(
308-
padding: const EdgeInsets.fromLTRB(3, 6, 11, 0),
309-
child: Container(
310-
clipBehavior: Clip.antiAlias,
311-
decoration: const BoxDecoration(
312-
borderRadius: BorderRadius.all(Radius.circular(4))),
313-
width: 35,
314-
height: 35,
315-
child: avatar)),
316-
Expanded(
317-
child: Column(
318-
crossAxisAlignment: CrossAxisAlignment.stretch,
319-
children: [
320-
const SizedBox(height: 3),
321-
Text(message.sender_full_name, // TODO get from user data
322-
style: const TextStyle(fontWeight: FontWeight.bold)),
323-
const SizedBox(height: 4),
324-
MessageContent(message: message, content: content),
325-
])),
326-
Container(
327-
width: 80,
328-
padding: const EdgeInsets.only(top: 4, right: 2),
329-
alignment: Alignment.topRight,
330-
child: Text(time, style: _kMessageTimestampStyle))
331-
]));
304+
return GestureDetector(
305+
behavior: HitTestBehavior.translucent,
306+
onLongPress: () => showMessageActionSheet(context: context, message: message),
307+
// TODO clean up this layout, by less precisely imitating web
308+
child: Padding(
309+
padding: const EdgeInsets.only(top: 2, bottom: 3, left: 8, right: 8),
310+
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
311+
Padding(
312+
padding: const EdgeInsets.fromLTRB(3, 6, 11, 0),
313+
child: Container(
314+
clipBehavior: Clip.antiAlias,
315+
decoration: const BoxDecoration(
316+
borderRadius: BorderRadius.all(Radius.circular(4))),
317+
width: 35,
318+
height: 35,
319+
child: avatar)),
320+
Expanded(
321+
child: Column(
322+
crossAxisAlignment: CrossAxisAlignment.stretch,
323+
children: [
324+
const SizedBox(height: 3),
325+
Text(message.sender_full_name, // TODO get from user data
326+
style: const TextStyle(fontWeight: FontWeight.bold)),
327+
const SizedBox(height: 4),
328+
MessageContent(message: message, content: content),
329+
])),
330+
Container(
331+
width: 80,
332+
padding: const EdgeInsets.only(top: 4, right: 2),
333+
alignment: Alignment.topRight,
334+
child: Text(time, style: _kMessageTimestampStyle))
335+
])),
336+
);
332337
}
333338
}
334339

0 commit comments

Comments
 (0)