Skip to content

Commit 8888151

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

File tree

2 files changed

+264
-17
lines changed

2 files changed

+264
-17
lines changed

lib/widgets/content.dart

Lines changed: 27 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;
@@ -197,30 +198,39 @@ class MessageImage extends StatelessWidget {
197198

198199
@override
199200
Widget build(BuildContext context) {
201+
final message = InheritedMessage.of(context).message;
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: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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+
return PerAccountStoreWidget(
59+
accountId: accountId,
60+
child: RealmContentNetworkImage(src, filterQuality: FilterQuality.medium));
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+
void _handleRouteEntranceAnimationStatusChange(AnimationStatus status) {
105+
final entranceAnimationComplete = status == AnimationStatus.completed;
106+
setState(() {
107+
_headerFooterVisible = entranceAnimationComplete;
108+
});
109+
}
110+
111+
void _handleTap() {
112+
setState(() {
113+
_headerFooterVisible = !_headerFooterVisible;
114+
});
115+
}
116+
117+
@override
118+
void initState() {
119+
super.initState();
120+
widget.routeEntranceAnimation.addStatusListener(_handleRouteEntranceAnimationStatusChange);
121+
}
122+
123+
@override
124+
void dispose() {
125+
widget.routeEntranceAnimation.removeStatusListener(_handleRouteEntranceAnimationStatusChange);
126+
super.dispose();
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+
),
159+
TextSpan(
160+
text: timestampText,
161+
162+
// Make smaller, like a subtitle
163+
style: themeData.textTheme.titleSmall!.copyWith(color: appBarForegroundColor)),
164+
])));
165+
}
166+
167+
Widget? bottomAppBar;
168+
if (_headerFooterVisible) {
169+
bottomAppBar = BottomAppBar(
170+
color: appBarBackgroundColor,
171+
child: Row(
172+
children: [
173+
_CopyLinkButton(url: widget.src),
174+
// TODO: Share image
175+
// TODO: Download image
176+
]));
177+
}
178+
179+
return Theme(
180+
data: themeData.copyWith(
181+
iconTheme: themeData.iconTheme.copyWith(color: appBarForegroundColor),
182+
),
183+
child: Scaffold(
184+
backgroundColor: Colors.black,
185+
extendBody: true, // For the BottomAppBar
186+
extendBodyBehindAppBar: true, // For the AppBar
187+
appBar: appBar,
188+
body: MediaQuery(
189+
// Clobber the MediaQueryData prepared by Scaffold with one that's not
190+
// affected by the app bars. On this screen, the app bars are
191+
// translucent, dismissible overlays above the pan-zoom layer in the
192+
// Z direction, so the pan-zoom layer doesn't need avoid them in the Y
193+
// direction.
194+
data: MediaQuery.of(context),
195+
196+
child: GestureDetector(
197+
behavior: HitTestBehavior.translucent,
198+
onTap: _handleTap,
199+
child: SizedBox.expand(
200+
child: InteractiveViewer(
201+
child: SafeArea(
202+
child: LightboxHero(
203+
message: widget.message,
204+
src: widget.src,
205+
child: RealmContentNetworkImage(widget.src, filterQuality: FilterQuality.medium))))))),
206+
bottomNavigationBar: bottomAppBar));
207+
}
208+
}
209+
210+
Route getLightboxRoute({
211+
required BuildContext context,
212+
required Message message,
213+
required String src
214+
}) {
215+
return AccountPageRouteBuilder(
216+
context: context,
217+
fullscreenDialog: true,
218+
pageBuilder: (
219+
BuildContext context,
220+
Animation<double> animation,
221+
Animation<double> secondaryAnimation,
222+
) {
223+
// TODO: Drag down to close?
224+
return _LightboxPage(routeEntranceAnimation: animation, message: message, src: src);
225+
},
226+
transitionsBuilder: (
227+
BuildContext context,
228+
Animation<double> animation,
229+
Animation<double> secondaryAnimation,
230+
Widget child,
231+
) {
232+
return FadeTransition(
233+
opacity: animation.drive(CurveTween(curve: Curves.easeIn)),
234+
child: child);
235+
},
236+
);
237+
}

0 commit comments

Comments
 (0)