From 28a6de6cb8bdf5b25e870650d45af131635fd591 Mon Sep 17 00:00:00 2001 From: Jaap Aarts Date: Tue, 29 Nov 2022 22:38:17 +0100 Subject: [PATCH] Rework album screen (#288) --- integration_test/album.dart | 139 +++++ .../{login_test.dart => login.dart} | 8 +- integration_test/main_test.dart | 7 + ios/Podfile.lock | 4 +- lib/api/api_repository.dart | 3 + lib/api/concrexit_api_repository.dart | 16 +- lib/blocs/album_cubit.dart | 127 +++- lib/main.dart | 19 +- lib/models/album.dart | 25 +- lib/models/album.g.dart | 4 +- lib/models/photo.dart | 51 +- lib/models/photo.g.dart | 19 + lib/ui/screens/album_screen.dart | 584 ++++++++++++------ pubspec.lock | 2 +- pubspec.yaml | 3 + test/mocks.mocks.dart | 16 + 16 files changed, 818 insertions(+), 209 deletions(-) create mode 100644 integration_test/album.dart rename integration_test/{login_test.dart => login.dart} (96%) create mode 100644 integration_test/main_test.dart diff --git a/integration_test/album.dart b/integration_test/album.dart new file mode 100644 index 000000000..c53d20c54 --- /dev/null +++ b/integration_test/album.dart @@ -0,0 +1,139 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:reaxit/api/exceptions.dart'; +import 'package:reaxit/blocs.dart'; +import 'package:reaxit/main.dart' as app; +import 'package:reaxit/models.dart'; + +import '../test/mocks.mocks.dart'; + +const imagelink1 = + 'https://raw.githubusercontent.com/svthalia/Reaxit/3e3a74364f10cd8de14ac1f74de8a05aa6d00b28/assets/img/album_placeholder.png'; + +const imagelink2 = + 'https://raw.githubusercontent.com/svthalia/Reaxit/3e3a74364f10cd8de14ac1f74de8a05aa6d00b28/assets/img/default-avatar.jpg'; + +const coverphoto1 = CoverPhoto( + 0, + 0, + true, + Photo( + imagelink1, + imagelink1, + imagelink1, + imagelink1, + ), +); + +const albumphoto1 = AlbumPhoto( + 0, + 0, + false, + Photo( + imagelink1, + imagelink1, + imagelink1, + imagelink1, + ), + false, + 0, +); + +const albumphoto2 = AlbumPhoto( + 0, + 0, + false, + Photo( + imagelink2, + imagelink2, + imagelink2, + imagelink2, + ), + false, + 0, +); + +WidgetTesterCallback getTestMethod(Album album) { + return (tester) async { + // Setup mock. + final api = MockApiRepository(); + when(api.getAlbum(slug: album.slug)).thenAnswer( + (realInvocation) async => album, + ); + final authCubit = MockAuthCubit(); + + throwOnMissingStub( + api, + exceptionBuilder: (_) { + throw ApiException.unknownError; + }, + ); + + final streamController = StreamController.broadcast() + ..stream.listen((state) { + when(authCubit.state).thenReturn(state); + }) + ..add(LoadingAuthState()) + ..add(LoggedInAuthState(apiRepository: api)); + + when(authCubit.load()).thenAnswer((_) => Future.value(null)); + when(authCubit.stream).thenAnswer((_) => streamController.stream); + + // Start app + app.testingMain(authCubit, '/albums/${album.slug}'); + await tester.pumpAndSettle(); + await Future.delayed(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + + for (AlbumPhoto photo in album.photos) { + //TODO: wait for https://github.com/flutter/flutter/issues/115479 to be fixed + expect( + find.image( + NetworkImage(photo.small), + ), + findsWidgets, + ); + } + expect(find.text(album.title.toUpperCase()), findsOneWidget); + }; +} + +void testAlbum() async { + final _ = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group( + 'Album', + () { + testWidgets( + 'able to load an album', + getTestMethod( + const Album.fromlist( + '1', + 'mock', + false, + false, + coverphoto1, + [albumphoto1], + ), + ), + ); + testWidgets( + 'able to load an album2', + getTestMethod( + const Album.fromlist( + '1', + 'MOcK2', + false, + false, + coverphoto1, + [albumphoto1, albumphoto2], + ), + ), + ); + }, + ); +} diff --git a/integration_test/login_test.dart b/integration_test/login.dart similarity index 96% rename from integration_test/login_test.dart rename to integration_test/login.dart index c0ed5174e..a831a0a3f 100644 --- a/integration_test/login_test.dart +++ b/integration_test/login.dart @@ -10,7 +10,7 @@ import 'package:reaxit/main.dart' as app; import '../test/mocks.mocks.dart'; -void main() { +void testLogin() { // ignore: unused_local_variable final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -31,7 +31,7 @@ void main() { when(authCubit.stream).thenAnswer((_) => streamController.stream); // Start app. - app.testingMain(authCubit); + app.testingMain(authCubit, null); await tester.pumpAndSettle(); await Future.delayed(const Duration(seconds: 5)); await tester.pumpAndSettle(); @@ -85,7 +85,7 @@ void main() { when(authCubit.stream).thenAnswer((_) => streamController.stream); // Start app. - app.testingMain(authCubit); + app.testingMain(authCubit, null); await tester.pumpAndSettle(); await Future.delayed(const Duration(seconds: 5)); await tester.pumpAndSettle(); @@ -120,7 +120,7 @@ void main() { when(authCubit.logOut()).thenAnswer((_) => Future.value(null)); // Start app. - app.testingMain(authCubit); + app.testingMain(authCubit, null); await tester.pumpAndSettle(); await Future.delayed(const Duration(seconds: 5)); await tester.pumpAndSettle(); diff --git a/integration_test/main_test.dart b/integration_test/main_test.dart new file mode 100644 index 000000000..c15425f96 --- /dev/null +++ b/integration_test/main_test.dart @@ -0,0 +1,7 @@ +import 'album.dart'; +import 'login.dart'; + +void main() { + testLogin(); + testAlbum(); +} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ee27a0ec9..5a54cc800 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -34,7 +34,7 @@ PODS: - GoogleUtilities/UserDefaults (~> 7.8) - nanopb (< 2.30910.0, >= 2.30908.0) - Flutter (1.0.0) - - flutter_secure_storage (3.3.1): + - flutter_secure_storage (6.0.0): - Flutter - flutter_web_auth_2 (1.1.1): - Flutter @@ -177,7 +177,7 @@ SPEC CHECKSUMS: FirebaseInstallations: 004915af170935e3a583faefd5f8bc851afc220f FirebaseMessaging: cc9f40f5b7494680f3844d08e517e92aa4e8d9f7 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 - flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec + flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be flutter_web_auth_2: a1bc00762c408a8f80b72a538cd7ff5b601c3e71 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a gallery_saver: 9fc173c9f4fcc48af53b2a9ebea1b643255be542 diff --git a/lib/api/api_repository.dart b/lib/api/api_repository.dart index 825baa1c7..4321c89ca 100644 --- a/lib/api/api_repository.dart +++ b/lib/api/api_repository.dart @@ -259,6 +259,9 @@ abstract class ApiRepository { /// Get the [Album] with the `slug`. Future getAlbum({required String slug}); + /// Create or delete a like on the photo with the `id`. + Future updateLiked(int id, bool liked); + /// Get a list of [ListAlbum]s. /// /// Use `limit` and `offset` for pagination. [ListResponse.count] is the diff --git a/lib/api/concrexit_api_repository.dart b/lib/api/concrexit_api_repository.dart index d402b273b..18ac4872d 100644 --- a/lib/api/concrexit_api_repository.dart +++ b/lib/api/concrexit_api_repository.dart @@ -63,7 +63,7 @@ class ConcrexitApiRepository implements ApiRepository { /// /// Translates exceptions that can be thrown by [oauth2.Client.send()], /// and throws exceptions based on status codes. By default, all status codes - /// other than 200, 201 and 204 result in an [ApiException], but this can be + /// other than 200, 201, 203, and 204 result in an [ApiException], but this can be /// overridden with `allowedStatusCodes`. /// /// Can be called for example as: @@ -81,7 +81,7 @@ class ConcrexitApiRepository implements ApiRepository { /// ``` Future _handleExceptions( Future Function() request, { - List allowedStatusCodes = const [200, 201, 204], + List allowedStatusCodes = const [200, 201, 202, 204], }) async { try { final response = await request(); @@ -923,6 +923,18 @@ class ConcrexitApiRepository implements ApiRepository { }); } + @override + Future updateLiked(int pk, bool liked) async { + return sandbox(() async { + final uri = _uri(path: '/photos/photos/$pk/like/'); + await _handleExceptions( + () => liked + ? _client.post(uri, headers: _jsonHeader) + : _client.delete(uri, headers: _jsonHeader), + ); + }); + } + @override Future> getAlbums({ String? search, diff --git a/lib/blocs/album_cubit.dart b/lib/blocs/album_cubit.dart index 93fcaf60c..bd1ce0960 100644 --- a/lib/blocs/album_cubit.dart +++ b/lib/blocs/album_cubit.dart @@ -1,4 +1,6 @@ +import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:meta/meta.dart'; import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/api/exceptions.dart'; import 'package:reaxit/blocs.dart'; @@ -6,18 +8,135 @@ import 'package:reaxit/models.dart'; typedef AlbumState = DetailState; -class AlbumCubit extends Cubit { +class AlbumScreenState extends Equatable { + /// This can only be null when [isLoading] or [hasException] is true. + final Album? album; + final bool isOpen; + final int? initialGalleryIndex; + + final String? message; + final bool isLoading; + bool get hasException => message != null; + + @protected + const AlbumScreenState({ + required this.album, + required this.isLoading, + required this.message, + this.isOpen = false, + this.initialGalleryIndex, + }) : assert( + album != null || isLoading || message != null, + 'album can only be null when isLoading or hasException is true.', + ), + assert( + isOpen || initialGalleryIndex == null, + 'initialGalleryIndex can only be set when isOpen is true.', + ), + assert( + initialGalleryIndex != null || !isOpen, + 'initialGalleryIndex must be set when isOpen is true.', + ), + assert( + !isOpen || album != null, + 'album must be set when isOpen is true.', + ); + + @override + List get props => [ + album, + message, + isLoading, + isOpen, + initialGalleryIndex, + ]; + + AlbumScreenState copyWith({ + Album? album, + List? registrations, + bool? isLoading, + String? message, + bool? isOpen, + int? initialGalleryIndex, + }) => + AlbumScreenState( + album: album ?? this.album, + isLoading: isLoading ?? this.isLoading, + message: message ?? this.message, + isOpen: isOpen ?? this.isOpen, + initialGalleryIndex: (isOpen ?? this.isOpen) + ? (initialGalleryIndex ?? this.initialGalleryIndex) + : null, + ); + + const AlbumScreenState.result( + {required this.album, required this.isOpen, this.initialGalleryIndex}) + : message = null, + isLoading = false; + + const AlbumScreenState.loading() + : message = null, + album = null, + isLoading = true, + isOpen = false, + initialGalleryIndex = null; + + const AlbumScreenState.failure({required String this.message}) + : album = null, + isLoading = false, + isOpen = false, + initialGalleryIndex = null; +} + +class AlbumCubit extends Cubit { final ApiRepository api; - AlbumCubit(this.api) : super(const AlbumState.loading()); + AlbumCubit(this.api) : super(const AlbumScreenState.loading()); + + Future updateLike({required bool liked, required int index}) async { + if (state.album == null) { + return; + } + + // Emit expected state after (un)liking. + final oldphoto = state.album!.photos[index]; + AlbumPhoto newphoto = oldphoto.copyWith( + liked: liked, + numLikes: oldphoto.numLikes + (liked ? 1 : -1), + ); + List newphotos = [...state.album!.photos]; + newphotos[index] = newphoto; + emit(AlbumScreenState.result( + album: state.album!.copyWith(photos: newphotos), + isOpen: state.isOpen, + initialGalleryIndex: state.initialGalleryIndex, + )); + + try { + await api.updateLiked(newphoto.pk, liked); + } on ApiException { + // Revert to state before (un)liking. + emit(state); + rethrow; + } + } + + void openGallery(int index) { + if (state.album == null) return; + emit(state.copyWith(isOpen: true, initialGalleryIndex: index)); + } + + void closeGallery() { + emit(state.copyWith(isOpen: false)); + } Future load(String slug) async { emit(state.copyWith(isLoading: true)); try { final album = await api.getAlbum(slug: slug); - emit(AlbumState.result(result: album)); + emit(AlbumScreenState.result(album: album, isOpen: false)); } on ApiException catch (exception) { - emit(AlbumState.failure( + emit(AlbumScreenState.failure( message: exception.getMessage(notFound: 'The album does not exist.'), )); } diff --git a/lib/main.dart b/lib/main.dart index 996673689..519064579 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -50,7 +50,7 @@ Future main() async { lazy: false, child: BlocProvider( create: (context) => AuthCubit()..load(), - child: ThaliApp(), + child: const ThaliApp(), ), )); }, @@ -58,7 +58,7 @@ Future main() async { } /// A copy of [main] that allows inserting an [AuthCubit] for integration tests. -Future testingMain(AuthCubit? authCubit) async { +Future testingMain(AuthCubit? authCubit, String? initialroute) async { WidgetsFlutterBinding.ensureInitialized(); // Google Fonts doesn't need to download fonts as they are bundled. @@ -84,9 +84,15 @@ Future testingMain(AuthCubit? authCubit) async { child: authCubit == null ? BlocProvider( create: (context) => AuthCubit()..load(), - child: ThaliApp(), + child: ThaliApp( + initialRoute: initialroute, + ), ) - : BlocProvider.value(value: authCubit..load(), child: ThaliApp()), + : BlocProvider.value( + value: authCubit..load(), + child: ThaliApp( + initialRoute: initialroute, + )), )); } @@ -106,6 +112,9 @@ class GoRouterRefreshStream extends ChangeNotifier { } class ThaliApp extends StatefulWidget { + final String? initialRoute; + const ThaliApp({this.initialRoute}); + @override State createState() => _ThaliAppState(); } @@ -210,6 +219,8 @@ class _ThaliAppState extends State { // Refresh to look for redirects whenever auth state changes. refreshListenable: GoRouterRefreshStream(_authCubit.stream), + + initialLocation: widget.initialRoute, ); _setupPushNotificationHandlers(); diff --git a/lib/models/album.dart b/lib/models/album.dart index 4c2330cc8..cde99b5f3 100644 --- a/lib/models/album.dart +++ b/lib/models/album.dart @@ -9,7 +9,7 @@ class ListAlbum { final String title; final bool accessible; final bool shareable; - final AlbumPhoto cover; + final CoverPhoto cover; const ListAlbum( this.slug, this.title, this.accessible, this.shareable, this.cover); @@ -22,8 +22,29 @@ class ListAlbum { class Album extends ListAlbum { final List photos; + Album copyWith({ + String? slug, + String? title, + bool? accessible, + bool? shareable, + CoverPhoto? cover, + List? photos, + }) => + Album( + slug ?? this.slug, + title ?? this.title, + accessible ?? this.accessible, + shareable ?? this.shareable, + cover ?? this.cover, + photos ?? this.photos, + ); + + const Album.fromlist(String slug, String title, bool accessible, + bool shareable, CoverPhoto cover, this.photos) + : super(slug, title, accessible, shareable, cover); + const Album(String slug, String title, bool accessible, bool shareable, - AlbumPhoto cover, this.photos) + CoverPhoto cover, this.photos) : super(slug, title, accessible, shareable, cover); factory Album.fromJson(Map json) => _$AlbumFromJson(json); diff --git a/lib/models/album.g.dart b/lib/models/album.g.dart index fa4d8ce0a..8c2b0788f 100644 --- a/lib/models/album.g.dart +++ b/lib/models/album.g.dart @@ -11,7 +11,7 @@ ListAlbum _$ListAlbumFromJson(Map json) => ListAlbum( json['title'] as String, json['accessible'] as bool, json['shareable'] as bool, - AlbumPhoto.fromJson(json['cover'] as Map), + CoverPhoto.fromJson(json['cover'] as Map), ); Map _$ListAlbumToJson(ListAlbum instance) => { @@ -27,7 +27,7 @@ Album _$AlbumFromJson(Map json) => Album( json['title'] as String, json['accessible'] as bool, json['shareable'] as bool, - AlbumPhoto.fromJson(json['cover'] as Map), + CoverPhoto.fromJson(json['cover'] as Map), (json['photos'] as List) .map((e) => AlbumPhoto.fromJson(e as Map)) .toList(), diff --git a/lib/models/photo.dart b/lib/models/photo.dart index 4345d8447..21d4b6166 100644 --- a/lib/models/photo.dart +++ b/lib/models/photo.dart @@ -3,7 +3,7 @@ import 'package:json_annotation/json_annotation.dart'; part 'photo.g.dart'; @JsonSerializable(fieldRename: FieldRename.snake) -class AlbumPhoto { +class CoverPhoto { final int pk; final int rotation; final bool hidden; @@ -14,12 +14,51 @@ class AlbumPhoto { String get medium => file.medium; String get large => file.large; + CoverPhoto copyWith({ + int? pk, + int? rotation, + bool? hidden, + Photo? file, + }) => + CoverPhoto( + pk ?? this.pk, + rotation ?? this.rotation, + hidden ?? this.hidden, + file ?? this.file, + ); + + const CoverPhoto(this.pk, this.rotation, this.hidden, this.file); + + factory CoverPhoto.fromJson(Map json) => + _$CoverPhotoFromJson(json); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class AlbumPhoto extends CoverPhoto { + final bool liked; + final int numLikes; + + @override + AlbumPhoto copyWith({ + int? pk, + int? rotation, + bool? hidden, + Photo? file, + bool? liked, + int? numLikes, + }) => + AlbumPhoto( + pk ?? this.pk, + rotation ?? this.rotation, + hidden ?? this.hidden, + file ?? this.file, + liked ?? this.liked, + numLikes ?? this.numLikes, + ); + const AlbumPhoto( - this.pk, - this.rotation, - this.hidden, - this.file, - ); + int pk, int rotation, bool hidden, Photo file, this.liked, this.numLikes) + : super(pk, rotation, hidden, file); factory AlbumPhoto.fromJson(Map json) => _$AlbumPhotoFromJson(json); diff --git a/lib/models/photo.g.dart b/lib/models/photo.g.dart index 7e4adc792..813110fd4 100644 --- a/lib/models/photo.g.dart +++ b/lib/models/photo.g.dart @@ -6,11 +6,28 @@ part of 'photo.dart'; // JsonSerializableGenerator // ************************************************************************** +CoverPhoto _$CoverPhotoFromJson(Map json) => CoverPhoto( + json['pk'] as int, + json['rotation'] as int, + json['hidden'] as bool, + Photo.fromJson(json['file'] as Map), + ); + +Map _$CoverPhotoToJson(CoverPhoto instance) => + { + 'pk': instance.pk, + 'rotation': instance.rotation, + 'hidden': instance.hidden, + 'file': instance.file, + }; + AlbumPhoto _$AlbumPhotoFromJson(Map json) => AlbumPhoto( json['pk'] as int, json['rotation'] as int, json['hidden'] as bool, Photo.fromJson(json['file'] as Map), + json['liked'] as bool, + json['num_likes'] as int, ); Map _$AlbumPhotoToJson(AlbumPhoto instance) => @@ -19,6 +36,8 @@ Map _$AlbumPhotoToJson(AlbumPhoto instance) => 'rotation': instance.rotation, 'hidden': instance.hidden, 'file': instance.file, + 'liked': instance.liked, + 'num_likes': instance.numLikes, }; Photo _$PhotoFromJson(Map json) => Photo( diff --git a/lib/ui/screens/album_screen.dart b/lib/ui/screens/album_screen.dart index 3a3ca720a..d7415d41f 100644 --- a/lib/ui/screens/album_screen.dart +++ b/lib/ui/screens/album_screen.dart @@ -3,234 +3,454 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:http/http.dart' as http; +import 'package:mime/mime.dart'; import 'package:path_provider/path_provider.dart'; import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; +import 'package:reaxit/api/exceptions.dart'; import 'package:reaxit/blocs.dart'; import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/models.dart'; +import 'package:reaxit/ui/theme.dart'; import 'package:reaxit/ui/widgets.dart'; import 'package:reaxit/config.dart' as config; import 'package:share_plus/share_plus.dart'; import 'package:gallery_saver/gallery_saver.dart'; -/// Screen that loads and shows a the Album of the member with `slug`. -class AlbumScreen extends StatefulWidget { +/// Screen that loads and shows the Album with `slug`. +class AlbumScreen extends StatelessWidget { final String slug; final ListAlbum? album; AlbumScreen({required this.slug, this.album}) : super(key: ValueKey(slug)); + String get title => album?.title ?? 'ALBUM'; + + Future _shareAlbum(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + try { + await Share.share('https://${config.apiHost}/members/photos/$slug/'); + } catch (_) { + messenger.showSnackBar( + const SnackBar( + behavior: SnackBarBehavior.floating, + content: Text('Could not share the album.'), + ), + ); + } + } + + Widget _shareAlbumButton(BuildContext context) => IconButton( + padding: const EdgeInsets.all(16), + color: Theme.of(context).primaryIconTheme.color, + icon: Icon(Icons.adaptive.share), + onPressed: () => _shareAlbum(context), + ); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => AlbumCubit( + RepositoryProvider.of(context), + )..load(slug), + child: BlocBuilder( + builder: (context, state) { + late final Widget body; + if (state.isLoading) { + body = const Center(child: CircularProgressIndicator()); + } else if (state.hasException) { + body = ErrorScrollView(state.message!); + } else { + body = _PhotoGrid(state.album!.photos); + } + + Widget mainScaffold = Scaffold( + appBar: ThaliaAppBar( + title: Text(state.album?.title.toUpperCase() ?? title), + actions: [_shareAlbumButton(context)], + ), + body: body, + ); + + return Stack( + children: [ + mainScaffold, + if (state.isOpen) + _Gallery( + album: state.album!, + initialPage: state.initialGalleryIndex!, + ), + ], + ); + }, + ), + ); + } +} + +class _Gallery extends StatefulWidget { + final Album album; + final int initialPage; + + const _Gallery({required this.album, required this.initialPage}); + @override - State createState() => _AlbumScreenState(); + State<_Gallery> createState() => __GalleryState(); } -class _AlbumScreenState extends State { - late final AlbumCubit _albumCubit; +class __GalleryState extends State<_Gallery> with TickerProviderStateMixin { + late final PageController controller; + + late final AnimationController likeController; + late final AnimationController unlikeController; + late final Animation likeAnimation; + late final Animation unlikeAnimation; @override void initState() { - _albumCubit = AlbumCubit( - RepositoryProvider.of(context), - )..load(widget.slug); super.initState(); + controller = PageController(initialPage: widget.initialPage); + + likeController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + upperBound: 0.8, + )..addStatusListener((status) { + if (status == AnimationStatus.completed) likeController.reset(); + }); + unlikeController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + upperBound: 0.8, + )..addStatusListener((status) { + if (status == AnimationStatus.completed) unlikeController.reset(); + }); + + unlikeAnimation = CurvedAnimation( + parent: unlikeController, + curve: Curves.elasticOut, + ); + likeAnimation = CurvedAnimation( + parent: likeController, + curve: Curves.elasticOut, + ); } - @override - void dispose() { - _albumCubit.close(); - super.dispose(); + Future _downloadImage(Uri url) async { + final messenger = ScaffoldMessenger.of(context); + try { + final response = await http.get(url); + if (response.statusCode != 200) throw Exception(); + final baseTempDir = await getTemporaryDirectory(); + final tempDir = await baseTempDir.createTemp(); + final tempFile = File( + '${tempDir.path}/${url.pathSegments.last}', + ); + await tempFile.writeAsBytes(response.bodyBytes); + await GallerySaver.saveImage(tempFile.path); + + messenger.showSnackBar( + const SnackBar( + behavior: SnackBarBehavior.floating, + content: Text('Succesfully saved the image.'), + ), + ); + } catch (_) { + messenger.showSnackBar( + const SnackBar( + behavior: SnackBarBehavior.floating, + content: Text('Could not download the image.'), + ), + ); + } } - Widget _makePhotoCard(Album album, int index) { - return GestureDetector( - onTap: () => _showPhotoGallery(album, index), - child: FadeInImage.assetNetwork( - placeholder: 'assets/img/photo_placeholder.png', - image: album.photos[index].small, - fit: BoxFit.cover, - ), - ); + Future _shareImage(Uri url) async { + final messenger = ScaffoldMessenger.of(context); + try { + final response = await http.get(url); + if (response.statusCode != 200) throw Exception(); + final file = XFile.fromData( + response.bodyBytes, + mimeType: lookupMimeType(url.path, headerBytes: response.bodyBytes), + name: url.pathSegments.last, + ); + await Share.shareXFiles([file]); + } catch (_) { + messenger.showSnackBar( + const SnackBar( + behavior: SnackBarBehavior.floating, + content: Text('Could not share the image.'), + ), + ); + } } - void _showPhotoGallery(Album album, int index) { - showDialog( - context: context, - useSafeArea: false, - barrierColor: Colors.black.withOpacity(0.92), - builder: (context) { - final pageController = PageController(initialPage: index); - return Scaffold( - extendBodyBehindAppBar: true, - backgroundColor: Colors.transparent, - appBar: AppBar( - backgroundColor: Colors.transparent, - shadowColor: Colors.transparent, - leading: CloseButton( - color: Theme.of(context).primaryIconTheme.color, - ), - actions: [ - IconButton( - padding: const EdgeInsets.all(16), - color: Theme.of(context).primaryIconTheme.color, - icon: const Icon(Icons.download), - onPressed: () async { - final messenger = ScaffoldMessenger.of(context); - - var i = pageController.page!.round(); - if (i < 0 || i >= album.photos.length) i = index; - final url = Uri.parse(album.photos[i].full); - try { - final response = await http.get(url); - if (response.statusCode != 200) throw Exception(); - final baseTempDir = await getTemporaryDirectory(); - final tempDir = await baseTempDir.createTemp(); - final tempFile = File( - '${tempDir.path}/${url.pathSegments.last}', - ); - await tempFile.writeAsBytes(response.bodyBytes); - await GallerySaver.saveImage(tempFile.path); - - messenger.showSnackBar( - const SnackBar( - behavior: SnackBarBehavior.floating, - content: Text('Succesfully saved the image.'), - ), - ); - } catch (_) { - messenger.showSnackBar( - const SnackBar( - behavior: SnackBarBehavior.floating, - content: Text('Could not download the image.'), - ), - ); - } - }, - ), - IconButton( - padding: const EdgeInsets.all(16), - color: Theme.of(context).primaryIconTheme.color, - icon: Icon( - Theme.of(context).platform == TargetPlatform.iOS - ? Icons.ios_share - : Icons.share, - ), - onPressed: () async { - final messenger = ScaffoldMessenger.of(context); - - var i = pageController.page!.round(); - if (i < 0 || i >= album.photos.length) i = index; - final url = Uri.parse(album.photos[i].full); - try { - final response = await http.get(url); - if (response.statusCode != 200) throw Exception(); - final file = XFile.fromData( - response.bodyBytes, - name: url.pathSegments.last, - ); - await Share.shareXFiles([file]); - } catch (_) { - messenger.showSnackBar( - const SnackBar( - behavior: SnackBarBehavior.floating, - content: Text('Could not share the image.'), - ), - ); - } - }, - ), - ], - ), - body: PhotoViewGallery.builder( - loadingBuilder: (_, __) => const Center( - child: CircularProgressIndicator(), - ), - backgroundDecoration: const BoxDecoration( - color: Colors.transparent, - ), - itemCount: album.photos.length, - builder: (context, i) => PhotoViewGalleryPageOptions( - imageProvider: NetworkImage(album.photos[i].full), - minScale: PhotoViewComputedScale.contained * 0.8, - maxScale: PhotoViewComputedScale.covered * 2, + Future _likePhoto(List photos, int index) async { + final messenger = ScaffoldMessenger.of(context); + if (photos[index].liked) { + unlikeController.forward(); + } else { + likeController.forward(); + } + try { + await BlocProvider.of(context).updateLike( + liked: !photos[index].liked, + index: index, + ); + } on ApiException { + messenger.showSnackBar( + const SnackBar( + content: Text('Something went wrong while liking the photo.'), + ), + ); + } + } + + Widget _gallery(List photos) => PhotoViewGallery.builder( + backgroundDecoration: const BoxDecoration(color: Colors.transparent), + pageController: controller, + itemCount: photos.length, + loadingBuilder: (_, __) => const Center( + child: CircularProgressIndicator(), + ), + builder: (context, i) { + return PhotoViewGalleryPageOptions.customChild( + child: GestureDetector( + onDoubleTap: () => _likePhoto(photos, i), + child: Image.network(photos[i].full), ), - pageController: pageController, - ), - ); - }, + minScale: PhotoViewComputedScale.contained * 0.8, + maxScale: PhotoViewComputedScale.covered * 2, + ); + }, + ); + + Widget _downloadButton(List photos) { + return IconButton( + padding: const EdgeInsets.all(16), + color: Theme.of(context).primaryIconTheme.color, + icon: const Icon(Icons.download), + onPressed: () => _downloadImage( + Uri.parse(photos[controller.page!.round()].full), + ), ); } - Widget _makeShareAlbumButton(String slug) { + Widget _shareButton(List photos) { return IconButton( padding: const EdgeInsets.all(16), color: Theme.of(context).primaryIconTheme.color, - icon: Icon( - Theme.of(context).platform == TargetPlatform.iOS - ? Icons.ios_share - : Icons.share, + icon: Icon(Icons.adaptive.share), + onPressed: () => _shareImage( + Uri.parse(photos[controller.page!.floor()].full), ), - onPressed: () async { - final messenger = ScaffoldMessenger.of(context); - try { - await Share.share('https://${config.apiHost}/members/photos/$slug/'); - } catch (_) { - messenger.showSnackBar(const SnackBar( - behavior: SnackBarBehavior.floating, - content: Text('Could not share the album.'), - )); - } - }, ); } @override Widget build(BuildContext context) { - return BlocBuilder( - bloc: _albumCubit, - builder: (context, state) { - if (state.hasException) { - return Scaffold( - appBar: ThaliaAppBar( - title: Text(widget.album?.title.toUpperCase() ?? 'ALBUM'), - actions: [_makeShareAlbumButton(widget.slug)], + Widget overlayScaffold = Scaffold( + extendBodyBehindAppBar: true, + backgroundColor: Colors.black.withOpacity(0.92), + appBar: AppBar( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + leading: CloseButton( + color: Theme.of(context).primaryIconTheme.color, + onPressed: BlocProvider.of(context).closeGallery, + ), + actions: [ + _downloadButton(widget.album.photos), + _shareButton(widget.album.photos), + ], + ), + body: Stack( + children: [ + _gallery(widget.album.photos), + Align( + alignment: Alignment.bottomCenter, + child: SafeArea( + child: _PageCounter( + controller, + widget.initialPage, + widget.album, + _likePhoto, + ), ), - body: ErrorScrollView(state.message!), - ); - } else if (state.isLoading) { - return Scaffold( - appBar: ThaliaAppBar( - title: Text(widget.album?.title.toUpperCase() ?? 'ALBUM'), - actions: [_makeShareAlbumButton(widget.slug)], + ), + ], + ), + ); + + return Stack( + alignment: AlignmentDirectional.center, + children: [ + overlayScaffold, + _HeartPopup(animation: unlikeAnimation, color: Colors.white), + _HeartPopup(animation: likeAnimation, color: magenta) + ], + ); + } +} + +class _HeartPopup extends StatelessWidget { + const _HeartPopup({ + Key? key, + required this.animation, + required this.color, + }) : super(key: key); + + final Animation animation; + final Color color; + + @override + Widget build(BuildContext context) { + return ScaleTransition( + scale: animation, + child: Center( + child: Icon( + Icons.favorite, + size: 70, + color: color, + shadows: const [ + BoxShadow( + color: Colors.black54, + spreadRadius: 20, + blurRadius: 20, ), - body: const Center(child: CircularProgressIndicator()), - ); - } else { - return Scaffold( - appBar: ThaliaAppBar( - title: Text(state.result!.title.toUpperCase()), - actions: [_makeShareAlbumButton(widget.slug)], + ], + ), + ), + ); + } +} + +class _PhotoGrid extends StatelessWidget { + final List photos; + + const _PhotoGrid(this.photos); + + @override + Widget build(BuildContext context) { + return Scrollbar( + child: GridView.builder( + key: const PageStorageKey('album'), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisSpacing: 8, + mainAxisSpacing: 8, + crossAxisCount: 3, + ), + itemCount: photos.length, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + itemBuilder: (context, index) => _PhotoTile( + photo: photos[index], + openGallery: () => + BlocProvider.of(context).openGallery(index), + ), + ), + ); + } +} + +class _PhotoTile extends StatelessWidget { + final AlbumPhoto photo; + final void Function() openGallery; + + _PhotoTile({ + required this.photo, + required this.openGallery, + }) : super(key: ValueKey(photo.pk)); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: openGallery, + child: FadeInImage.assetNetwork( + placeholder: 'assets/img/photo_placeholder.png', + image: photo.small, + fit: BoxFit.cover, + ), + ); + } +} + +class _PageCounter extends StatefulWidget { + final PageController controller; + final int initialPage; + final Album album; + final void Function(List photos, int index) likePhoto; + + const _PageCounter( + this.controller, + this.initialPage, + this.album, + this.likePhoto, + ); + + @override + State<_PageCounter> createState() => __PageCounterState(); +} + +class __PageCounterState extends State<_PageCounter> { + late int currentIndex; + + void onPageChange() { + final newIndex = widget.controller.page!.round(); + if (newIndex != currentIndex) { + setState(() => currentIndex = newIndex); + } + } + + @override + void initState() { + currentIndex = widget.initialPage; + widget.controller.addListener(onPageChange); + super.initState(); + } + + @override + void dispose() { + widget.controller.removeListener(onPageChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final photo = widget.album.photos[currentIndex]; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${currentIndex + 1} / ${widget.album.photos.length}', + style: + textTheme.bodyText1?.copyWith(fontSize: 24, color: Colors.white), + ), + Tooltip( + message: 'like photo', + child: IconButton( + iconSize: 24, + icon: Icon( + color: photo.liked ? magenta : Colors.white, + photo.liked ? Icons.favorite : Icons.favorite_outline, ), - body: Scrollbar( - child: GridView.builder( - key: const PageStorageKey('album'), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisSpacing: 8, - mainAxisSpacing: 8, - crossAxisCount: 3, - ), - itemCount: state.result!.photos.length, - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(8), - itemBuilder: (context, index) => _makePhotoCard( - state.result!, - index, - ), - ), + onPressed: () => widget.likePhoto( + widget.album.photos, + currentIndex, ), - ); - } - }, + ), + ), + Text( + '${photo.numLikes}', + style: textTheme.bodyText1?.copyWith( + fontSize: 24, + color: Colors.white, + ), + ), + ], ); } } diff --git a/pubspec.lock b/pubspec.lock index 14ced486b..e2e4ef701 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -662,7 +662,7 @@ packages: source: hosted version: "1.8.0" mime: - dependency: transitive + dependency: "direct main" description: name: mime url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index f00bbfa16..5896d7d20 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -73,6 +73,9 @@ dependencies: # Share menu. share_plus: ^6.2.0 + # Get mime type from file extension. + mime: ^1.0.2 + # Path for temporary files. path_provider: ^2.0.11 diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart index 8c7050088..30b56d2ff 100644 --- a/test/mocks.mocks.dart +++ b/test/mocks.mocks.dart @@ -1101,6 +1101,22 @@ class MockApiRepository extends _i1.Mock implements _i4.ApiRepository { )), ) as _i5.Future<_i3.Album>); @override + _i5.Future updateLiked( + int? id, + bool? liked, + ) => + (super.noSuchMethod( + Invocation.method( + #updateLiked, + [ + id, + liked, + ], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + @override _i5.Future<_i3.ListResponse<_i3.ListAlbum>> getAlbums({ String? search, int? limit,