Skip to content

Commit 625fbd0

Browse files
committed
msglist: Add message action sheet with "Share" button
1 parent 9ec55eb commit 625fbd0

File tree

3 files changed

+202
-29
lines changed

3 files changed

+202
-29
lines changed

lib/widgets/action_sheet.dart

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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+
class _Button extends StatelessWidget {
8+
const _Button({required this.icon, required this.label, this.onPressed});
9+
10+
final IconData icon;
11+
final String label;
12+
final VoidCallback? onPressed;
13+
14+
@override
15+
Widget build(BuildContext context) {
16+
return Column(
17+
mainAxisAlignment: MainAxisAlignment.start,
18+
children: [
19+
IconButton(onPressed: onPressed, icon: Icon(icon)),
20+
Text(label),
21+
],
22+
);
23+
}
24+
}
25+
26+
List<_Button> _messageActionSheetButtons(Message message) => [
27+
_Button(
28+
icon: Icons.share_outlined,
29+
label: 'Share', // TODO(i18n)
30+
onPressed: () {
31+
// TODO: Share raw Markdown, right, not HTML?
32+
Share.share(message.content);
33+
}),
34+
];
35+
36+
void showMessageActionSheet({required BuildContext context, required Message message}) {
37+
showDraggableScrollableModalBottomSheet(
38+
context: context,
39+
builder: (BuildContext context) {
40+
return SafeArea(
41+
minimum: const EdgeInsets.symmetric(horizontal: 10),
42+
child: Wrap(
43+
// TODO: choose `runSpacing` / `spacing`, to control spacing between buttons
44+
45+
children: _messageActionSheetButtons(message)
46+
),
47+
);
48+
});
49+
}
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+
// Enough less than 1 as to not extend into the device's top inset
12+
maxChildSize: 0.75,
13+
14+
// (Things break with `expand: true`, the default; not sure why.)
15+
expand: false,
16+
17+
builder: (BuildContext context, ScrollController scrollController) {
18+
return SingleChildScrollView(
19+
// Prevent overscroll animation on swipe down; it looks
20+
// sloppy when you're swiping to dismiss the sheet.
21+
physics: const ClampingScrollPhysics(),
22+
23+
controller: scrollController,
24+
25+
child: Padding(
26+
// Avoid the drag handle. See comment on
27+
// _DragHandleLayer's SizedBox.height.
28+
padding: const EdgeInsets.only(top: kMinInteractiveDimension),
29+
30+
child: Column(
31+
// Extend DraggableScrollableSheet to full width so the whole
32+
// sheet responds to drag/scroll uniformly. There's probably a
33+
// better way to do this.
34+
crossAxisAlignment: CrossAxisAlignment.stretch,
35+
36+
children: [
37+
Builder(builder: builder),
38+
],
39+
),
40+
),
41+
);
42+
});
43+
}
44+
}
45+
46+
class _DragHandleLayer extends StatelessWidget {
47+
@override
48+
Widget build(BuildContext context) {
49+
ColorScheme colorScheme = Theme.of(context).colorScheme;
50+
return SizedBox(
51+
// In the spec, this is expressed as 22 logical pixels of top/bottom
52+
// padding on the drag handle:
53+
// https://m3.material.io/components/bottom-sheets/specs#e69f3dfb-e443-46ba-b4a8-aabc718cf335
54+
// The drag handle is specified with height 4 logical pixels, so we can
55+
// get the same result by vertically centering the handle in a box with
56+
// height 22 + 4 + 22 = 48. We have another way to say 48 --
57+
// kMinInteractiveDimension -- which is actually not a bad way to
58+
// express it, since the feature was announced as "an optional drag
59+
// handle with an accessible 48dp hit target":
60+
// https://m3.material.io/components/bottom-sheets/overview#2cce5bae-eb83-40b0-8e52-5d0cfaa9b795
61+
// As a bonus, that constant is easy to use at the other layer in the
62+
// Stack where we set the starting position of the sheet's content to
63+
// avoid the drag handle.
64+
height: kMinInteractiveDimension,
65+
66+
child: Center(
67+
child: ClipRRect(
68+
clipBehavior: Clip.hardEdge,
69+
borderRadius: const BorderRadius.all(Radius.circular(2)),
70+
child: SizedBox(
71+
// height / width / color (including opacity) from this table:
72+
// https://m3.material.io/components/bottom-sheets/specs#7c093473-d9e1-48f3-9659-b75519c2a29d
73+
height: 4,
74+
width: 32,
75+
child: ColoredBox(color: colorScheme.onSurfaceVariant.withOpacity(0.40)),
76+
),
77+
)));
78+
}
79+
}
80+
81+
/// Show a modal bottom sheet that drags and scrolls to present lots of content.
82+
///
83+
/// Aims to follow Material 3's "bottom sheet" with a drag handle:
84+
/// https://m3.material.io/components/bottom-sheets/overview
85+
Future<T?> showDraggableScrollableModalBottomSheet<T>({
86+
required BuildContext context,
87+
required WidgetBuilder builder,
88+
}) {
89+
return showModalBottomSheet<T>(
90+
context: context,
91+
92+
// Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect
93+
// on my iPhone 13 Pro but is marked as "much slower":
94+
// https://api.flutter.dev/flutter/dart-ui/Clip.html
95+
clipBehavior: Clip.antiAlias,
96+
97+
// The spec:
98+
// https://m3.material.io/components/bottom-sheets/specs
99+
// defines the container's shape with the design token
100+
// `md.sys.shape.corner.extra-large.top`, which in the table at
101+
// https://m3.material.io/styles/shape/shape-scale-tokens#6f668ba1-b671-4ea2-bcf3-c1cff4f4099e
102+
// maps to:
103+
// 28dp,28dp,0dp,0dp
104+
// SHAPE_FAMILY_ROUNDED_CORNERS
105+
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28.0))),
106+
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(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(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)