Skip to content

Commit c0cf7e6

Browse files
committed
lightbox: Prototype lightbox
1 parent 75bfa2a commit c0cf7e6

File tree

2 files changed

+234
-20
lines changed

2 files changed

+234
-20
lines changed

lib/widgets/content.dart

Lines changed: 34 additions & 20 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,33 +181,46 @@ 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: Image.network(
204-
resolvedSrc,
205-
filterQuality: FilterQuality.medium,
206-
headers: isUrlOnRealm(resolvedSrc, store.account)
207-
? Map.fromEntries([authHeader(store.account)])
208-
: null,
209-
))));
195+
return GestureDetector(
196+
onTap: () {
197+
Navigator.of(context).push(getLightboxRoute(message: message, src: resolvedSrc));
198+
},
199+
child: Align(
200+
alignment: Alignment.centerLeft,
201+
child: Padding(
202+
// TODO clean up this padding by imitating web less precisely;
203+
// in particular, avoid adding loose whitespace at end of message.
204+
// The corresponding element on web has a 5px two-sided margin…
205+
// and then a 1px transparent border all around.
206+
padding: const EdgeInsets.fromLTRB(1, 1, 6, 6),
207+
child: Container(
208+
height: 100,
209+
width: 150,
210+
alignment: Alignment.center,
211+
color: const Color.fromRGBO(0, 0, 0, 0.03),
212+
child: LightboxHero(
213+
message: message,
214+
src: resolvedSrc,
215+
child: Image.network(
216+
resolvedSrc,
217+
filterQuality: FilterQuality.medium,
218+
headers: isUrlOnRealm(resolvedSrc, store.account)
219+
? Map.fromEntries([authHeader(store.account)])
220+
: null,
221+
),
222+
)))),
223+
);
210224
}
211225
}
212226

lib/widgets/lightbox.dart

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter/services.dart';
3+
import 'package:intl/intl.dart';
4+
5+
import '../api/core.dart';
6+
import '../api/model/model.dart';
7+
import 'content.dart';
8+
import 'store.dart';
9+
10+
class LightboxHero extends Hero {
11+
LightboxHero({
12+
super.key,
13+
required Message message,
14+
required String src,
15+
required super.child
16+
}) : super(
17+
// TODO: Add index of the image preview in the message, to not break if
18+
// there are multiple image previews with the same URL in the same
19+
// message. Maybe keep `src`, so that on exit the lightbox image doesn't
20+
// fly to an image preview with a different URL, following a message edit
21+
// while the lightbox was open.
22+
tag: '${message.id.toString()} $src'
23+
);
24+
}
25+
26+
class _Page extends StatefulWidget {
27+
const _Page({
28+
required this.routeEntranceAnimation,
29+
required this.message,
30+
required this.src
31+
});
32+
33+
final Animation routeEntranceAnimation;
34+
final Message message;
35+
final String src;
36+
37+
@override
38+
State<_Page> createState() => _PageState();
39+
}
40+
41+
class _PageState extends State<_Page> {
42+
// TODO: Animate entrance/exit of header and footer
43+
bool _headerFooterVisible = false;
44+
45+
void _handleRouteEntranceAnimationStatusChange(AnimationStatus status) {
46+
if (status == AnimationStatus.completed) {
47+
setState(() {
48+
_headerFooterVisible = true;
49+
});
50+
}
51+
}
52+
53+
void _handleTap() {
54+
setState(() {
55+
_headerFooterVisible = !_headerFooterVisible;
56+
});
57+
}
58+
59+
@override
60+
void initState() {
61+
super.initState();
62+
widget.routeEntranceAnimation.addStatusListener(_handleRouteEntranceAnimationStatusChange);
63+
}
64+
65+
@override
66+
void dispose() {
67+
widget.routeEntranceAnimation.removeStatusListener(_handleRouteEntranceAnimationStatusChange);
68+
super.dispose();
69+
}
70+
71+
@override
72+
Widget build(BuildContext context) {
73+
final store = PerAccountStoreWidget.of(context);
74+
75+
final themeData = Theme.of(context);
76+
77+
final appBarBackgroundColor = Colors.grey.shade900.withOpacity(0.87);
78+
const appBarForegroundColor = Colors.white;
79+
80+
return Theme(
81+
data: themeData.copyWith(
82+
iconTheme: themeData.iconTheme.copyWith(color: appBarForegroundColor),
83+
),
84+
child: Scaffold(
85+
backgroundColor: Colors.black,
86+
extendBody: true, // For the BottomAppBar
87+
extendBodyBehindAppBar: true, // For the AppBar
88+
appBar: _headerFooterVisible
89+
? AppBar(
90+
// TODO: Show message author's avatar
91+
title: RichText(
92+
text: TextSpan(
93+
children: [
94+
TextSpan(
95+
text: '${widget.message.sender_full_name}\n',
96+
97+
// Restate default
98+
style: themeData.textTheme.titleLarge!.copyWith(color: appBarForegroundColor),
99+
),
100+
TextSpan(
101+
// TODO: Format with e.g. "Yesterday at 4:47 PM"
102+
text: DateFormat
103+
.yMMMd(/* TODO(i18n): Pass selected language here, I think? */)
104+
.add_Hms()
105+
.format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000)),
106+
107+
// Make smaller, like a subtitle
108+
style: themeData.textTheme.titleSmall!.copyWith(color: appBarForegroundColor)),
109+
]
110+
)),
111+
centerTitle: false,
112+
foregroundColor: appBarForegroundColor,
113+
backgroundColor: appBarBackgroundColor,
114+
115+
// With Material 3, later, try removing these "transparent" overrides
116+
shadowColor: Colors.transparent,
117+
surfaceTintColor: Colors.transparent,
118+
)
119+
: null,
120+
body: MediaQuery(
121+
// Declare the vertical insets unconsumed by `appBar` and
122+
// `bottomNavigationBar`, so we can position the image to not overlap
123+
// any system intrusions, at least until the user zooms in. The top
124+
// and bottom app bars overlay the pan-zoom layer in the Z direction;
125+
// they don't affect anything in the pan-zoom layer's Y direction.
126+
//
127+
// Done by clobbering the ambient MediaQueryData (prepared by Scaffold
128+
// for the `body`) with one taken from a BuildContext above the
129+
// Scaffold.
130+
data: MediaQuery.of(context),
131+
132+
child: GestureDetector(
133+
behavior: HitTestBehavior.translucent,
134+
onTap: _handleTap,
135+
child: SizedBox.expand(
136+
child: InteractiveViewer(
137+
child: SafeArea(
138+
child: LightboxHero(
139+
message: widget.message,
140+
src: widget.src,
141+
child:
142+
Image.network(
143+
widget.src,
144+
filterQuality: FilterQuality.medium,
145+
headers: isUrlOnRealm(widget.src, store.account)
146+
? Map.fromEntries([authHeader(store.account)])
147+
: null))))))),
148+
bottomNavigationBar: _headerFooterVisible
149+
? BottomAppBar(
150+
color: appBarBackgroundColor,
151+
152+
// With Material 3, later, try removing these "transparent" overrides
153+
shadowColor: Colors.transparent,
154+
surfaceTintColor: Colors.transparent,
155+
156+
child: Row(
157+
children: [
158+
IconButton(
159+
tooltip: 'Copy link',
160+
icon: const Icon(Icons.copy),
161+
onPressed: () async {
162+
await Clipboard.setData(ClipboardData(text: widget.src));
163+
if (!context.mounted) return; // ignore: use_build_context_synchronously
164+
ScaffoldMessenger.of(context).showSnackBar(
165+
const SnackBar(
166+
behavior: SnackBarBehavior.floating,
167+
content: Text('Link copied')));
168+
}),
169+
// TODO: Share image
170+
// TODO: Download image
171+
],
172+
))
173+
: null));
174+
}
175+
}
176+
177+
Route getLightboxRoute({required Message message, required String src}) {
178+
return PageRouteBuilder(
179+
fullscreenDialog: true,
180+
pageBuilder: (
181+
BuildContext context,
182+
Animation<double> animation,
183+
Animation<double> secondaryAnimation
184+
) {
185+
// TODO: Drag down to close?
186+
return _Page(routeEntranceAnimation: animation, message: message, src: src);
187+
},
188+
transitionsBuilder: (
189+
BuildContext context,
190+
Animation<double> animation,
191+
Animation<double> secondaryAnimation,
192+
Widget child
193+
) {
194+
return FadeTransition(
195+
opacity: animation.drive(CurveTween(curve: Curves.easeIn)),
196+
child: child,
197+
);
198+
},
199+
);
200+
}

0 commit comments

Comments
 (0)