diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index e68e15a64d..ea12f33872 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -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, @@ -93,10 +94,10 @@ class _LightboxPage extends StatefulWidget { final String src; @override - State<_LightboxPage> createState() => _LightboxPageState(); + State createState() => _LightboxPageState(); } -class _LightboxPageState extends State<_LightboxPage> { +class _LightboxPageState extends State { // TODO(#38): Animate entrance/exit of header and footer bool _headerFooterVisible = false; @@ -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', @@ -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( @@ -216,7 +228,7 @@ Route getLightboxRoute({ Animation 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, diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart new file mode 100644 index 0000000000..31ae7610a9 --- /dev/null +++ b/test/widgets/lightbox_test.dart @@ -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 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(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 getUrl(Uri url) async => request; +} + +class _FakeHttpClientRequest extends Fake implements HttpClientRequest { + final _FakeHttpClientResponse response = _FakeHttpClientResponse(); + + @override + final _FakeHttpHeaders headers = _FakeHttpHeaders(); + + @override + Future close() async => response; +} + +class _FakeHttpHeaders extends Fake implements HttpHeaders { + final Map> 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 content; + + @override + int get contentLength => content.length; + + @override + HttpClientResponseCompressionState get compressionState => HttpClientResponseCompressionState.notCompressed; + + @override + StreamSubscription> listen(void Function(List event)? onData, {Function? onError, void Function()? onDone, bool? cancelOnError}) { + return Stream.value(content).listen( + onData, onDone: onDone, onError: onError, cancelOnError: cancelOnError); + } +}