Skip to content

Commit 9971951

Browse files
committed
lightbox: Prototype lightbox
1 parent 6d3f1f8 commit 9971951

File tree

3 files changed

+310
-17
lines changed

3 files changed

+310
-17
lines changed

lib/widgets/clipboard.dart

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import 'package:device_info_plus/device_info_plus.dart';
2+
import 'package:flutter/material.dart';
3+
import 'package:flutter/services.dart';
4+
5+
/// Copies [data] to the clipboard and shows a popup on success.
6+
///
7+
/// Must have a [Scaffold] ancestor.
8+
///
9+
/// On newer Android the popup is defined and shown by the platform. On older
10+
/// Android and on iOS, shows a [Snackbar] with [successText].
11+
///
12+
/// In English, the [successText] should be short, should start with a capital
13+
/// letter, and should have no ending punctuation: "{noun} copied".
14+
void copyWithPopup({
15+
required BuildContext context,
16+
required ClipboardData data,
17+
required Text successText // TODO(i18n)
18+
}) async {
19+
await Clipboard.setData(data);
20+
final deviceInfo = await DeviceInfoPlugin().deviceInfo;
21+
22+
// Early return on !mounted would be better, but:
23+
// https://github.com/dart-lang/linter/issues/4007
24+
if (context.mounted) {
25+
final shouldShowSnackbar =
26+
(deviceInfo is IosDeviceInfo)
27+
28+
// Android 13+ shows its own popup on copying to the clipboard,
29+
// so we suppress ours, following the advice at:
30+
// https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications
31+
// TODO(android-sdk-32) Remove and simplify dartdoc
32+
|| (deviceInfo is AndroidDeviceInfo && deviceInfo.version.sdkInt <= 32);
33+
34+
if (shouldShowSnackbar) {
35+
ScaffoldMessenger.of(context).showSnackBar(
36+
SnackBar(behavior: SnackBarBehavior.floating, content: successText));
37+
}
38+
}
39+
}

lib/widgets/content.dart

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import '../api/model/model.dart';
66
import '../model/content.dart';
77
import '../model/store.dart';
88
import 'store.dart';
9+
import 'lightbox.dart';
910

1011
/// The font size for message content in a plain unstyled paragraph.
1112
const double kBaseFontSize = 14;
@@ -180,30 +181,40 @@ class MessageImage extends StatelessWidget {
180181

181182
@override
182183
Widget build(BuildContext context) {
184+
final messageContentWidget = context.findAncestorWidgetOfExactType<MessageContent>();
185+
assert(messageContentWidget != null, 'No MessageContent ancestor');
186+
final message = messageContentWidget!.message;
187+
183188
// TODO multiple images in a row
184189
// TODO image hover animation
185190
final src = node.srcUrl;
186191

187192
final store = PerAccountStoreWidget.of(context);
188193
final resolvedSrc = resolveUrl(src, store.account);
189194

190-
return Align(
191-
alignment: Alignment.centerLeft,
192-
child: Padding(
193-
// TODO clean up this padding by imitating web less precisely;
194-
// in particular, avoid adding loose whitespace at end of message.
195-
// The corresponding element on web has a 5px two-sided margin…
196-
// and then a 1px transparent border all around.
197-
padding: const EdgeInsets.fromLTRB(1, 1, 6, 6),
198-
child: Container(
199-
height: 100,
200-
width: 150,
201-
alignment: Alignment.center,
202-
color: const Color.fromRGBO(0, 0, 0, 0.03),
203-
child: RealmContentNetworkImage(
204-
resolvedSrc,
205-
filterQuality: FilterQuality.medium,
206-
))));
195+
return GestureDetector(
196+
onTap: () {
197+
Navigator.of(context).push(getLightboxRoute(
198+
context: context, message: message, src: resolvedSrc));
199+
},
200+
child: Align(
201+
alignment: Alignment.centerLeft,
202+
child: Padding(
203+
// TODO clean up this padding by imitating web less precisely;
204+
// in particular, avoid adding loose whitespace at end of message.
205+
// The corresponding element on web has a 5px two-sided margin…
206+
// and then a 1px transparent border all around.
207+
padding: const EdgeInsets.fromLTRB(1, 1, 6, 6),
208+
child: Container(
209+
height: 100,
210+
width: 150,
211+
alignment: Alignment.center,
212+
color: const Color.fromRGBO(0, 0, 0, 0.03),
213+
child: LightboxHero(
214+
accountId: PerAccountStoreWidget.accountIdOf(context),
215+
message: message,
216+
src: resolvedSrc)))),
217+
);
207218
}
208219
}
209220

lib/widgets/lightbox.dart

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter/services.dart';
3+
import 'package:intl/intl.dart';
4+
5+
import '../api/model/model.dart';
6+
import 'content.dart';
7+
import 'page.dart';
8+
import 'clipboard.dart';
9+
import 'store.dart';
10+
11+
// TODO: Add index of the image preview in the message, to not break if
12+
// there are multiple image previews with the same URL in the same
13+
// message. Maybe keep `src`, so that on exit the lightbox image doesn't
14+
// fly to an image preview with a different URL, following a message edit
15+
// while the lightbox was open.
16+
class _LightboxHeroTag {
17+
_LightboxHeroTag({required this.messageId, required this.src});
18+
19+
final int messageId;
20+
final String src;
21+
22+
@override
23+
bool operator ==(Object other) {
24+
return other is _LightboxHeroTag &&
25+
other.messageId == messageId &&
26+
other.src == other.src;
27+
}
28+
29+
@override
30+
int get hashCode => Object.hash('_LightboxHeroTag', messageId, src);
31+
}
32+
33+
/// Builds a [Hero] from an image in the message list to the lightbox page.
34+
class LightboxHero extends StatelessWidget {
35+
const LightboxHero({
36+
super.key,
37+
required this.accountId,
38+
required this.message,
39+
required this.src,
40+
});
41+
42+
/// The account the message list belongs to.
43+
final int accountId;
44+
45+
final Message message;
46+
final String src;
47+
48+
@override
49+
Widget build(BuildContext context) {
50+
return Hero(
51+
tag: _LightboxHeroTag(messageId: message.id, src: src),
52+
53+
// RealmContentNetworkImage needs an ancestor PerAccountStoreWidget, and
54+
// during the hero's "flight", we wouldn't otherwise get one.
55+
child: PerAccountStoreWidget(accountId: accountId,
56+
child: RealmContentNetworkImage(src, filterQuality: FilterQuality.medium)),
57+
);
58+
}
59+
}
60+
61+
class _CopyLinkButton extends StatelessWidget {
62+
const _CopyLinkButton({required this.url});
63+
64+
final String url;
65+
66+
@override
67+
Widget build(BuildContext context) {
68+
return IconButton(
69+
tooltip: 'Copy link',
70+
icon: const Icon(Icons.copy),
71+
onPressed: () async {
72+
copyWithPopup(context: context, successText: const Text('Link copied'),
73+
data: ClipboardData(text: url));
74+
});
75+
}
76+
}
77+
78+
class _LightboxPage extends StatefulWidget {
79+
const _LightboxPage({
80+
required this.routeEntranceAnimation,
81+
required this.message,
82+
required this.src,
83+
});
84+
85+
final Animation routeEntranceAnimation;
86+
final Message message;
87+
final String src;
88+
89+
@override
90+
State<_LightboxPage> createState() => _LightboxPageState();
91+
}
92+
93+
class _LightboxPageState extends State<_LightboxPage> {
94+
// TODO: Animate entrance/exit of header and footer
95+
bool _headerFooterVisible = false;
96+
97+
void _handleRouteEntranceAnimationStatusChange(AnimationStatus status) {
98+
switch (status) {
99+
case AnimationStatus.completed:
100+
// Route entrance transition complete
101+
setState(() {
102+
_headerFooterVisible = true;
103+
});
104+
break;
105+
default:
106+
setState(() {
107+
_headerFooterVisible = false;
108+
});
109+
break;
110+
}
111+
}
112+
113+
void _handleTap() {
114+
setState(() {
115+
_headerFooterVisible = !_headerFooterVisible;
116+
});
117+
}
118+
119+
@override
120+
void initState() {
121+
super.initState();
122+
widget.routeEntranceAnimation.addStatusListener(_handleRouteEntranceAnimationStatusChange);
123+
}
124+
125+
@override
126+
void dispose() {
127+
widget.routeEntranceAnimation.removeStatusListener(_handleRouteEntranceAnimationStatusChange);
128+
super.dispose();
129+
}
130+
131+
@override
132+
Widget build(BuildContext context) {
133+
final themeData = Theme.of(context);
134+
135+
final appBarBackgroundColor = Colors.grey.shade900.withOpacity(0.87);
136+
const appBarForegroundColor = Colors.white;
137+
138+
PreferredSizeWidget? appBar;
139+
if (_headerFooterVisible) {
140+
// TODO: Format with e.g. "Yesterday at 4:47 PM"
141+
final timestampText = DateFormat
142+
.yMMMd(/* TODO(i18n): Pass selected language here, I think? */)
143+
.add_Hms()
144+
.format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000));
145+
146+
appBar = AppBar(
147+
// TODO: Show message author's avatar
148+
title: RichText(
149+
text: TextSpan(
150+
children: [
151+
TextSpan(
152+
text: '${widget.message.sender_full_name}\n',
153+
154+
// Restate default
155+
style: themeData.textTheme.titleLarge!.copyWith(color: appBarForegroundColor),
156+
),
157+
TextSpan(
158+
text: timestampText,
159+
160+
// Make smaller, like a subtitle
161+
style: themeData.textTheme.titleSmall!.copyWith(color: appBarForegroundColor)),
162+
]
163+
)),
164+
165+
centerTitle: false,
166+
foregroundColor: appBarForegroundColor,
167+
backgroundColor: appBarBackgroundColor,
168+
);
169+
}
170+
171+
Widget? bottomAppBar;
172+
if (_headerFooterVisible) {
173+
bottomAppBar = BottomAppBar(
174+
color: appBarBackgroundColor,
175+
child: Row(
176+
children: [
177+
_CopyLinkButton(url: widget.src),
178+
// TODO: Share image
179+
// TODO: Download image
180+
],
181+
));
182+
}
183+
184+
return Theme(
185+
data: themeData.copyWith(
186+
iconTheme: themeData.iconTheme.copyWith(color: appBarForegroundColor),
187+
),
188+
child: Scaffold(
189+
backgroundColor: Colors.black,
190+
extendBody: true, // For the BottomAppBar
191+
extendBodyBehindAppBar: true, // For the AppBar
192+
appBar: appBar,
193+
body: MediaQuery(
194+
// Clobber the MediaQueryData prepared by Scaffold with one that's not
195+
// affected by the app bars. On this screen, the app bars are
196+
// translucent, dismissible overlays above the pan-zoom layer in the
197+
// Z direction, so the pan-zoom layer doesn't need avoid them in the Y
198+
// direction.
199+
data: MediaQuery.of(context),
200+
201+
child: GestureDetector(
202+
behavior: HitTestBehavior.translucent,
203+
onTap: _handleTap,
204+
child: SizedBox.expand(
205+
child: InteractiveViewer(
206+
child: SafeArea(
207+
child: LightboxHero(
208+
accountId: PerAccountStoreWidget.accountIdOf(context),
209+
message: widget.message,
210+
src: widget.src)))))),
211+
bottomNavigationBar: bottomAppBar));
212+
}
213+
}
214+
215+
Route getLightboxRoute({
216+
required BuildContext context,
217+
required Message message,
218+
required String src
219+
}) {
220+
return AccountPageRouteBuilder(
221+
context: context,
222+
fullscreenDialog: true,
223+
pageBuilder: (
224+
BuildContext context,
225+
Animation<double> animation,
226+
Animation<double> secondaryAnimation,
227+
) {
228+
// TODO: Drag down to close?
229+
return _LightboxPage(routeEntranceAnimation: animation, message: message, src: src);
230+
},
231+
transitionsBuilder: (
232+
BuildContext context,
233+
Animation<double> animation,
234+
Animation<double> secondaryAnimation,
235+
Widget child,
236+
) {
237+
return FadeTransition(
238+
opacity: animation.drive(CurveTween(curve: Curves.easeIn)),
239+
child: child,
240+
);
241+
},
242+
);
243+
}

0 commit comments

Comments
 (0)