Skip to content

Commit 7fcbe99

Browse files
committed
msglist: Prototype message action sheet with "Share" button
1 parent cc57828 commit 7fcbe99

File tree

3 files changed

+186
-29
lines changed

3 files changed

+186
-29
lines changed

lib/widgets/action_sheet.dart

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

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 'app.dart';
1112
import 'content.dart';
1213
import 'sticky_header.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)