Skip to content

Commit d3a3178

Browse files
committed
lightbox: Prototype lightbox
1 parent f94d942 commit d3a3178

File tree

3 files changed

+285
-39
lines changed

3 files changed

+285
-39
lines changed

lib/widgets/clipboard.dart

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,25 @@ void copyWithPopup({
1919
await Clipboard.setData(data);
2020
final deviceInfo = await DeviceInfoPlugin().deviceInfo;
2121

22-
// Early return on !mounted would be better, but:
23-
// https://github.com/dart-lang/linter/issues/4007
24-
if (context.mounted) {
25-
final bool shouldShowSnackbar;
26-
switch (deviceInfo) {
27-
case AndroidDeviceInfo(:var version):
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-33): Simplify this and dartdoc
32-
shouldShowSnackbar = version.sdkInt <= 32;
33-
default:
34-
shouldShowSnackbar = true;
35-
}
22+
if (context.mounted) {} // https://github.com/dart-lang/linter/issues/4007
23+
else {
24+
return;
25+
}
26+
27+
final bool shouldShowSnackbar;
28+
switch (deviceInfo) {
29+
case AndroidDeviceInfo(:var version):
30+
// Android 13+ shows its own popup on copying to the clipboard,
31+
// so we suppress ours, following the advice at:
32+
// https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications
33+
// TODO(android-sdk-33): Simplify this and dartdoc
34+
shouldShowSnackbar = version.sdkInt <= 32;
35+
default:
36+
shouldShowSnackbar = true;
37+
}
3638

37-
if (shouldShowSnackbar) {
38-
ScaffoldMessenger.of(context).showSnackBar(
39-
SnackBar(behavior: SnackBarBehavior.floating, content: successContent));
40-
}
39+
if (shouldShowSnackbar) {
40+
ScaffoldMessenger.of(context).showSnackBar(
41+
SnackBar(behavior: SnackBarBehavior.floating, content: successContent));
4142
}
4243
}

lib/widgets/content.dart

Lines changed: 31 additions & 21 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;
@@ -22,11 +23,11 @@ class MessageContent extends StatelessWidget {
2223

2324
@override
2425
Widget build(BuildContext context) {
25-
return InheritedMessage(message: message, child: BlockContentList(nodes: content.nodes));
26+
return InheritedMessage(message: message,
27+
child: BlockContentList(nodes: content.nodes));
2628
}
2729
}
2830

29-
/// Provides access to [message].
3031
class InheritedMessage extends InheritedWidget {
3132
const InheritedMessage({super.key, required this.message, required super.child});
3233

@@ -36,10 +37,10 @@ class InheritedMessage extends InheritedWidget {
3637
bool updateShouldNotify(covariant InheritedMessage oldWidget) =>
3738
!identical(oldWidget.message, message);
3839

39-
static InheritedMessage of(BuildContext context) {
40+
static Message of(BuildContext context) {
4041
final widget = context.dependOnInheritedWidgetOfExactType<InheritedMessage>();
4142
assert(widget != null, 'No InheritedMessage ancestor');
42-
return widget!;
43+
return widget!.message;
4344
}
4445
}
4546

@@ -197,30 +198,39 @@ class MessageImage extends StatelessWidget {
197198

198199
@override
199200
Widget build(BuildContext context) {
201+
final message = InheritedMessage.of(context);
202+
200203
// TODO multiple images in a row
201204
// TODO image hover animation
202205
final src = node.srcUrl;
203206

204207
final store = PerAccountStoreWidget.of(context);
205208
final resolvedSrc = resolveUrl(src, store.account);
206209

207-
return Align(
208-
alignment: Alignment.centerLeft,
209-
child: Padding(
210-
// TODO clean up this padding by imitating web less precisely;
211-
// in particular, avoid adding loose whitespace at end of message.
212-
// The corresponding element on web has a 5px two-sided margin…
213-
// and then a 1px transparent border all around.
214-
padding: const EdgeInsets.fromLTRB(1, 1, 6, 6),
215-
child: Container(
216-
height: 100,
217-
width: 150,
218-
alignment: Alignment.center,
219-
color: const Color.fromRGBO(0, 0, 0, 0.03),
220-
child: RealmContentNetworkImage(
221-
resolvedSrc,
222-
filterQuality: FilterQuality.medium,
223-
))));
210+
return GestureDetector(
211+
onTap: () {
212+
Navigator.of(context).push(getLightboxRoute(
213+
context: context, message: message, src: resolvedSrc));
214+
},
215+
child: Align(
216+
alignment: Alignment.centerLeft,
217+
child: Padding(
218+
// TODO clean up this padding by imitating web less precisely;
219+
// in particular, avoid adding loose whitespace at end of message.
220+
// The corresponding element on web has a 5px two-sided margin…
221+
// and then a 1px transparent border all around.
222+
padding: const EdgeInsets.fromLTRB(1, 1, 6, 6),
223+
child: Container(
224+
height: 100,
225+
width: 150,
226+
alignment: Alignment.center,
227+
color: const Color.fromRGBO(0, 0, 0, 0.03),
228+
child: LightboxHero(
229+
message: message,
230+
src: resolvedSrc,
231+
child: RealmContentNetworkImage(
232+
resolvedSrc,
233+
filterQuality: FilterQuality.medium))))));
224234
}
225235
}
226236

lib/widgets/lightbox.dart

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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 == 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.message,
38+
required this.src,
39+
required this.child,
40+
});
41+
42+
final Message message;
43+
final String src;
44+
final Widget child;
45+
46+
@override
47+
Widget build(BuildContext context) {
48+
return Hero(
49+
tag: _LightboxHeroTag(messageId: message.id, src: src),
50+
flightShuttleBuilder: (
51+
BuildContext flightContext,
52+
Animation<double> animation,
53+
HeroFlightDirection flightDirection,
54+
BuildContext fromHeroContext,
55+
BuildContext toHeroContext,
56+
) {
57+
final accountId = PerAccountStoreWidget.accountIdOf(fromHeroContext);
58+
59+
// For a RealmContentNetworkImage shown during flight.
60+
return PerAccountStoreWidget(accountId: accountId, child: child);
61+
},
62+
child: child,
63+
);
64+
}
65+
}
66+
67+
class _CopyLinkButton extends StatelessWidget {
68+
const _CopyLinkButton({required this.url});
69+
70+
final String url;
71+
72+
@override
73+
Widget build(BuildContext context) {
74+
return IconButton(
75+
tooltip: 'Copy link',
76+
icon: const Icon(Icons.copy),
77+
onPressed: () async {
78+
// TODO(i18n)
79+
copyWithPopup(context: context, successContent: const Text('Link copied'),
80+
data: ClipboardData(text: url));
81+
});
82+
}
83+
}
84+
85+
class _LightboxPage extends StatefulWidget {
86+
const _LightboxPage({
87+
required this.routeEntranceAnimation,
88+
required this.message,
89+
required this.src,
90+
});
91+
92+
final Animation routeEntranceAnimation;
93+
final Message message;
94+
final String src;
95+
96+
@override
97+
State<_LightboxPage> createState() => _LightboxPageState();
98+
}
99+
100+
class _LightboxPageState extends State<_LightboxPage> {
101+
// TODO: Animate entrance/exit of header and footer
102+
bool _headerFooterVisible = false;
103+
104+
@override
105+
void initState() {
106+
super.initState();
107+
widget.routeEntranceAnimation.addStatusListener(_handleRouteEntranceAnimationStatusChange);
108+
}
109+
110+
@override
111+
void dispose() {
112+
widget.routeEntranceAnimation.removeStatusListener(_handleRouteEntranceAnimationStatusChange);
113+
super.dispose();
114+
}
115+
116+
void _handleRouteEntranceAnimationStatusChange(AnimationStatus status) {
117+
final entranceAnimationComplete = status == AnimationStatus.completed;
118+
setState(() {
119+
_headerFooterVisible = entranceAnimationComplete;
120+
});
121+
}
122+
123+
void _handleTap() {
124+
setState(() {
125+
_headerFooterVisible = !_headerFooterVisible;
126+
});
127+
}
128+
129+
@override
130+
Widget build(BuildContext context) {
131+
final themeData = Theme.of(context);
132+
133+
final appBarBackgroundColor = Colors.grey.shade900.withOpacity(0.87);
134+
const appBarForegroundColor = Colors.white;
135+
136+
PreferredSizeWidget? appBar;
137+
if (_headerFooterVisible) {
138+
// TODO: Format with e.g. "Yesterday at 4:47 PM"
139+
final timestampText = DateFormat
140+
.yMMMd(/* TODO(i18n): Pass selected language here, I think? */)
141+
.add_Hms()
142+
.format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000));
143+
144+
appBar = AppBar(
145+
centerTitle: false,
146+
foregroundColor: appBarForegroundColor,
147+
backgroundColor: appBarBackgroundColor,
148+
149+
// TODO: Show message author's avatar
150+
title: RichText(
151+
text: TextSpan(
152+
children: [
153+
TextSpan(
154+
text: '${widget.message.sender_full_name}\n',
155+
156+
// Restate default
157+
style: themeData.textTheme.titleLarge!.copyWith(color: appBarForegroundColor)),
158+
TextSpan(
159+
text: timestampText,
160+
161+
// Make smaller, like a subtitle
162+
style: themeData.textTheme.titleSmall!.copyWith(color: appBarForegroundColor)),
163+
])));
164+
}
165+
166+
Widget? bottomAppBar;
167+
if (_headerFooterVisible) {
168+
bottomAppBar = BottomAppBar(
169+
color: appBarBackgroundColor,
170+
child: Row(
171+
children: [
172+
_CopyLinkButton(url: widget.src),
173+
// TODO: Share image
174+
// TODO: Download image
175+
]));
176+
}
177+
178+
return Theme(
179+
data: themeData.copyWith(
180+
iconTheme: themeData.iconTheme.copyWith(color: appBarForegroundColor)),
181+
child: Scaffold(
182+
backgroundColor: Colors.black,
183+
extendBody: true, // For the BottomAppBar
184+
extendBodyBehindAppBar: true, // For the AppBar
185+
appBar: appBar,
186+
body: MediaQuery(
187+
// Clobber the MediaQueryData prepared by Scaffold with one that's not
188+
// affected by the app bars. On this screen, the app bars are
189+
// translucent, dismissible overlays above the pan-zoom layer in the
190+
// Z direction, so the pan-zoom layer doesn't need avoid them in the Y
191+
// direction.
192+
data: MediaQuery.of(context),
193+
194+
child: GestureDetector(
195+
behavior: HitTestBehavior.translucent,
196+
onTap: _handleTap,
197+
child: SizedBox.expand(
198+
child: InteractiveViewer(
199+
child: SafeArea(
200+
child: LightboxHero(
201+
message: widget.message,
202+
src: widget.src,
203+
child: RealmContentNetworkImage(widget.src, filterQuality: FilterQuality.medium))))))),
204+
bottomNavigationBar: bottomAppBar));
205+
}
206+
}
207+
208+
Route getLightboxRoute({
209+
required BuildContext context,
210+
required Message message,
211+
required String src
212+
}) {
213+
return AccountPageRouteBuilder(
214+
context: context,
215+
fullscreenDialog: true,
216+
pageBuilder: (
217+
BuildContext context,
218+
Animation<double> animation,
219+
Animation<double> secondaryAnimation,
220+
) {
221+
// TODO: Drag down to close?
222+
return _LightboxPage(routeEntranceAnimation: animation, message: message, src: src);
223+
},
224+
transitionsBuilder: (
225+
BuildContext context,
226+
Animation<double> animation,
227+
Animation<double> secondaryAnimation,
228+
Widget child,
229+
) {
230+
return FadeTransition(
231+
opacity: animation.drive(CurveTween(curve: Curves.easeIn)),
232+
child: child);
233+
},
234+
);
235+
}

0 commit comments

Comments
 (0)