Skip to content

Animate lightbox bars #224

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 45 additions & 33 deletions lib/widgets/lightbox.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,9 @@ class _CopyLinkButton extends StatelessWidget {
}
}

class _LightboxPage extends StatefulWidget {
const _LightboxPage({
@visibleForTesting
class LightboxPage extends StatefulWidget {
const LightboxPage({
required this.routeEntranceAnimation,
required this.message,
required this.src,
Expand All @@ -93,10 +94,10 @@ class _LightboxPage extends StatefulWidget {
final String src;

@override
State<_LightboxPage> createState() => _LightboxPageState();
State<LightboxPage> createState() => _LightboxPageState();
}

class _LightboxPageState extends State<_LightboxPage> {
class _LightboxPageState extends State<LightboxPage> {
// TODO(#38): Animate entrance/exit of header and footer
bool _headerFooterVisible = false;

Expand Down Expand Up @@ -128,26 +129,33 @@ class _LightboxPageState extends State<_LightboxPage> {
@override
Widget build(BuildContext context) {
final themeData = Theme.of(context);

final appBarBackgroundColor = Colors.grey.shade900.withOpacity(0.87);
const appBarForegroundColor = Colors.white;

PreferredSizeWidget? appBar;
if (_headerFooterVisible) {
// TODO(#45): Format with e.g. "Yesterday at 4:47 PM"
final timestampText = DateFormat
.yMMMd(/* TODO(i18n): Pass selected language here, I think? */)
.add_Hms()
.format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000));

appBar = AppBar(
centerTitle: false,
foregroundColor: appBarForegroundColor,
backgroundColor: appBarBackgroundColor,

// TODO(#41): Show message author's avatar
title: RichText(
text: TextSpan(children: [
// TODO(#45): Format with e.g. "Yesterday at 4:47 PM"
final timestampText = DateFormat
.yMMMd(/* TODO(i18n): Pass selected language here, I think? */)
.add_Hms()
.format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000));

final appBar = PreferredSize(
preferredSize: Size(MediaQuery.of(context).size.width, kToolbarHeight),
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
curve: Curves.easeIn,
height: _headerFooterVisible ? AppBar.preferredHeightFor(
context,
Size(0, MediaQuery.of(context).padding.top + kToolbarHeight)
)
: 0,
child: AppBar(
centerTitle: false,
foregroundColor: appBarForegroundColor,
backgroundColor: appBarBackgroundColor,

// TODO(#41): Show message author's avatar
title: RichText(
text: TextSpan(children: [
TextSpan(
text: '${widget.message.senderFullName}\n',

Expand All @@ -158,19 +166,23 @@ class _LightboxPageState extends State<_LightboxPage> {

// Make smaller, like a subtitle
style: themeData.textTheme.titleSmall!.copyWith(color: appBarForegroundColor)),
])));
}

Widget? bottomAppBar;
if (_headerFooterVisible) {
bottomAppBar = BottomAppBar(
])))));

final bottomAppBar = AnimatedContainer(
duration: const Duration(milliseconds: 100),
curve: Curves.easeIn,
// 80 is the default in M3, we need to set a value for the animation
// to work
height: _headerFooterVisible
? BottomAppBarTheme.of(context).height ?? 80
: 0,
child: BottomAppBar(
color: appBarBackgroundColor,
child: Row(children: [
_CopyLinkButton(url: widget.src),
// TODO(#43): Share image
// TODO(#42): Download image
]));
}
_CopyLinkButton(url: widget.src),
// TODO(#43): Share image
// TODO(#42): Download image
])));

return Theme(
data: themeData.copyWith(
Expand Down Expand Up @@ -216,7 +228,7 @@ Route getLightboxRoute({
Animation<double> secondaryAnimation,
) {
// TODO(#40): Drag down to close?
return _LightboxPage(routeEntranceAnimation: animation, message: message, src: src);
return LightboxPage(routeEntranceAnimation: animation, message: message, src: src);
},
transitionsBuilder: (
BuildContext context,
Expand Down
176 changes: 176 additions & 0 deletions test/widgets/lightbox_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import 'dart:async';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:zulip/api/model/model.dart';
import 'package:zulip/model/narrow.dart';
import 'package:zulip/widgets/content.dart';
import 'package:zulip/widgets/lightbox.dart';
import 'package:zulip/widgets/store.dart';

import '../example_data.dart' as eg;
import '../model/binding.dart';
import '../model/test_store.dart';
import 'content_test.dart';

Future<void> setupToMessageActionSheet(WidgetTester tester, {
required Message message,
required Narrow narrow,
}) async {
addTearDown(() {
TestZulipBinding.instance.reset();
});

await TestZulipBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot());
final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id);
store.addUser(eg.user(userId: message.senderId));

await tester.pumpWidget(
MaterialApp(
home: GlobalStoreWidget(
child: PerAccountStoreWidget(
accountId: eg.selfAccount.id,
child: MediaQuery(
// This simulates the effect of a notch
data: const MediaQueryData(padding: EdgeInsets.only(top: 60),
size: Size(800, 600),
),
child: LightboxPage(
message: message,
routeEntranceAnimation: const AlwaysStoppedAnimation<double>(1),
src: "https://zulip.com/",
),
)))));

// global store, per-account store, and message list get loaded
await tester.pumpAndSettle();
}

void main() {
TestZulipBinding.ensureInitialized();

group('lightbox', () {
setUp(() {
final httpClient = _FakeHttpClient();
debugNetworkImageHttpClientProvider = () => httpClient;
httpClient.request.response
..statusCode = HttpStatus.ok
..content = kSolidBlueAvatar;
});

testWidgets('tries to render an image', (WidgetTester tester) async {
await setupToMessageActionSheet(tester, message: eg.streamMessage(), narrow: StreamNarrow(eg.streamMessage().streamId));

expect(find.byType(RealmContentNetworkImage), findsOneWidget);
// unset the client here, otherwise the test will always fail
debugNetworkImageHttpClientProvider = null;
});

testWidgets('appbar is invisible at first', (WidgetTester tester) async {
await setupToMessageActionSheet(tester, message: eg.streamMessage(), narrow: StreamNarrow(eg.streamMessage().streamId));

final appBarFinder = find.byType(AppBar);
expect(appBarFinder, findsOneWidget);
expect(tester.getSize(appBarFinder).height, 0);

// unset the client here, otherwise the test will always fail
debugNetworkImageHttpClientProvider = null;
});

testWidgets('appbar is visible after a time', (WidgetTester tester) async {
await setupToMessageActionSheet(tester, message: eg.streamMessage(), narrow: StreamNarrow(eg.streamMessage().streamId));

expect(find.byType(AppBar), findsOneWidget);
await tester.tap(find.byType(RealmContentNetworkImage));

await tester.pumpAndSettle(const Duration(milliseconds: 3000));
expect(tester.getSize(find.byType(AppBar)).height, greaterThan(20));

// unset the client here, otherwise the test will always fail
debugNetworkImageHttpClientProvider = null;
});

testWidgets('appbar hides again after a time', (WidgetTester tester) async {
await setupToMessageActionSheet(tester, message: eg.streamMessage(), narrow: StreamNarrow(eg.streamMessage().streamId));

expect(find.byType(AppBar), findsOneWidget);
await tester.tap(find.byType(RealmContentNetworkImage));
await tester.pumpAndSettle(const Duration(milliseconds: 3000));

await tester.tap(find.byType(RealmContentNetworkImage));
await tester.pumpAndSettle(const Duration(milliseconds: 3000));

expect(tester.getSize(find.byType(AppBar)).height, 0);

// unset the client here, otherwise the test will always fail
debugNetworkImageHttpClientProvider = null;
});

testWidgets('appbar is visible despite notch', (WidgetTester tester) async {
await setupToMessageActionSheet(tester, message: eg.streamMessage(), narrow: StreamNarrow(eg.streamMessage().streamId));

expect(find.byType(AppBar), findsOneWidget);
await tester.tap(find.byType(RealmContentNetworkImage));
await tester.pumpAndSettle(const Duration(milliseconds: 3000));

expect(tester.getSize(find.byType(AppBar)).height, greaterThan(20));

// This will fail if the appBar is obstructed by the notch
expect(
find.byWidgetPredicate((widget) =>
widget is RichText &&
widget.text.toPlainText().contains('A Person')).hitTestable(),
findsOneWidget
);

// unset the client here, otherwise the test will always fail
debugNetworkImageHttpClientProvider = null;
});
});
}

class _FakeHttpClient extends Fake implements HttpClient {
final _FakeHttpClientRequest request = _FakeHttpClientRequest();

@override
Future<HttpClientRequest> getUrl(Uri url) async => request;
}

class _FakeHttpClientRequest extends Fake implements HttpClientRequest {
final _FakeHttpClientResponse response = _FakeHttpClientResponse();

@override
final _FakeHttpHeaders headers = _FakeHttpHeaders();

@override
Future<HttpClientResponse> close() async => response;
}

class _FakeHttpHeaders extends Fake implements HttpHeaders {
final Map<String, List<String>> values = {};

@override
void add(String name, Object value, {bool preserveHeaderCase = false}) {
(values[name] ??= []).add(value.toString());
}
}

class _FakeHttpClientResponse extends Fake implements HttpClientResponse {
@override
int statusCode = HttpStatus.ok;

late List<int> content;

@override
int get contentLength => content.length;

@override
HttpClientResponseCompressionState get compressionState => HttpClientResponseCompressionState.notCompressed;

@override
StreamSubscription<List<int>> listen(void Function(List<int> event)? onData, {Function? onError, void Function()? onDone, bool? cancelOnError}) {
return Stream.value(content).listen(
onData, onDone: onDone, onError: onError, cancelOnError: cancelOnError);
}
}