From 3576f7e3971fbf6a035768ff80aa893f0891b6ae Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 7 Oct 2024 01:15:45 +0800 Subject: [PATCH] Completely rewrite Viewer --- app/build.yaml | 1 + app/lib/controller/files_controller.dart | 36 +- app/lib/controller/files_controller.g.dart | 4 +- app/lib/controller/pref_controller.dart | 36 + app/lib/controller/pref_controller.g.dart | 28 + app/lib/controller/pref_controller/util.dart | 18 + app/lib/entity/pref/extension.dart | 28 - app/lib/exception_util.dart | 7 + app/lib/widget/archive_browser/bloc.dart | 29 +- app/lib/widget/archive_browser/view.dart | 3 +- app/lib/widget/collection_browser/bloc.dart | 15 +- app/lib/widget/collection_browser/view.dart | 11 +- app/lib/widget/file_content_view.dart | 185 +++ app/lib/widget/file_content_view.g.dart | 191 +++ app/lib/widget/file_content_view/bloc.dart | 135 +++ .../widget/file_content_view/state_event.dart | 169 +++ app/lib/widget/file_content_view/view.dart | 107 ++ app/lib/widget/home_collections.g.dart | 2 +- app/lib/widget/home_photos/view.dart | 3 +- app/lib/widget/home_search.dart | 9 +- app/lib/widget/image_viewer.dart | 101 +- app/lib/widget/result_viewer.dart | 3 +- app/lib/widget/viewer.dart | 1080 +++-------------- app/lib/widget/viewer.g.dart | 412 ++++++- app/lib/widget/viewer/app_bar.dart | 213 ++++ app/lib/widget/viewer/bloc.dart | 441 +++++++ app/lib/widget/viewer/detail_pane.dart | 111 ++ app/lib/widget/viewer/state_event.dart | 412 +++++++ app/lib/widget/viewer/type.dart | 25 + app/lib/widget/viewer/view.dart | 372 ++++++ app/lib/widget/viewer_bottom_app_bar.dart | 41 - app/lib/widget/zoomable_viewer.dart | 4 +- np_collection/lib/src/list_extension.dart | 2 + 33 files changed, 3151 insertions(+), 1083 deletions(-) create mode 100644 app/lib/widget/file_content_view.dart create mode 100644 app/lib/widget/file_content_view.g.dart create mode 100644 app/lib/widget/file_content_view/bloc.dart create mode 100644 app/lib/widget/file_content_view/state_event.dart create mode 100644 app/lib/widget/file_content_view/view.dart create mode 100644 app/lib/widget/viewer/app_bar.dart create mode 100644 app/lib/widget/viewer/bloc.dart create mode 100644 app/lib/widget/viewer/detail_pane.dart create mode 100644 app/lib/widget/viewer/state_event.dart create mode 100644 app/lib/widget/viewer/type.dart create mode 100644 app/lib/widget/viewer/view.dart delete mode 100644 app/lib/widget/viewer_bottom_app_bar.dart diff --git a/app/build.yaml b/app/build.yaml index 3f712dba..bb77db2a 100644 --- a/app/build.yaml +++ b/app/build.yaml @@ -7,6 +7,7 @@ targets: double: "${$?.toStringAsFixed(3)}" List: "[length: ${$?.length}]" Set: "{length: ${$?.length}}" + Map: "{length: ${$?.length}}" File: "${$?.path}" FileDescriptor: "${$?.fdPath}" useEnumName: true diff --git a/app/lib/controller/files_controller.dart b/app/lib/controller/files_controller.dart index 63c6ca04..d02b4c89 100644 --- a/app/lib/controller/files_controller.dart +++ b/app/lib/controller/files_controller.dart @@ -11,6 +11,7 @@ import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/exception_event.dart'; import 'package:nc_photos/progress_util.dart'; import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; import 'package:nc_photos/rx_extension.dart'; @@ -90,6 +91,8 @@ class FilesController { /// callers must sort it by themselves if the ordering is important ValueStream get stream => _dataStreamController.stream; + Stream get errorStream => _dataErrorStreamController.stream; + /// Return a stream of file summaries associated with [account] /// /// File summary contains the number of files grouped by their dates @@ -107,6 +110,9 @@ class FilesController { ValueStream get timelineStream => _timelineStreamController.stream; + Stream get timelineErrorStream => + _timelineErrorStreamController.stream; + Future syncRemote({ void Function(Progress progress)? onProgressUpdate, }) async { @@ -166,7 +172,7 @@ class FilesController { OrNull? overrideDateTime, bool? isFavorite, OrNull? location, - Exception? Function(List fileIds) errorBuilder = + Exception? Function(List files) errorBuilder = UpdatePropertyFailureError.new, }) async { final dataBackups = {}; @@ -287,8 +293,8 @@ class FilesController { } return value.copyWith(data: next); }); - errorBuilder(failures.map((e) => e.fdId).toList()) - ?.let(_dataStreamController.addError); + errorBuilder(failures) + ?.let((e) => _dataErrorStreamController.add(ExceptionEvent(e))); } // TODO query outdated @@ -299,7 +305,7 @@ class FilesController { Future remove( List files, { - Exception? Function(List fileIds) errorBuilder = + Exception? Function(List files) errorBuilder = RemoveFailureError.new, }) async { final dataBackups = {}; @@ -391,8 +397,8 @@ class FilesController { } return value.copyWith(data: next); }); - errorBuilder(failures.map((e) => e.fdId).toList()) - ?.let(_dataStreamController.addError); + errorBuilder(failures) + ?.let((e) => _dataErrorStreamController.add(ExceptionEvent(e))); } } @@ -447,7 +453,7 @@ class FilesController { files: v.files.addedAll(data), )); } catch (e, stackTrace) { - _dataStreamController.addError(e, stackTrace); + _dataErrorStreamController.add(ExceptionEvent(e, stackTrace)); } } @@ -462,7 +468,7 @@ class FilesController { files: v.files.addedAll(data), )); } catch (e, stackTrace) { - _dataStreamController.addError(e, stackTrace); + _dataErrorStreamController.add(ExceptionEvent(e, stackTrace)); } } @@ -483,7 +489,7 @@ class FilesController { )); _addTimelineDateRange(dateRange); } catch (e, stackTrace) { - _timelineStreamController.addError(e, stackTrace); + _timelineErrorStreamController.add(ExceptionEvent(e, stackTrace)); } } @@ -663,6 +669,8 @@ class FilesController { hasNext: true, ), ); + final _dataErrorStreamController = + StreamController.broadcast(); var _isSummaryStreamInited = false; final _summaryStreamController = BehaviorSubject(); @@ -670,6 +678,8 @@ class FilesController { final _timelineStreamController = BehaviorSubject.seeded( const TimelineStreamEvent(data: {}, isDummy: true), ); + final _timelineErrorStreamController = + StreamController.broadcast(); // sorted in descending order var _timelineQueriedRanges = []; @@ -680,22 +690,22 @@ class FilesController { @toString class UpdatePropertyFailureError implements Exception { - const UpdatePropertyFailureError(this.fileIds); + const UpdatePropertyFailureError(this.files); @override String toString() => _$toString(); - final List fileIds; + final List files; } @toString class RemoveFailureError implements Exception { - const RemoveFailureError(this.fileIds); + const RemoveFailureError(this.files); @override String toString() => _$toString(); - final List fileIds; + final List files; } class _MockResult { diff --git a/app/lib/controller/files_controller.g.dart b/app/lib/controller/files_controller.g.dart index 3a12fbd7..9c2c51e8 100644 --- a/app/lib/controller/files_controller.g.dart +++ b/app/lib/controller/files_controller.g.dart @@ -77,13 +77,13 @@ extension _$FilesControllerNpLog on FilesController { extension _$UpdatePropertyFailureErrorToString on UpdatePropertyFailureError { String _$toString() { // ignore: unnecessary_string_interpolations - return "UpdatePropertyFailureError {fileIds: [length: ${fileIds.length}]}"; + return "UpdatePropertyFailureError {files: [length: ${files.length}]}"; } } extension _$RemoveFailureErrorToString on RemoveFailureError { String _$toString() { // ignore: unnecessary_string_interpolations - return "RemoveFailureError {fileIds: [length: ${fileIds.length}]}"; + return "RemoveFailureError {files: [length: ${files.length}]}"; } } diff --git a/app/lib/controller/pref_controller.dart b/app/lib/controller/pref_controller.dart index 7c609435..7d34fa81 100644 --- a/app/lib/controller/pref_controller.dart +++ b/app/lib/controller/pref_controller.dart @@ -210,6 +210,30 @@ class PrefController { value: value, ); + Future setSlideshowDuration(Duration value) => _set( + controller: _slideshowDurationController, + setter: (pref, value) => pref.setSlideshowDuration(value), + value: value, + ); + + Future setSlideshowShuffle(bool value) => _set( + controller: _isSlideshowShuffleController, + setter: (pref, value) => pref.setSlideshowShuffle(value), + value: value, + ); + + Future setSlideshowRepeat(bool value) => _set( + controller: _isSlideshowRepeatController, + setter: (pref, value) => pref.setSlideshowRepeat(value), + value: value, + ); + + Future setSlideshowReverse(bool value) => _set( + controller: _isSlideshowReverseController, + setter: (pref, value) => pref.setSlideshowReverse(value), + value: value, + ); + Future _set({ required BehaviorSubject controller, required Future Function(Pref pref, T value) setter, @@ -337,6 +361,18 @@ class PrefController { @npSubjectAccessor late final _mapDefaultCustomRangeController = BehaviorSubject.seeded( pref.getMapDefaultCustomRange() ?? const Duration(days: 30)); + @npSubjectAccessor + late final _slideshowDurationController = BehaviorSubject.seeded( + pref.getSlideshowDuration() ?? const Duration(seconds: 5)); + @npSubjectAccessor + late final _isSlideshowShuffleController = + BehaviorSubject.seeded(pref.isSlideshowShuffle() ?? false); + @npSubjectAccessor + late final _isSlideshowRepeatController = + BehaviorSubject.seeded(pref.isSlideshowRepeat() ?? false); + @npSubjectAccessor + late final _isSlideshowReverseController = + BehaviorSubject.seeded(pref.isSlideshowReverse() ?? false); } extension PrefControllerExtension on PrefController { diff --git a/app/lib/controller/pref_controller.g.dart b/app/lib/controller/pref_controller.g.dart index af146361..1d32089e 100644 --- a/app/lib/controller/pref_controller.g.dart +++ b/app/lib/controller/pref_controller.g.dart @@ -206,6 +206,34 @@ extension $PrefControllerNpSubjectAccessor on PrefController { mapDefaultCustomRange.distinct().skip(1); Duration get mapDefaultCustomRangeValue => _mapDefaultCustomRangeController.value; +// _slideshowDurationController + ValueStream get slideshowDuration => + _slideshowDurationController.stream; + Stream get slideshowDurationNew => slideshowDuration.skip(1); + Stream get slideshowDurationChange => + slideshowDuration.distinct().skip(1); + Duration get slideshowDurationValue => _slideshowDurationController.value; +// _isSlideshowShuffleController + ValueStream get isSlideshowShuffle => + _isSlideshowShuffleController.stream; + Stream get isSlideshowShuffleNew => isSlideshowShuffle.skip(1); + Stream get isSlideshowShuffleChange => + isSlideshowShuffle.distinct().skip(1); + bool get isSlideshowShuffleValue => _isSlideshowShuffleController.value; +// _isSlideshowRepeatController + ValueStream get isSlideshowRepeat => + _isSlideshowRepeatController.stream; + Stream get isSlideshowRepeatNew => isSlideshowRepeat.skip(1); + Stream get isSlideshowRepeatChange => + isSlideshowRepeat.distinct().skip(1); + bool get isSlideshowRepeatValue => _isSlideshowRepeatController.value; +// _isSlideshowReverseController + ValueStream get isSlideshowReverse => + _isSlideshowReverseController.stream; + Stream get isSlideshowReverseNew => isSlideshowReverse.skip(1); + Stream get isSlideshowReverseChange => + isSlideshowReverse.distinct().skip(1); + bool get isSlideshowReverseValue => _isSlideshowReverseController.value; } extension $SecurePrefControllerNpSubjectAccessor on SecurePrefController { diff --git a/app/lib/controller/pref_controller/util.dart b/app/lib/controller/pref_controller/util.dart index 3687ee62..703992e8 100644 --- a/app/lib/controller/pref_controller/util.dart +++ b/app/lib/controller/pref_controller/util.dart @@ -131,6 +131,24 @@ extension on Pref { ?.let((v) => Duration(days: v)); Future setMapDefaultCustomRange(Duration value) => provider.setInt(PrefKey.mapDefaultCustomRange, value.inDays); + + Duration? getSlideshowDuration() => provider + .getInt(PrefKey.slideshowDuration) + ?.let((v) => Duration(seconds: v)); + Future setSlideshowDuration(Duration value) => + provider.setInt(PrefKey.slideshowDuration, value.inSeconds); + + bool? isSlideshowShuffle() => provider.getBool(PrefKey.isSlideshowShuffle); + Future setSlideshowShuffle(bool value) => + provider.setBool(PrefKey.isSlideshowShuffle, value); + + bool? isSlideshowRepeat() => provider.getBool(PrefKey.isSlideshowRepeat); + Future setSlideshowRepeat(bool value) => + provider.setBool(PrefKey.isSlideshowRepeat, value); + + bool? isSlideshowReverse() => provider.getBool(PrefKey.isSlideshowReverse); + Future setSlideshowReverse(bool value) => + provider.setBool(PrefKey.isSlideshowReverse, value); } MapCoord? _tryMapCoordFromJson(dynamic json) { diff --git a/app/lib/entity/pref/extension.dart b/app/lib/entity/pref/extension.dart index af3bbefa..043d81da 100644 --- a/app/lib/entity/pref/extension.dart +++ b/app/lib/entity/pref/extension.dart @@ -82,27 +82,6 @@ extension PrefExtension on Pref { Future setLanguage(int value) => _set( PrefKey.language, value, (key, value) => provider.setInt(key, value)); - int? getSlideshowDuration() => provider.getInt(PrefKey.slideshowDuration); - int getSlideshowDurationOr(int def) => getSlideshowDuration() ?? def; - Future setSlideshowDuration(int value) => _set( - PrefKey.slideshowDuration, - value, - (key, value) => provider.setInt(key, value)); - - bool? isSlideshowShuffle() => provider.getBool(PrefKey.isSlideshowShuffle); - bool isSlideshowShuffleOr(bool def) => isSlideshowShuffle() ?? def; - Future setSlideshowShuffle(bool value) => _set( - PrefKey.isSlideshowShuffle, - value, - (key, value) => provider.setBool(key, value)); - - bool? isSlideshowRepeat() => provider.getBool(PrefKey.isSlideshowRepeat); - bool isSlideshowRepeatOr(bool def) => isSlideshowRepeat() ?? def; - Future setSlideshowRepeat(bool value) => _set( - PrefKey.isSlideshowRepeat, - value, - (key, value) => provider.setBool(key, value)); - bool? isAlbumBrowserShowDate() => provider.getBool(PrefKey.isAlbumBrowserShowDate); bool isAlbumBrowserShowDateOr([bool def = false]) => @@ -179,13 +158,6 @@ extension PrefExtension on Pref { value, (key, value) => provider.setBool(key, value)); - bool? isSlideshowReverse() => provider.getBool(PrefKey.isSlideshowReverse); - bool isSlideshowReverseOr(bool def) => isSlideshowReverse() ?? def; - Future setSlideshowReverse(bool value) => _set( - PrefKey.isSlideshowReverse, - value, - (key, value) => provider.setBool(key, value)); - bool? isVideoPlayerMute() => provider.getBool(PrefKey.isVideoPlayerMute); bool isVideoPlayerMuteOr([bool def = false]) => isVideoPlayerMute() ?? def; Future setVideoPlayerMute(bool value) => _set( diff --git a/app/lib/exception_util.dart b/app/lib/exception_util.dart index 7914534c..6ce32a62 100644 --- a/app/lib/exception_util.dart +++ b/app/lib/exception_util.dart @@ -2,6 +2,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/controller/files_controller.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/exception.dart'; import 'package:nc_photos/navigation_manager.dart'; import 'package:nc_photos/widget/trusted_cert_manager.dart'; @@ -58,6 +60,11 @@ String toUserString(Object? exception) { ?.pushNamed(TrustedCertManager.routeName), ), ); + } else if (exception is UpdatePropertyFailureError) { + return ( + "Failed to update files: ${exception.files.map((f) => f.filename).join(", ")}", + null + ); } return (exception?.toString() ?? "Unknown error", null); } diff --git a/app/lib/widget/archive_browser/bloc.dart b/app/lib/widget/archive_browser/bloc.dart index 5ca31806..53ab8bfd 100644 --- a/app/lib/widget/archive_browser/bloc.dart +++ b/app/lib/widget/archive_browser/bloc.dart @@ -59,21 +59,24 @@ class _Bloc extends Bloc<_Event, _State> Future _onLoad(_LoadItems ev, Emitter<_State> emit) { _log.info(ev); unawaited(filesController.queryByArchived()); - return forEach( - emit, - filesController.stream, - onData: (data) => state.copyWith( - files: data.data, - isLoading: data.hasNext || _itemTransformerQueue.isProcessing, + return Future.wait([ + forEach( + emit, + filesController.stream, + onData: (data) => state.copyWith( + files: data.data, + isLoading: data.hasNext || _itemTransformerQueue.isProcessing, + ), ), - onError: (e, stackTrace) { - _log.severe("[_onLoad] Uncaught exception", e, stackTrace); - return state.copyWith( + forEach( + emit, + filesController.errorStream, + onData: (data) => state.copyWith( isLoading: _itemTransformerQueue.isProcessing, - error: ExceptionEvent(e, stackTrace), - ); - }, - ); + error: ExceptionEvent(data.error, data.stackTrace), + ), + ), + ]); } void _onTransformItems(_TransformItems ev, Emitter<_State> emit) { diff --git a/app/lib/widget/archive_browser/view.dart b/app/lib/widget/archive_browser/view.dart index a80a2ec0..6ecf4f50 100644 --- a/app/lib/widget/archive_browser/view.dart +++ b/app/lib/widget/archive_browser/view.dart @@ -73,10 +73,9 @@ class _ContentListBody extends StatelessWidget { Navigator.of(context).pushNamed( Viewer.routeName, arguments: ViewerArguments( - context.bloc.account, state.transformedItems .whereType<_FileItem>() - .map((e) => e.file) + .map((e) => e.file.fdId) .toList(), actualIndex, ), diff --git a/app/lib/widget/collection_browser/bloc.dart b/app/lib/widget/collection_browser/bloc.dart index 137f2d0e..0d963912 100644 --- a/app/lib/widget/collection_browser/bloc.dart +++ b/app/lib/widget/collection_browser/bloc.dart @@ -149,13 +149,14 @@ class _Bloc extends Bloc<_Event, _State> itemsWhitelist: whitelist, ); }, - onError: (e, stackTrace) { - _log.severe("[_onLoad] Uncaught exception", e, stackTrace); - return state.copyWith( - isLoading: false, - error: ExceptionEvent(e, stackTrace), - ); - }, + ), + forEach( + emit, + filesController.errorStream, + onData: (data) => state.copyWith( + isLoading: false, + error: ExceptionEvent(data.error, data.stackTrace), + ), ), ]); } diff --git a/app/lib/widget/collection_browser/view.dart b/app/lib/widget/collection_browser/view.dart index 963b2a3b..890ac18d 100644 --- a/app/lib/widget/collection_browser/view.dart +++ b/app/lib/widget/collection_browser/view.dart @@ -73,19 +73,12 @@ class _ContentListBody extends StatelessWidget { Navigator.of(context).pushNamed( Viewer.routeName, arguments: ViewerArguments( - context.bloc.account, state.transformedItems .whereType<_FileItem>() - .map((e) => e.file) + .map((e) => e.file.fdId) .toList(), actualIndex, - fromCollection: ViewerCollectionData( - state.collection, - state.transformedItems - .whereType<_ActualItem>() - .map((e) => e.original) - .toList(), - ), + collectionId: state.collection.id, ), ); }, diff --git a/app/lib/widget/file_content_view.dart b/app/lib/widget/file_content_view.dart new file mode 100644 index 00000000..d0e6d15d --- /dev/null +++ b/app/lib/widget/file_content_view.dart @@ -0,0 +1,185 @@ +import 'dart:async'; + +import 'package:copy_with/copy_with.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/bloc_util.dart'; +import 'package:nc_photos/controller/account_controller.dart'; +import 'package:nc_photos/controller/files_controller.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/live_photo_util.dart'; +import 'package:nc_photos/widget/image_viewer.dart'; +import 'package:nc_photos/widget/live_photo_viewer.dart'; +import 'package:nc_photos/widget/video_viewer.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/unique.dart'; +import 'package:to_string/to_string.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; + +part 'file_content_view.g.dart'; +part 'file_content_view/bloc.dart'; +part 'file_content_view/state_event.dart'; +part 'file_content_view/view.dart'; + +@npLog +class FileContentView extends StatefulWidget { + const FileContentView({ + super.key, + required this.fileId, + required this.shouldPlayLivePhoto, + required this.canZoom, + required this.canPlay, + required this.isPlayControlVisible, + this.onContentHeightChanged, + this.onZoomChanged, + this.onVideoPlayingChanged, + this.onLivePhotoLoadFailue, + }); + + @override + State createState() => _FileContentViewState(); + + final int fileId; + final bool shouldPlayLivePhoto; + final bool canZoom; + final bool canPlay; + final bool isPlayControlVisible; + final void Function(double height)? onContentHeightChanged; + final void Function(bool isZoomed)? onZoomChanged; + final void Function(bool isPlaying)? onVideoPlayingChanged; + final void Function()? onLivePhotoLoadFailue; +} + +class _FileContentViewState extends State { + @override + void initState() { + super.initState(); + _bloc = _Bloc( + account: context.read().account, + filesController: context.read().filesController, + fileId: widget.fileId, + shouldPlayLivePhoto: widget.shouldPlayLivePhoto, + canZoom: widget.canZoom, + canPlay: widget.canPlay, + isPlayControlVisible: widget.isPlayControlVisible, + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _bloc, + child: MultiBlocListener( + listeners: [ + _BlocListenerT( + selector: (state) => state.contentHeight, + listener: (context, contentHeight) { + if (contentHeight != null) { + widget.onContentHeightChanged?.call(contentHeight); + } + }, + ), + _BlocListenerT( + selector: (state) => state.isZoomed, + listener: (context, isZoomed) { + widget.onZoomChanged?.call(isZoomed); + }, + ), + _BlocListenerT( + selector: (state) => state.isPlaying, + listener: (context, isPlaying) { + widget.onVideoPlayingChanged?.call(isPlaying); + }, + ), + _BlocListenerT( + selector: (state) => state.isLivePhotoLoadFailed, + listener: (context, isLivePhotoLoadFailed) { + if (isLivePhotoLoadFailed.value) { + widget.onLivePhotoLoadFailue?.call(); + } + }, + ), + ], + child: const _WrappedFileContentView(), + ), + ); + } + + @override + void didUpdateWidget(covariant FileContentView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.shouldPlayLivePhoto != oldWidget.shouldPlayLivePhoto) { + _bloc.add(_SetShouldPlayLivePhoto(widget.shouldPlayLivePhoto)); + } + if (widget.canZoom != oldWidget.canZoom) { + _bloc.add(_SetCanZoom(widget.canZoom)); + } + if (widget.canPlay != oldWidget.canPlay) { + _bloc.add(_SetCanPlay(widget.canPlay)); + } + if (widget.isPlayControlVisible != oldWidget.isPlayControlVisible) { + _bloc.add(_SetIsPlayControlVisible(widget.isPlayControlVisible)); + } + } + + late final _Bloc _bloc; +} + +@npLog +class _WrappedFileContentView extends StatelessWidget { + const _WrappedFileContentView(); + + @override + Widget build(BuildContext context) { + return _BlocSelector( + selector: (state) => state.file, + builder: (context, file) { + if (file == null) { + _log.severe("[build] File is null"); + return Container(); + } else if (file_util.isSupportedImageFormat(file)) { + return _BlocSelector( + selector: (state) => state.shouldPlayLivePhoto, + builder: (context, shouldPlayLivePhoto) { + if (shouldPlayLivePhoto) { + final livePhotoType = getLivePhotoTypeFromFile(file); + if (livePhotoType != null) { + return _LivePhotoPageContentView( + livePhotoType: livePhotoType, + ); + } else { + _log.warning("[build] Not a live photo"); + return const _PhotoPageContentView(); + } + } else { + return const _PhotoPageContentView(); + } + }, + ); + } else if (file_util.isSupportedVideoFormat(file)) { + return const _VideoPageContentView(); + } else { + _log.shout("[build] Unknown file format: ${file.fdMime}"); + // _pageStates[index]!.itemHeight = 0; + return Container(); + } + }, + ); + } +} + +typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; +// typedef _BlocListener = BlocListener<_Bloc, _State>; +typedef _BlocListenerT = BlocListenerT<_Bloc, _State, T>; +typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; +typedef _Emitter = Emitter<_State>; + +extension on BuildContext { + _Bloc get bloc => read<_Bloc>(); + // _State get state => bloc.state; + void addEvent(_Event event) => bloc.add(event); +} diff --git a/app/lib/widget/file_content_view.g.dart b/app/lib/widget/file_content_view.g.dart new file mode 100644 index 00000000..74fc6b91 --- /dev/null +++ b/app/lib/widget/file_content_view.g.dart @@ -0,0 +1,191 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'file_content_view.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_StateCopyWithWorker { + _State call( + {FileDescriptor? file, + bool? shouldPlayLivePhoto, + bool? canZoom, + bool? canPlay, + bool? isPlayControlVisible, + bool? isLoaded, + bool? isZoomed, + bool? isPlaying, + Unique? isLivePhotoLoadFailed, + double? contentHeight, + ExceptionEvent? error}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic file = copyWithNull, + dynamic shouldPlayLivePhoto, + dynamic canZoom, + dynamic canPlay, + dynamic isPlayControlVisible, + dynamic isLoaded, + dynamic isZoomed, + dynamic isPlaying, + dynamic isLivePhotoLoadFailed, + dynamic contentHeight = copyWithNull, + dynamic error = copyWithNull}) { + return _State( + file: file == copyWithNull ? that.file : file as FileDescriptor?, + shouldPlayLivePhoto: + shouldPlayLivePhoto as bool? ?? that.shouldPlayLivePhoto, + canZoom: canZoom as bool? ?? that.canZoom, + canPlay: canPlay as bool? ?? that.canPlay, + isPlayControlVisible: + isPlayControlVisible as bool? ?? that.isPlayControlVisible, + isLoaded: isLoaded as bool? ?? that.isLoaded, + isZoomed: isZoomed as bool? ?? that.isZoomed, + isPlaying: isPlaying as bool? ?? that.isPlaying, + isLivePhotoLoadFailed: isLivePhotoLoadFailed as Unique? ?? + that.isLivePhotoLoadFailed, + contentHeight: contentHeight == copyWithNull + ? that.contentHeight + : contentHeight as double?, + error: error == copyWithNull ? that.error : error as ExceptionEvent?); + } + + final _State that; +} + +extension $_StateCopyWith on _State { + $_StateCopyWithWorker get copyWith => _$copyWith; + $_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$FileContentViewNpLog on FileContentView { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.file_content_view.FileContentView"); +} + +extension _$_WrappedFileContentViewNpLog on _WrappedFileContentView { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.file_content_view._WrappedFileContentView"); +} + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.file_content_view._Bloc"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_StateToString on _State { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_State {file: ${file == null ? null : "${file!.fdPath}"}, shouldPlayLivePhoto: $shouldPlayLivePhoto, canZoom: $canZoom, canPlay: $canPlay, isPlayControlVisible: $isPlayControlVisible, isLoaded: $isLoaded, isZoomed: $isZoomed, isPlaying: $isPlaying, isLivePhotoLoadFailed: $isLivePhotoLoadFailed, contentHeight: ${contentHeight == null ? null : "${contentHeight!.toStringAsFixed(3)}"}, error: $error}"; + } +} + +extension _$_SetFileToString on _SetFile { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetFile {value: ${value == null ? null : "${value!.fdPath}"}}"; + } +} + +extension _$_SetShouldPlayLivePhotoToString on _SetShouldPlayLivePhoto { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetShouldPlayLivePhoto {value: $value}"; + } +} + +extension _$_SetCanZoomToString on _SetCanZoom { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetCanZoom {value: $value}"; + } +} + +extension _$_SetCanPlayToString on _SetCanPlay { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetCanPlay {value: $value}"; + } +} + +extension _$_SetIsPlayControlVisibleToString on _SetIsPlayControlVisible { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetIsPlayControlVisible {value: $value}"; + } +} + +extension _$_SetLoadedToString on _SetLoaded { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetLoaded {}"; + } +} + +extension _$_SetContentHeightToString on _SetContentHeight { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetContentHeight {value: ${value.toStringAsFixed(3)}}"; + } +} + +extension _$_SetIsZoomedToString on _SetIsZoomed { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetIsZoomed {value: $value}"; + } +} + +extension _$_SetPlayingToString on _SetPlaying { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetPlaying {}"; + } +} + +extension _$_SetPauseToString on _SetPause { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetPause {}"; + } +} + +extension _$_SetLivePhotoLoadFailedToString on _SetLivePhotoLoadFailed { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetLivePhotoLoadFailed {}"; + } +} + +extension _$_SetErrorToString on _SetError { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetError {error: $error, stackTrace: $stackTrace}"; + } +} diff --git a/app/lib/widget/file_content_view/bloc.dart b/app/lib/widget/file_content_view/bloc.dart new file mode 100644 index 00000000..276721f9 --- /dev/null +++ b/app/lib/widget/file_content_view/bloc.dart @@ -0,0 +1,135 @@ +part of '../file_content_view.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> with BlocLogger { + _Bloc({ + required this.account, + required this.filesController, + required this.fileId, + required bool shouldPlayLivePhoto, + required bool canZoom, + required bool canPlay, + required bool isPlayControlVisible, + }) : super(_State.init( + file: filesController.stream.value.dataMap[fileId], + shouldPlayLivePhoto: shouldPlayLivePhoto, + canZoom: canZoom, + canPlay: canPlay, + isPlayControlVisible: isPlayControlVisible, + )) { + on<_SetFile>(_onSetFile); + on<_SetShouldPlayLivePhoto>(_onSetShouldPlayLivePhoto); + on<_SetCanZoom>(_onSetCanZoom); + on<_SetCanPlay>(_onSetCanPlay); + on<_SetIsPlayControlVisible>(_onSetIsPlayControlVisible); + on<_SetLoaded>(_onSetLoaded); + on<_SetIsZoomed>(_onSetIsZoomed); + on<_SetContentHeight>(_onSetContentHeight); + on<_SetPlaying>(_onSetPlaying); + on<_SetPause>(_onSetPause); + on<_SetLivePhotoLoadFailed>(_onSetLivePhotoLoadFailed); + + on<_SetError>(_onSetError); + + _subscriptions.add(filesController.stream.listen((ev) { + add(_SetFile(ev.dataMap[fileId])); + })); + _subscriptions.add(filesController.errorStream.listen((ev) { + add(_SetError(ev.error, ev.stackTrace)); + })); + } + + @override + Future close() { + for (final s in _subscriptions) { + s.cancel(); + } + return super.close(); + } + + @override + String get tag => _log.fullName; + + @override + void onError(Object error, StackTrace stackTrace) { + // we need this to prevent onError being triggered recursively + if (!isClosed && !_isHandlingError) { + _isHandlingError = true; + try { + add(_SetError(error, stackTrace)); + } catch (_) {} + _isHandlingError = false; + } + super.onError(error, stackTrace); + } + + void _onSetFile(_SetFile ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(file: ev.value)); + } + + void _onSetShouldPlayLivePhoto(_SetShouldPlayLivePhoto ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(shouldPlayLivePhoto: ev.value)); + } + + void _onSetCanZoom(_SetCanZoom ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(canZoom: ev.value)); + } + + void _onSetCanPlay(_SetCanPlay ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(canPlay: ev.value)); + } + + void _onSetIsPlayControlVisible(_SetIsPlayControlVisible ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(isPlayControlVisible: ev.value)); + } + + void _onSetLoaded(_SetLoaded ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(isLoaded: true)); + } + + void _onSetIsZoomed(_SetIsZoomed ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(isZoomed: ev.value)); + } + + void _onSetContentHeight(_SetContentHeight ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(contentHeight: ev.value)); + } + + void _onSetPlaying(_SetPlaying ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(isPlaying: true)); + } + + void _onSetPause(_SetPause ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(isPlaying: false)); + } + + void _onSetLivePhotoLoadFailed(_SetLivePhotoLoadFailed ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith( + shouldPlayLivePhoto: false, + isLivePhotoLoadFailed: Unique(true), + )); + } + + void _onSetError(_SetError ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); + } + + final Account account; + final FilesController filesController; + final int fileId; + + final _subscriptions = []; + var _isHandlingError = false; +} diff --git a/app/lib/widget/file_content_view/state_event.dart b/app/lib/widget/file_content_view/state_event.dart new file mode 100644 index 00000000..b890adc9 --- /dev/null +++ b/app/lib/widget/file_content_view/state_event.dart @@ -0,0 +1,169 @@ +part of '../file_content_view.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + required this.file, + required this.shouldPlayLivePhoto, + required this.canZoom, + required this.canPlay, + required this.isPlayControlVisible, + required this.isLoaded, + required this.isZoomed, + required this.isPlaying, + required this.isLivePhotoLoadFailed, + this.contentHeight, + this.error, + }); + + factory _State.init({ + required FileDescriptor? file, + required bool shouldPlayLivePhoto, + required bool canZoom, + required bool canPlay, + required bool isPlayControlVisible, + }) => + _State( + file: file, + shouldPlayLivePhoto: shouldPlayLivePhoto, + canZoom: canZoom, + canPlay: canPlay, + isPlayControlVisible: isPlayControlVisible, + isLoaded: false, + isZoomed: false, + isPlaying: false, + isLivePhotoLoadFailed: Unique(false), + ); + + @override + String toString() => _$toString(); + + final FileDescriptor? file; + final bool shouldPlayLivePhoto; + final bool canZoom; + final bool canPlay; + final bool isPlayControlVisible; + final bool isLoaded; + final bool isZoomed; + final bool isPlaying; + final Unique isLivePhotoLoadFailed; + final double? contentHeight; + + final ExceptionEvent? error; +} + +abstract class _Event {} + +@toString +class _SetFile implements _Event { + const _SetFile(this.value); + + @override + String toString() => _$toString(); + + final FileDescriptor? value; +} + +@toString +class _SetShouldPlayLivePhoto implements _Event { + const _SetShouldPlayLivePhoto(this.value); + + @override + String toString() => _$toString(); + + final bool value; +} + +@toString +class _SetCanZoom implements _Event { + const _SetCanZoom(this.value); + + @override + String toString() => _$toString(); + + final bool value; +} + +@toString +class _SetCanPlay implements _Event { + const _SetCanPlay(this.value); + + @override + String toString() => _$toString(); + + final bool value; +} + +@toString +class _SetIsPlayControlVisible implements _Event { + const _SetIsPlayControlVisible(this.value); + + @override + String toString() => _$toString(); + + final bool value; +} + +@toString +class _SetLoaded implements _Event { + const _SetLoaded(); + + @override + String toString() => _$toString(); +} + +@toString +class _SetContentHeight implements _Event { + const _SetContentHeight(this.value); + + @override + String toString() => _$toString(); + + final double value; +} + +@toString +class _SetIsZoomed implements _Event { + const _SetIsZoomed(this.value); + + @override + String toString() => _$toString(); + + final bool value; +} + +@toString +class _SetPlaying implements _Event { + const _SetPlaying(); + + @override + String toString() => _$toString(); +} + +@toString +class _SetPause implements _Event { + const _SetPause(); + + @override + String toString() => _$toString(); +} + +@toString +class _SetLivePhotoLoadFailed implements _Event { + const _SetLivePhotoLoadFailed(); + + @override + String toString() => _$toString(); +} + +@toString +class _SetError implements _Event { + const _SetError(this.error, [this.stackTrace]); + + @override + String toString() => _$toString(); + + final Object error; + final StackTrace? stackTrace; +} diff --git a/app/lib/widget/file_content_view/view.dart b/app/lib/widget/file_content_view/view.dart new file mode 100644 index 00000000..c164ac2b --- /dev/null +++ b/app/lib/widget/file_content_view/view.dart @@ -0,0 +1,107 @@ +part of '../file_content_view.dart'; + +class _LivePhotoPageContentView extends StatelessWidget { + const _LivePhotoPageContentView({ + required this.livePhotoType, + }); + + @override + Widget build(BuildContext context) { + return _BlocBuilder( + buildWhen: (previous, current) => + previous.file != current.file || previous.canPlay != current.canPlay, + builder: (context, state) => state.file == null + ? Container() + : LivePhotoViewer( + account: context.bloc.account, + file: state.file!, + livePhotoType: livePhotoType, + canPlay: state.canPlay, + onLoaded: () { + context.addEvent(const _SetLoaded()); + }, + onHeightChanged: (height) { + context.addEvent(_SetContentHeight(height)); + }, + onLoadFailure: () { + context.addEvent(const _SetLivePhotoLoadFailed()); + }, + ), + ); + } + + final LivePhotoType livePhotoType; +} + +class _PhotoPageContentView extends StatelessWidget { + const _PhotoPageContentView(); + + @override + Widget build(BuildContext context) { + return _BlocBuilder( + buildWhen: (previous, current) => + previous.file != current.file || previous.canZoom != current.canZoom, + builder: (context, state) => state.file == null + ? Container() + : RemoteImageViewer( + account: context.bloc.account, + file: state.file!, + canZoom: state.canZoom, + onLoaded: () { + context.addEvent(const _SetLoaded()); + }, + onHeightChanged: (height) { + context.addEvent(_SetContentHeight(height)); + }, + onZoomStarted: () { + context.addEvent(const _SetIsZoomed(true)); + }, + onZoomEnded: () { + context.addEvent(const _SetIsZoomed(false)); + }, + ), + ); + } +} + +class _VideoPageContentView extends StatelessWidget { + const _VideoPageContentView(); + + @override + Widget build(BuildContext context) { + return _BlocBuilder( + buildWhen: (previous, current) => + previous.file != current.file || + previous.canZoom != current.canZoom || + previous.isPlayControlVisible != current.isPlayControlVisible || + previous.canPlay != current.canPlay, + builder: (context, state) => state.file == null + ? Container() + : VideoViewer( + account: context.bloc.account, + file: state.file!, + canZoom: state.canZoom, + canPlay: state.canPlay, + isControlVisible: state.isPlayControlVisible, + onLoaded: () { + context.addEvent(const _SetLoaded()); + }, + onHeightChanged: (height) { + context.addEvent(_SetContentHeight(height)); + }, + onZoomStarted: () { + context.addEvent(const _SetIsZoomed(true)); + }, + onZoomEnded: () { + context.addEvent(const _SetIsZoomed(false)); + }, + onPlay: () { + context.addEvent(const _SetPlaying()); + }, + onPause: () { + context.addEvent(const _SetPause()); + }, + ), + ); + } +} diff --git a/app/lib/widget/home_collections.g.dart b/app/lib/widget/home_collections.g.dart index c002b482..36be50b2 100644 --- a/app/lib/widget/home_collections.g.dart +++ b/app/lib/widget/home_collections.g.dart @@ -99,7 +99,7 @@ extension _$_ItemNpLog on _Item { extension _$_StateToString on _State { String _$toString() { // ignore: unnecessary_string_interpolations - return "_State {collections: [length: ${collections.length}], sort: ${sort.name}, isLoading: $isLoading, transformedItems: [length: ${transformedItems.length}], selectedItems: {length: ${selectedItems.length}}, itemCounts: $itemCounts, error: $error, removeError: $removeError}"; + return "_State {collections: [length: ${collections.length}], sort: ${sort.name}, isLoading: $isLoading, transformedItems: [length: ${transformedItems.length}], selectedItems: {length: ${selectedItems.length}}, itemCounts: {length: ${itemCounts.length}}, error: $error, removeError: $removeError}"; } } diff --git a/app/lib/widget/home_photos/view.dart b/app/lib/widget/home_photos/view.dart index a16bb8c3..41217d7a 100644 --- a/app/lib/widget/home_photos/view.dart +++ b/app/lib/widget/home_photos/view.dart @@ -87,10 +87,9 @@ class _ContentListBody extends StatelessWidget { Navigator.of(context).pushNamed( Viewer.routeName, arguments: ViewerArguments( - context.bloc.account, state.transformedItems .whereType<_FileItem>() - .map((e) => e.file) + .map((e) => e.file.fdId) .toList(), actualIndex, ), diff --git a/app/lib/widget/home_search.dart b/app/lib/widget/home_search.dart index 6476f6f3..00e4816a 100644 --- a/app/lib/widget/home_search.dart +++ b/app/lib/widget/home_search.dart @@ -87,11 +87,12 @@ class _HomeSearchState extends State @override onItemTap(SelectableItem item, int index) { item.as()?.run((fileItem) { - Navigator.pushNamed( - context, + Navigator.of(context).pushNamed( Viewer.routeName, - arguments: - ViewerArguments(widget.account, _backingFiles, fileItem.fileIndex), + arguments: ViewerArguments( + _backingFiles.map((e) => e.fdId).toList(), + fileItem.fileIndex, + ), ); }); } diff --git a/app/lib/widget/image_viewer.dart b/app/lib/widget/image_viewer.dart index c9217ed8..85cf43bf 100644 --- a/app/lib/widget/image_viewer.dart +++ b/app/lib/widget/image_viewer.dart @@ -1,4 +1,3 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'; import 'package:flutter/widgets.dart'; import 'package:logging/logging.dart'; @@ -152,40 +151,17 @@ class _RemoteImageViewerState extends State { toHeroContext, ); }, - child: CachedNetworkImage( - fit: BoxFit.contain, - cacheManager: ThumbnailCacheManager.inst, - imageUrl: NetworkRectThumbnail.imageUrlForFile( - widget.account, widget.file), - httpHeaders: { - "Authorization": - AuthUtil.fromAccount(widget.account).toHeaderValue(), - }, - fadeInDuration: const Duration(), - filterQuality: FilterQuality.high, - imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, + child: _PreviewImage( + account: widget.account, + file: widget.file, ), ), ), if (_isHeroDone) - mod.CachedNetworkImage( - fit: BoxFit.contain, - cacheManager: LargeImageCacheManager.inst, - imageUrl: _getImageUrl(widget.account, widget.file), - httpHeaders: { - "Authorization": - AuthUtil.fromAccount(widget.account).toHeaderValue(), - }, - fadeInDuration: const Duration(), - filterQuality: FilterQuality.high, - imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, - imageBuilder: (context, child, imageProvider) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _onItemLoaded(); - }); - const SizeChangedLayoutNotification().dispatch(context); - return child; - }, + _FullSizedImage( + account: widget.account, + file: widget.file, + onItemLoaded: _onItemLoaded, ), ], ), @@ -286,3 +262,66 @@ String _getImageUrl(Account account, FileDescriptor file) { ); } } + +class _PreviewImage extends StatelessWidget { + const _PreviewImage({ + required this.account, + required this.file, + }); + + @override + Widget build(BuildContext context) { + return mod.CachedNetworkImage( + fit: BoxFit.contain, + cacheManager: ThumbnailCacheManager.inst, + imageUrl: NetworkRectThumbnail.imageUrlForFile(account, file), + httpHeaders: { + "Authorization": AuthUtil.fromAccount(account).toHeaderValue(), + }, + fadeInDuration: const Duration(), + filterQuality: FilterQuality.high, + imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, + imageBuilder: (context, child, imageProvider) { + const SizeChangedLayoutNotification().dispatch(context); + return child; + }, + ); + } + + final Account account; + final FileDescriptor file; +} + +class _FullSizedImage extends StatelessWidget { + const _FullSizedImage({ + required this.account, + required this.file, + this.onItemLoaded, + }); + + @override + Widget build(BuildContext context) { + return mod.CachedNetworkImage( + fit: BoxFit.contain, + cacheManager: LargeImageCacheManager.inst, + imageUrl: _getImageUrl(account, file), + httpHeaders: { + "Authorization": AuthUtil.fromAccount(account).toHeaderValue(), + }, + fadeInDuration: const Duration(), + filterQuality: FilterQuality.high, + imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, + imageBuilder: (context, child, imageProvider) { + WidgetsBinding.instance.addPostFrameCallback((_) { + onItemLoaded?.call(); + }); + const SizeChangedLayoutNotification().dispatch(context); + return child; + }, + ); + } + + final Account account; + final FileDescriptor file; + final VoidCallback? onItemLoaded; +} diff --git a/app/lib/widget/result_viewer.dart b/app/lib/widget/result_viewer.dart index c06c5923..d98917b9 100644 --- a/app/lib/widget/result_viewer.dart +++ b/app/lib/widget/result_viewer.dart @@ -76,8 +76,7 @@ class _ResultViewerState extends State { ); } else { return Viewer( - account: _account!, - streamFiles: [_file!], + fileIds: [_file!.fdId], startIndex: 0, ); } diff --git a/app/lib/widget/viewer.dart b/app/lib/widget/viewer.dart index 3019216a..9424a584 100644 --- a/app/lib/widget/viewer.dart +++ b/app/lib/widget/viewer.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:math'; -import 'package:flutter/foundation.dart'; +import 'package:copy_with/copy_with.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -12,68 +12,67 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/asset.dart'; +import 'package:nc_photos/bloc_util.dart'; import 'package:nc_photos/controller/account_controller.dart'; import 'package:nc_photos/controller/collection_items_controller.dart'; +import 'package:nc_photos/controller/collections_controller.dart'; +import 'package:nc_photos/controller/files_controller.dart'; +import 'package:nc_photos/controller/pref_controller.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/download_handler.dart'; import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/collection/adapter.dart'; import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/entity/pref.dart'; +import 'package:nc_photos/exception_event.dart'; import 'package:nc_photos/flutter_util.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/live_photo_util.dart'; -import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/platform/features.dart' as features; import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; -import 'package:nc_photos/widget/app_intermediate_circular_progress_indicator.dart'; import 'package:nc_photos/widget/disposable.dart'; +import 'package:nc_photos/widget/file_content_view.dart'; import 'package:nc_photos/widget/handler/remove_selection_handler.dart'; import 'package:nc_photos/widget/horizontal_page_viewer.dart'; import 'package:nc_photos/widget/image_editor.dart'; import 'package:nc_photos/widget/image_enhancer.dart'; -import 'package:nc_photos/widget/image_viewer.dart'; -import 'package:nc_photos/widget/live_photo_viewer.dart'; +import 'package:nc_photos/widget/page_visibility_mixin.dart'; import 'package:nc_photos/widget/png_icon.dart'; import 'package:nc_photos/widget/slideshow_dialog.dart'; import 'package:nc_photos/widget/slideshow_viewer.dart'; -import 'package:nc_photos/widget/video_viewer.dart'; -import 'package:nc_photos/widget/viewer_bottom_app_bar.dart'; import 'package:nc_photos/widget/viewer_detail_pane.dart'; import 'package:nc_photos/widget/viewer_mixin.dart'; import 'package:np_codegen/np_codegen.dart'; +import 'package:np_collection/np_collection.dart'; +import 'package:np_common/object_util.dart'; import 'package:np_common/or_null.dart'; +import 'package:np_common/unique.dart'; import 'package:np_platform_util/np_platform_util.dart'; -import 'package:video_player_platform_interface/video_player_platform_interface.dart'; +import 'package:to_string/to_string.dart'; +part 'viewer/app_bar.dart'; +part 'viewer/bloc.dart'; +part 'viewer/detail_pane.dart'; +part 'viewer/state_event.dart'; +part 'viewer/type.dart'; +part 'viewer/view.dart'; part 'viewer.g.dart'; -class ViewerCollectionData { - const ViewerCollectionData(this.collection, this.items); - - final Collection collection; - final List items; -} - class ViewerArguments { const ViewerArguments( - this.account, - this.streamFiles, + this.fileIds, this.startIndex, { - this.fromCollection, + this.collectionId, }); - final Account account; - final List streamFiles; + final List fileIds; final int startIndex; - final ViewerCollectionData? fromCollection; + final String? collectionId; } -class Viewer extends StatefulWidget { +class Viewer extends StatelessWidget { static const routeName = "/viewer"; static Route buildRoute(ViewerArguments args) => @@ -85,44 +84,61 @@ class Viewer extends StatefulWidget { const Viewer({ super.key, - required this.account, - required this.streamFiles, + required this.fileIds, required this.startIndex, - this.fromCollection, + this.collectionId, }); Viewer.fromArgs(ViewerArguments args, {Key? key}) : this( key: key, - account: args.account, - streamFiles: args.streamFiles, + fileIds: args.fileIds, startIndex: args.startIndex, - fromCollection: args.fromCollection, + collectionId: args.collectionId, ); @override - createState() => _ViewerState(); + Widget build(BuildContext context) { + final accountController = context.read(); + return BlocProvider( + create: (_) => _Bloc( + KiwiContainer().resolve(), + account: accountController.account, + filesController: accountController.filesController, + collectionsController: accountController.collectionsController, + prefController: context.read(), + fileIds: fileIds, + startIndex: startIndex, + brightness: Theme.of(context).brightness, + collectionId: collectionId, + )..add(const _Init()), + child: const _WrappedViewer(), + ); + } - final Account account; - final List streamFiles; + final List fileIds; final int startIndex; - /// Data of the collection these files belongs to, or null - final ViewerCollectionData? fromCollection; + /// ID of the collection these files belongs to, or null + final String? collectionId; +} + +class _WrappedViewer extends StatefulWidget { + const _WrappedViewer(); + + @override + State createState() => _WrappedViewerState(); } @npLog -class _ViewerState extends State - with DisposableManagerMixin, ViewerControllersMixin { +class _WrappedViewerState extends State<_WrappedViewer> + with + DisposableManagerMixin<_WrappedViewer>, + ViewerControllersMixin<_WrappedViewer>, + RouteAware, + PageVisibilityMixin { @override - void initState() { - super.initState(); - _streamFilesView = widget.streamFiles; - } - - @override - build(BuildContext context) { - final originalBrightness = Theme.of(context).brightness; + Widget build(BuildContext context) { return Theme( data: buildDarkTheme(context), child: AnnotatedRegion( @@ -130,246 +146,69 @@ class _ViewerState extends State systemNavigationBarColor: Colors.black, systemNavigationBarIconBrightness: Brightness.dark, ), - child: Scaffold( - extendBodyBehindAppBar: true, - extendBody: true, - appBar: _isShowAppBar ? _buildAppBar(context) : null, - bottomNavigationBar: _isShowAppBar && !_isDetailPaneActive - ? _buildBottomAppBar(context) - : null, - body: Builder( - builder: (context) => _buildContent(context, originalBrightness), - ), - ), - ), - ); - } - - Widget _buildContent(BuildContext context, Brightness originalBrightness) { - return GestureDetector( - onTap: () { - setState(() { - _setShowActionBar(!_isShowAppBar); - }); - }, - child: Stack( - children: [ - Container(color: Colors.black), - if (!_isViewerLoaded || - _pageStates[_viewerController.currentPage]?.hasLoaded != true) - const Align( - alignment: Alignment.center, - child: AppIntermediateCircularProgressIndicator(), + child: MultiBlocListener( + listeners: [ + _BlocListenerT( + selector: (state) => state.imageEditorRequest, + listener: (context, imageEditorRequest) { + if (imageEditorRequest.value != null) { + Navigator.of(context).pushNamed(ImageEditor.routeName, + arguments: imageEditorRequest.value); + } + }, ), - HorizontalPageViewer( - key: _pageViewerKey, - pageCount: _streamFilesView.length, - pageBuilder: (context, i) => - _buildPage(context, i, originalBrightness), - initialPage: widget.startIndex, - controller: _viewerController, - viewportFraction: _viewportFraction, - canSwitchPage: _canSwitchPage(), - onPageChanged: (from, to) { - setState(() { - _pageStates[from]?.shouldPlayLivePhoto = false; - }); - }, - ), - if (_isShowAppBar) - Container( - // + status bar height - height: kToolbarHeight + MediaQuery.of(context).padding.top, - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment(0, -1), - end: Alignment(0, 1), - colors: [ - Color.fromARGB(192, 0, 0, 0), - Color.fromARGB(0, 0, 0, 0), - ], - ), - ), + _BlocListenerT( + selector: (state) => state.imageEnhancerRequest, + listener: (context, imageEnhancerRequest) { + if (imageEnhancerRequest.value != null) { + Navigator.of(context).pushNamed(ImageEnhancer.routeName, + arguments: imageEnhancerRequest.value); + } + }, ), - ], - ), - ); - } - - AppBar _buildAppBar(BuildContext context) { - final index = - _isViewerLoaded ? _viewerController.currentPage : widget.startIndex; - final file = _streamFilesView[index]; - final isCentered = getRawPlatform() == NpPlatform.iOs; - return AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - title: _isDetailPaneActive - ? null - : _AppBarTitle( - file: file, - isCentered: isCentered, + _BlocListenerT( + selector: (state) => state.shareRequest, + listener: (context, shareRequest) { + if (shareRequest.value != null) { + ShareHandler( + KiwiContainer().resolve(), + context: context, + ).shareFiles( + context.bloc.account, [shareRequest.value!.file]); + } + }, ), - titleSpacing: 0, - centerTitle: isCentered, - actions: [ - if (!_isDetailPaneActive && _canOpenDetailPane()) ...[ - if (getLivePhotoTypeFromFile(file) != null) - if (_pageStates[index]?.shouldPlayLivePhoto ?? false) - IconButton( - icon: const Icon(Icons.motion_photos_pause_outlined), - onPressed: () => _onPauseMotionPhotosPressed(index), - ) - else - IconButton( - icon: const PngIcon(icMotionPhotosPlay24dp), - onPressed: () => _onPlayMotionPhotosPressed(index), - ), - (_pageStates[index]?.favoriteOverride ?? file.fdIsFavorite) == true - ? IconButton( - icon: const Icon(Icons.star), - tooltip: L10n.global().unfavoriteTooltip, - onPressed: () => _onUnfavoritePressed(index), - ) - : IconButton( - icon: const Icon(Icons.star_border), - tooltip: L10n.global().favoriteTooltip, - onPressed: () => _onFavoritePressed(index), - ), - IconButton( - icon: const Icon(Icons.more_vert), - tooltip: L10n.global().detailsTooltip, - onPressed: _onDetailsPressed, - ), - ], - ], - ); - } - - Widget _buildBottomAppBar(BuildContext context) { - final index = - _isViewerLoaded ? _viewerController.currentPage : widget.startIndex; - final file = _streamFilesView[index]; - return ViewerBottomAppBar( - children: [ - IconButton( - icon: const Icon(Icons.share_outlined), - tooltip: L10n.global().shareTooltip, - onPressed: () => _onSharePressed(context), - ), - if (features.isSupportEnhancement && - ImageEnhancer.isSupportedFormat(file)) ...[ - IconButton( - icon: const Icon(Icons.tune_outlined), - tooltip: L10n.global().editTooltip, - onPressed: () => _onEditPressed(context), - ), - IconButton( - icon: const Icon(Icons.auto_fix_high_outlined), - tooltip: L10n.global().enhanceTooltip, - onPressed: () => _onEnhancePressed(context), - ), - ], - IconButton( - icon: const Icon(Icons.download_outlined), - tooltip: L10n.global().downloadTooltip, - onPressed: _onDownloadPressed, - ), - if (widget.fromCollection == null) - IconButton( - icon: const Icon(Icons.delete_outlined), - tooltip: L10n.global().deleteTooltip, - onPressed: () => _onDeletePressed(context), - ), - ], - ); - } - - Widget _buildPage( - BuildContext context, int index, Brightness originalBrightness) { - if (_pageStates[index] == null) { - _onCreateNewPage(context, index); - } else if (!_pageStates[index]!.scrollController.hasClients) { - // the page has been moved out of view and is now coming back - _log.fine("[_buildPage] Recreating page#$index"); - _onRecreatePageAfterMovedOut(context, index); - } - - if (kDebugMode) { - _log.info("[_buildPage] $index"); - } - - return FractionallySizedBox( - widthFactor: 1 / _viewportFraction, - child: NotificationListener( - onNotification: (notif) => _onPageContentScrolled(notif, index), - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), - child: SingleChildScrollView( - controller: _pageStates[index]!.scrollController, - physics: !_isZoomed ? null : const NeverScrollableScrollPhysics(), - child: Stack( - children: [ - _buildItemView(context, index), - IgnorePointer( - ignoring: !_isShowDetailPane, - child: Visibility( - visible: !_isZoomed, - child: AnimatedOpacity( - opacity: _isShowDetailPane ? 1 : 0, - duration: k.animationDurationNormal, - onEnd: () { - if (!_isShowDetailPane) { - setState(() { - _isDetailPaneActive = false; - }); - } - }, - child: Theme( - data: buildTheme(context, originalBrightness), - child: Builder( - builder: (context) { - return Container( - alignment: Alignment.topLeft, - constraints: BoxConstraints( - minHeight: MediaQuery.of(context).size.height, - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(4), - ), - ), - margin: EdgeInsets.only( - top: _calcDetailPaneOffset(index), - ), - // this visibility widget avoids loading the detail pane - // until it's actually opened, otherwise swiping between - // photos will slow down severely - child: Visibility( - visible: _isShowDetailPane, - child: ViewerDetailPane( - account: widget.account, - fd: _streamFilesView[index], - fromCollection: widget.fromCollection?.run( - (d) => ViewerSingleCollectionData( - d.collection, d.items[index])), - onRemoveFromCollectionPressed: - _onRemoveFromCollectionPressed, - onArchivePressed: _onArchivePressed, - onUnarchivePressed: _onUnarchivePressed, - onSlideshowPressed: _onSlideshowPressed, - onDeletePressed: _onDeletePressed, - ), - ), - ); - }, - ), - ), - ), - ), - ), - ], + _BlocListenerT( + selector: (state) => state.slideshowRequest, + listener: _onSlideshowRequest, + ), + _BlocListenerT( + selector: (state) => state.error, + listener: (context, error) { + if (error != null && isPageVisible()) { + SnackBarManager().showSnackBarForException(error.error); + } + }, + ), + ], + child: _BlocBuilder( + buildWhen: (previous, current) => + previous.isShowAppBar != current.isShowAppBar || + previous.isDetailPaneActive != current.isDetailPaneActive, + builder: (context, state) => Scaffold( + extendBodyBehindAppBar: true, + extendBody: true, + appBar: state.isShowAppBar + ? const PreferredSize( + preferredSize: Size.fromHeight(kToolbarHeight), + child: _AppBar(), + ) + : null, + bottomNavigationBar: + state.isShowAppBar && !state.isDetailPaneActive + ? const _BottomAppBar() + : null, + body: const _ContentBody(), ), ), ), @@ -377,665 +216,56 @@ class _ViewerState extends State ); } - Widget _buildItemView(BuildContext context, int index) { - final file = _streamFilesView[index]; - if (file_util.isSupportedImageFormat(file)) { - final shouldPlayLivePhoto = _pageStates[index]!.shouldPlayLivePhoto; - if (shouldPlayLivePhoto) { - final livePhotoType = getLivePhotoTypeFromFile(file); - if (livePhotoType != null) { - return _buildLivePhotoView(context, index, livePhotoType); - } else { - _log.warning("[_buildItemView] Not a live photo"); - } - } - return _buildImageView(context, index); - } else if (file_util.isSupportedVideoFormat(file)) { - return _buildVideoView(context, index); - } else { - _log.shout("[_buildItemView] Unknown file format: ${file.fdMime}"); - _pageStates[index]!.itemHeight = 0; - return Container(); - } - } - - Widget _buildImageView(BuildContext context, int index) { - return RemoteImageViewer( - account: widget.account, - file: _streamFilesView[index], - canZoom: _canZoom(), - onLoaded: () => _onImageLoaded(index), - onHeightChanged: (height) => _updateItemHeight(index, height), - onZoomStarted: () { - setState(() { - _isZoomed = true; - }); - }, - onZoomEnded: () { - setState(() { - _isZoomed = false; - }); - }, - ); - } - - Widget _buildVideoView(BuildContext context, int index) { - return VideoViewer( - account: widget.account, - file: _streamFilesView[index], - onLoaded: () => _onVideoLoaded(index), - onHeightChanged: (height) => _updateItemHeight(index, height), - onPlay: _onVideoPlay, - onPause: _onVideoPause, - isControlVisible: _isShowAppBar && !_isDetailPaneActive, - canPlay: !_isDetailPaneActive, - canZoom: _canZoom(), - onZoomStarted: () { - setState(() { - _isZoomed = true; - }); - }, - onZoomEnded: () { - setState(() { - _isZoomed = false; - }); - }, - ); - } - - Widget _buildLivePhotoView( - BuildContext context, int index, LivePhotoType livePhotoType) { - return LivePhotoViewer( - account: widget.account, - file: _streamFilesView[index], - onLoaded: () => _onVideoLoaded(index), - onHeightChanged: (height) => _updateItemHeight(index, height), - canPlay: !_isDetailPaneActive, - livePhotoType: livePhotoType, - onLoadFailure: () { - if (mounted) { - setState(() { - _pageStates[index]!.shouldPlayLivePhoto = false; - }); - } - }, - ); - } - - bool _onPageContentScrolled(ScrollNotification notification, int index) { - if (!_canOpenDetailPane()) { - return false; - } - if (notification is ScrollStartNotification) { - _scrollStartPosition = - _pageStates[index]?.scrollController.position.pixels; - } - if (notification is ScrollEndNotification) { - _scrollStartPosition = null; - final scrollPos = _pageStates[index]!.scrollController.position; - if (scrollPos.pixels == 0) { - setState(() { - _onDetailPaneClosed(); - }); - } else if (scrollPos.pixels < - _calcDetailPaneOpenedScrollPosition(index) - 1) { - if (scrollPos.userScrollDirection == ScrollDirection.reverse) { - // upward, open the pane to its minimal size - Future.delayed(Duration.zero, () { - setState(() { - _openDetailPane(_viewerController.currentPage, - shouldAnimate: true); - }); - }); - } else if (scrollPos.userScrollDirection == ScrollDirection.forward) { - // downward, close the pane - Future.delayed(Duration.zero, () { - _closeDetailPane(_viewerController.currentPage, - shouldAnimate: true); - }); - } - } - } else if (notification is ScrollUpdateNotification) { - if (!_isShowDetailPane) { - Future.delayed(Duration.zero, () { - setState(() { - _isShowDetailPane = true; - _isDetailPaneActive = true; - }); - }); - } - } - - if (notification is OverscrollNotification) { - if (_scrollStartPosition == 0) { - // start at top - _overscrollSum += notification.overscroll; - if (_overscrollSum < -144) { - // and scroll downwards - Navigator.of(context).pop(); - } - } - } else { - _overscrollSum = 0; - } - return false; - } - - void _onImageLoaded(int index) { - // currently pageview doesn't pre-load pages, we do it manually - // don't pre-load if user already navigated away - if (_viewerController.currentPage == index && - !_pageStates[index]!.hasLoaded) { - _log.info("[_onImageLoaded] Pre-loading nearby images"); - if (index > 0) { - final prevFile = _streamFilesView[index - 1]; - if (file_util.isSupportedImageFormat(prevFile)) { - RemoteImageViewer.preloadImage(widget.account, prevFile); - } - } - if (index + 1 < _streamFilesView.length) { - final nextFile = _streamFilesView[index + 1]; - if (file_util.isSupportedImageFormat(nextFile)) { - RemoteImageViewer.preloadImage(widget.account, nextFile); - } - } - } - setState(() { - _pageStates[index]!.hasLoaded = true; - _isViewerLoaded = true; - }); - } - - void _onVideoLoaded(int index) { - setState(() { - _pageStates[index]!.hasLoaded = true; - _isViewerLoaded = true; - }); - } - - void _onVideoPlay() { - setState(() { - _setShowActionBar(false); - }); - } - - void _onVideoPause() { - setState(() { - _setShowActionBar(true); - }); - } - - /// Called when the page is being built for the first time - void _onCreateNewPage(BuildContext context, int index) { - _pageStates[index] = _PageState(ScrollController( - initialScrollOffset: _isShowDetailPane && !_isClosingDetailPane - ? _calcDetailPaneOpenedScrollPosition(index) - : 0, - )); - } - - /// Called when the page is being built after previously moved out of view - void _onRecreatePageAfterMovedOut(BuildContext context, int index) { - _pageStates[index]!.setScrollController(ScrollController( - initialScrollOffset: _isShowDetailPane && !_isClosingDetailPane - ? _calcDetailPaneOpenedScrollPosition(index) - : 0, - )); - if (_isShowDetailPane && !_isClosingDetailPane) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _pageStates[index]!.itemHeight != null) { - setState(() { - _openDetailPane(index); - }); - } - }); - } else { - WidgetsBinding.instance.addPostFrameCallback((_) { - _pageStates[index]!.scrollController.jumpTo(0); - }); - } - } - - void _onPlayMotionPhotosPressed(int index) { - setState(() { - _pageStates[index]!.shouldPlayLivePhoto = true; - }); - } - - void _onPauseMotionPhotosPressed(int index) { - setState(() { - _pageStates[index]!.shouldPlayLivePhoto = false; - }); - } - - Future _onFavoritePressed(int index) async { - if (_pageStates[index]!.isProcessingFavorite) { - _log.fine("[_onFavoritePressed] Process ongoing, ignored"); + Future _onSlideshowRequest( + BuildContext context, + Unique<_SlideshowRequest?> slideshowRequest, + ) async { + if (slideshowRequest.value == null) { return; } - - setState(() { - _pageStates[index]!.favoriteOverride = true; - }); - _pageStates[index]!.isProcessingFavorite = true; - final fd = _streamFilesView[_viewerController.currentPage]; - try { - await context.read().filesController.updateProperty( - [fd], - isFavorite: true, - errorBuilder: (fileIds) { - if (mounted) { - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().unfavoriteFailureNotification), - duration: k.snackBarDurationNormal, - )); - setState(() { - _pageStates[index]!.favoriteOverride = false; - }); - } - return null; - }, - ); - } finally { - _pageStates[index]!.isProcessingFavorite = false; - } - } - - Future _onUnfavoritePressed(int index) async { - if (_pageStates[index]!.isProcessingFavorite) { - _log.fine("[_onUnfavoritePressed] Process ongoing, ignored"); - return; - } - - setState(() { - _pageStates[index]!.favoriteOverride = false; - }); - _pageStates[index]!.isProcessingFavorite = true; - final fd = _streamFilesView[_viewerController.currentPage]; - try { - await context.read().filesController.updateProperty( - [fd], - isFavorite: false, - errorBuilder: (fileIds) { - if (mounted) { - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().unfavoriteFailureNotification), - duration: k.snackBarDurationNormal, - )); - setState(() { - _pageStates[index]!.favoriteOverride = true; - }); - } - return null; - }, - ); - } finally { - _pageStates[index]!.isProcessingFavorite = false; - } - } - - void _onDetailsPressed() { - if (!_isDetailPaneActive) { - setState(() { - _openDetailPane(_viewerController.currentPage, shouldAnimate: true); - }); - } - } - - void _onSharePressed(BuildContext context) { - final c = KiwiContainer().resolve(); - final file = _streamFilesView[_viewerController.currentPage]; - ShareHandler( - c, - context: context, - ).shareFiles(widget.account, [file]); - } - - void _onEditPressed(BuildContext context) { - final file = _streamFilesView[_viewerController.currentPage]; - if (!file_util.isSupportedImageFormat(file)) { - _log.shout("[_onEditPressed] Video file not supported"); - return; - } - - _log.info("[_onEditPressed] Edit file: ${file.fdPath}"); - Navigator.of(context).pushNamed(ImageEditor.routeName, - arguments: ImageEditorArguments(widget.account, file)); - } - - void _onEnhancePressed(BuildContext context) { - final file = _streamFilesView[_viewerController.currentPage]; - if (!file_util.isSupportedImageFormat(file)) { - _log.shout("[_onEnhancePressed] Video file not supported"); - return; - } - final c = KiwiContainer().resolve(); - - _log.info("[_onEnhancePressed] Enhance file: ${file.fdPath}"); - Navigator.of(context).pushNamed(ImageEnhancer.routeName, - arguments: ImageEnhancerArguments( - widget.account, file, c.pref.isSaveEditResultToServerOr())); - } - - void _onDownloadPressed() { - final c = KiwiContainer().resolve(); - final file = _streamFilesView[_viewerController.currentPage]; - _log.info("[_onDownloadPressed] Downloading file: ${file.fdPath}"); - DownloadHandler(c).downloadFiles(widget.account, [file]); - } - - void _onDeletePressed(BuildContext context) { - final index = _viewerController.currentPage; - final file = _streamFilesView[index]; - _log.info("[_onDeletePressed] Removing file: ${file.fdPath}"); - unawaited(RemoveSelectionHandler( - filesController: context.read().filesController, - )( - account: widget.account, - selection: [file], - isRemoveOpened: true, - isMoveToTrash: true, - )); - _removeCurrentItemFromStream(context, index); - } - - void _onArchivePressed(BuildContext context) { - final index = _viewerController.currentPage; - final file = _streamFilesView[index]; - _log.info("[_onArchivePressed] Archive file: ${file.fdPath}"); - context.read().filesController.updateProperty( - [file], - isArchived: const OrNull(true), - errorBuilder: (fileIds) { - if (mounted) { - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().archiveSelectedFailureNotification(1)), - duration: k.snackBarDurationNormal, - )); - } - return null; - }, - ); - _removeCurrentItemFromStream(context, index); - } - - void _onUnarchivePressed(BuildContext context) { - final index = _viewerController.currentPage; - final file = _streamFilesView[index]; - _log.info("[_onUnarchivePressed] Unarchive file: ${file.fdPath}"); - context.read().filesController.updateProperty( - [file], - isArchived: const OrNull(false), - errorBuilder: (fileIds) { - if (mounted) { - SnackBarManager().showSnackBar(SnackBar( - content: - Text(L10n.global().unarchiveSelectedFailureNotification(1)), - duration: k.snackBarDurationNormal, - )); - } - return null; - }, - ); - _removeCurrentItemFromStream(context, index); - } - - Future _onRemoveFromCollectionPressed(BuildContext context) async { - assert(CollectionAdapter.of(KiwiContainer().resolve(), - widget.account, widget.fromCollection!.collection) - .isPermitted(CollectionCapability.manualItem)); - final index = _viewerController.currentPage; - final file = _streamFilesView[index]; - _log.info("[_onRemoveFromCollectionPressed] Remove file: ${file.fdPath}"); - try { - final itemsController = _findCollectionItemsController(context); - final item = itemsController.stream.value.items - .whereType() - .firstWhere((i) => i.file.compareServerIdentity(file)); - await itemsController.removeItems([item]); - } catch (e, stackTrace) { - _log.shout("[_onRemoveFromCollectionPressed] Failed while updating album", - e, stackTrace); - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().removeSelectedFromAlbumFailureNotification), - duration: k.snackBarDurationNormal, - )); - } - _removeCurrentItemFromStream(context, index); - } - - void _removeCurrentItemFromStream(BuildContext context, int index) { - if (_streamFilesView.length == 1) { - Navigator.of(context).pop(); - } else { - if (index >= _streamFilesView.length - 1) { - // last item, go back - _viewerController - .previousPage( - duration: k.animationDurationNormal, - curve: Curves.easeInOut, - ) - .then((_) { - if (mounted) { - setState(() { - _streamFilesEditable.removeAt(index); - }); - } - }); - } else { - _viewerController - .nextPage( - duration: k.animationDurationNormal, - curve: Curves.easeInOut, - ) - .then((_) { - if (mounted) { - setState(() { - _streamFilesEditable.removeAt(index); - }); - // a page is removed, length - 1 - _viewerController.jumpToPage(index); - } - }); - } - } - } - - Future _onSlideshowPressed() async { final result = await showDialog( context: context, builder: (_) => SlideshowDialog( - duration: Duration(seconds: Pref().getSlideshowDurationOr(5)), - isShuffle: Pref().isSlideshowShuffleOr(false), - isRepeat: Pref().isSlideshowRepeatOr(false), - isReverse: Pref().isSlideshowReverseOr(false), + duration: context.bloc.prefController.slideshowDurationValue, + isShuffle: context.bloc.prefController.isSlideshowShuffleValue, + isRepeat: context.bloc.prefController.isSlideshowRepeatValue, + isReverse: context.bloc.prefController.isSlideshowReverseValue, ), ); - if (result == null) { + if (!context.mounted || result == null) { return; } - unawaited(Pref().setSlideshowDuration(result.duration.inSeconds)); - unawaited(Pref().setSlideshowShuffle(result.isShuffle)); - unawaited(Pref().setSlideshowRepeat(result.isRepeat)); - unawaited(Pref().setSlideshowReverse(result.isReverse)); + unawaited( + context.bloc.prefController.setSlideshowDuration(result.duration)); + unawaited( + context.bloc.prefController.setSlideshowShuffle(result.isShuffle)); + unawaited(context.bloc.prefController.setSlideshowRepeat(result.isRepeat)); + unawaited( + context.bloc.prefController.setSlideshowReverse(result.isReverse)); final newIndex = await Navigator.of(context).pushNamed( SlideshowViewer.routeName, - arguments: SlideshowViewerArguments(widget.account, widget.streamFiles, - _viewerController.currentPage, result), + arguments: SlideshowViewerArguments( + slideshowRequest.value!.account, + slideshowRequest.value!.files, + slideshowRequest.value!.startIndex, + result, + ), ); - _log.info("[_onSlideshowPressed] Slideshow ended, jump to: $newIndex"); + _log.info("[_onSlideshowRequest] Slideshow ended, jump to: $newIndex"); if (newIndex != null && context.mounted) { - _viewerController.jumpToPage(newIndex); + context.addEvent(_RequestPage(newIndex)); } } - - double _calcDetailPaneOffset(int index) { - if (_pageStates[index]?.itemHeight == null) { - return MediaQuery.of(context).size.height; - } else { - return _pageStates[index]!.itemHeight! + - (MediaQuery.of(context).size.height - - _pageStates[index]!.itemHeight!) / - 2 - - 4; - } - } - - double _calcDetailPaneOpenedScrollPosition(int index) { - // distance of the detail pane from the top edge - const distanceFromTop = 196; - return max(_calcDetailPaneOffset(index) - distanceFromTop, 0); - } - - void _updateItemHeight(int index, double height) { - if (_pageStates[index]!.itemHeight != height) { - _log.fine("[_updateItemHeight] New height of item#$index: $height"); - setState(() { - _pageStates[index]!.itemHeight = height; - if (_isDetailPaneActive) { - _openDetailPane(index); - } - }); - } - } - - void _setShowActionBar(bool flag) { - _isShowAppBar = flag; - if (flag) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, - overlays: SystemUiOverlay.values); - } else { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); - } - } - - void _openDetailPane(int index, {bool shouldAnimate = false}) { - if (!_canOpenDetailPane()) { - _log.warning("[_openDetailPane] Can't open detail pane right now"); - return; - } - - _isShowDetailPane = true; - _isDetailPaneActive = true; - if (shouldAnimate) { - _pageStates[index]!.scrollController.animateTo( - _calcDetailPaneOpenedScrollPosition(index), - duration: k.animationDurationNormal, - curve: Curves.easeOut); - } else { - _pageStates[index]! - .scrollController - .jumpTo(_calcDetailPaneOpenedScrollPosition(index)); - } - } - - void _closeDetailPane(int index, {bool shouldAnimate = false}) { - _isClosingDetailPane = true; - if (shouldAnimate) { - _pageStates[index]!.scrollController.animateTo(0, - duration: k.animationDurationNormal, curve: Curves.easeOut); - } - } - - void _onDetailPaneClosed() { - _isShowDetailPane = false; - _isClosingDetailPane = false; - } - - CollectionItemsController _findCollectionItemsController( - BuildContext context) { - return context - .read() - .collectionsController - .stream - .value - .data - .firstWhere((d) => - d.collection.compareIdentity(widget.fromCollection!.collection)) - .controller; - } - - bool _canSwitchPage() => !_isZoomed; - bool _canOpenDetailPane() => !_isZoomed; - bool _canZoom() => !_isDetailPaneActive; - - List get _streamFilesEditable { - if (!_isStreamFilesCopy) { - _streamFilesView = List.of(_streamFilesView); - _isStreamFilesCopy = true; - } - return _streamFilesView; - } - - var _isShowAppBar = true; - - var _isShowDetailPane = false; - var _isDetailPaneActive = false; - var _isClosingDetailPane = false; - - var _isZoomed = false; - - final _viewerController = HorizontalPageViewerController(); - bool _isViewerLoaded = false; - final _pageStates = {}; - - double? _scrollStartPosition; - var _overscrollSum = 0.0; - - late List _streamFilesView; - bool _isStreamFilesCopy = false; - - final _pageViewerKey = GlobalKey(); - - static const _viewportFraction = 1.05; } -class _PageState { - _PageState(this.scrollController); +typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; +typedef _BlocListener = BlocListener<_Bloc, _State>; +typedef _BlocListenerT = BlocListenerT<_Bloc, _State, T>; +typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; +typedef _Emitter = Emitter<_State>; - void setScrollController(ScrollController c) { - scrollController = c; - } - - ScrollController scrollController; - double? itemHeight; - bool hasLoaded = false; - - bool isProcessingFavorite = false; - bool? favoriteOverride; - bool shouldPlayLivePhoto = false; -} - -class _AppBarTitle extends StatelessWidget { - const _AppBarTitle({ - required this.file, - required this.isCentered, - }); - - @override - Widget build(BuildContext context) { - final locale = Localizations.localeOf(context).languageCode; - final localTime = file.fdDateTime.toLocal(); - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - isCentered ? CrossAxisAlignment.center : CrossAxisAlignment.start, - children: [ - Text( - (localTime.year == DateTime.now().year - ? DateFormat.MMMd(locale) - : DateFormat.yMMMd(locale)) - .format(localTime), - style: Theme.of(context).textTheme.titleMedium, - ), - Text( - DateFormat.jm(locale).format(localTime), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ); - } - - final FileDescriptor file; - final bool isCentered; +extension on BuildContext { + _Bloc get bloc => read<_Bloc>(); + _State get state => bloc.state; + void addEvent(_Event event) => bloc.add(event); } diff --git a/app/lib/widget/viewer.g.dart b/app/lib/widget/viewer.g.dart index ef85660e..0991f7ae 100644 --- a/app/lib/widget/viewer.g.dart +++ b/app/lib/widget/viewer.g.dart @@ -2,13 +2,421 @@ part of 'viewer.dart'; +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_StateCopyWithWorker { + _State call( + {List? fileIdOrders, + Map? files, + Map? fileStates, + int? index, + FileDescriptor? currentFile, + _PageState? currentFileState, + Collection? collection, + CollectionItemsController? collectionItemsController, + Map? collectionItems, + bool? isShowDetailPane, + bool? isClosingDetailPane, + bool? isDetailPaneActive, + Unique<_OpenDetailPaneRequest>? openDetailPaneRequest, + Unique? closeDetailPane, + bool? isZoomed, + bool? isShowAppBar, + bool? isInitialLoad, + Unique? pendingRemovePage, + Unique? imageEditorRequest, + Unique? imageEnhancerRequest, + Unique<_ShareRequest?>? shareRequest, + Unique<_SlideshowRequest?>? slideshowRequest, + ExceptionEvent? error}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic fileIdOrders, + dynamic files, + dynamic fileStates, + dynamic index, + dynamic currentFile = copyWithNull, + dynamic currentFileState = copyWithNull, + dynamic collection = copyWithNull, + dynamic collectionItemsController = copyWithNull, + dynamic collectionItems = copyWithNull, + dynamic isShowDetailPane, + dynamic isClosingDetailPane, + dynamic isDetailPaneActive, + dynamic openDetailPaneRequest, + dynamic closeDetailPane, + dynamic isZoomed, + dynamic isShowAppBar, + dynamic isInitialLoad, + dynamic pendingRemovePage, + dynamic imageEditorRequest, + dynamic imageEnhancerRequest, + dynamic shareRequest, + dynamic slideshowRequest, + dynamic error = copyWithNull}) { + return _State( + fileIdOrders: fileIdOrders as List? ?? that.fileIdOrders, + files: files as Map? ?? that.files, + fileStates: fileStates as Map? ?? that.fileStates, + index: index as int? ?? that.index, + currentFile: currentFile == copyWithNull + ? that.currentFile + : currentFile as FileDescriptor?, + currentFileState: currentFileState == copyWithNull + ? that.currentFileState + : currentFileState as _PageState?, + collection: collection == copyWithNull + ? that.collection + : collection as Collection?, + collectionItemsController: collectionItemsController == copyWithNull + ? that.collectionItemsController + : collectionItemsController as CollectionItemsController?, + collectionItems: collectionItems == copyWithNull + ? that.collectionItems + : collectionItems as Map?, + isShowDetailPane: isShowDetailPane as bool? ?? that.isShowDetailPane, + isClosingDetailPane: + isClosingDetailPane as bool? ?? that.isClosingDetailPane, + isDetailPaneActive: + isDetailPaneActive as bool? ?? that.isDetailPaneActive, + openDetailPaneRequest: + openDetailPaneRequest as Unique<_OpenDetailPaneRequest>? ?? + that.openDetailPaneRequest, + closeDetailPane: + closeDetailPane as Unique? ?? that.closeDetailPane, + isZoomed: isZoomed as bool? ?? that.isZoomed, + isShowAppBar: isShowAppBar as bool? ?? that.isShowAppBar, + isInitialLoad: isInitialLoad as bool? ?? that.isInitialLoad, + pendingRemovePage: + pendingRemovePage as Unique? ?? that.pendingRemovePage, + imageEditorRequest: + imageEditorRequest as Unique? ?? + that.imageEditorRequest, + imageEnhancerRequest: + imageEnhancerRequest as Unique? ?? + that.imageEnhancerRequest, + shareRequest: + shareRequest as Unique<_ShareRequest?>? ?? that.shareRequest, + slideshowRequest: slideshowRequest as Unique<_SlideshowRequest?>? ?? + that.slideshowRequest, + error: error == copyWithNull ? that.error : error as ExceptionEvent?); + } + + final _State that; +} + +extension $_StateCopyWith on _State { + $_StateCopyWithWorker get copyWith => _$copyWith; + $_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this); +} + +abstract class $_PageStateCopyWithWorker { + _PageState call( + {double? itemHeight, bool? hasLoaded, bool? shouldPlayLivePhoto}); +} + +class _$_PageStateCopyWithWorkerImpl implements $_PageStateCopyWithWorker { + _$_PageStateCopyWithWorkerImpl(this.that); + + @override + _PageState call( + {dynamic itemHeight = copyWithNull, + dynamic hasLoaded, + dynamic shouldPlayLivePhoto}) { + return _PageState( + itemHeight: itemHeight == copyWithNull + ? that.itemHeight + : itemHeight as double?, + hasLoaded: hasLoaded as bool? ?? that.hasLoaded, + shouldPlayLivePhoto: + shouldPlayLivePhoto as bool? ?? that.shouldPlayLivePhoto); + } + + final _PageState that; +} + +extension $_PageStateCopyWith on _PageState { + $_PageStateCopyWithWorker get copyWith => _$copyWith; + $_PageStateCopyWithWorker get _$copyWith => + _$_PageStateCopyWithWorkerImpl(this); +} + // ************************************************************************** // NpLogGenerator // ************************************************************************** -extension _$_ViewerStateNpLog on _ViewerState { +extension _$_WrappedViewerStateNpLog on _WrappedViewerState { // ignore: unused_element Logger get _log => log; - static final log = Logger("widget.viewer._ViewerState"); + static final log = Logger("widget.viewer._WrappedViewerState"); +} + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.viewer._Bloc"); +} + +extension _$_ContentBodyStateNpLog on _ContentBodyState { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.viewer._ContentBodyState"); +} + +extension _$_PageViewStateNpLog on _PageViewState { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.viewer._PageViewState"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_StateToString on _State { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_State {fileIdOrders: $fileIdOrders, files: {length: ${files.length}}, fileStates: {length: ${fileStates.length}}, index: $index, currentFile: ${currentFile == null ? null : "${currentFile!.fdPath}"}, currentFileState: $currentFileState, collection: $collection, collectionItemsController: $collectionItemsController, collectionItems: ${collectionItems == null ? null : "{length: ${collectionItems!.length}}"}, isShowDetailPane: $isShowDetailPane, isClosingDetailPane: $isClosingDetailPane, isDetailPaneActive: $isDetailPaneActive, openDetailPaneRequest: $openDetailPaneRequest, closeDetailPane: $closeDetailPane, isZoomed: $isZoomed, isShowAppBar: $isShowAppBar, isInitialLoad: $isInitialLoad, pendingRemovePage: $pendingRemovePage, imageEditorRequest: $imageEditorRequest, imageEnhancerRequest: $imageEnhancerRequest, shareRequest: $shareRequest, slideshowRequest: $slideshowRequest, error: $error}"; + } +} + +extension _$_PageStateToString on _PageState { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_PageState {itemHeight: ${itemHeight == null ? null : "${itemHeight!.toStringAsFixed(3)}"}, hasLoaded: $hasLoaded, shouldPlayLivePhoto: $shouldPlayLivePhoto}"; + } +} + +extension _$_InitToString on _Init { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Init {}"; + } +} + +extension _$_SetIndexToString on _SetIndex { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetIndex {index: $index}"; + } +} + +extension _$_RequestPageToString on _RequestPage { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_RequestPage {index: $index}"; + } +} + +extension _$_SetCollectionToString on _SetCollection { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetCollection {collection: $collection, itemsController: $itemsController}"; + } +} + +extension _$_SetCollectionItemsToString on _SetCollectionItems { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetCollectionItems {value: ${value == null ? null : "[length: ${value!.length}]"}}"; + } +} + +extension _$_ToggleAppBarToString on _ToggleAppBar { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_ToggleAppBar {}"; + } +} + +extension _$_ShowAppBarToString on _ShowAppBar { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_ShowAppBar {}"; + } +} + +extension _$_HideAppBarToString on _HideAppBar { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_HideAppBar {}"; + } +} + +extension _$_PauseLivePhotoToString on _PauseLivePhoto { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_PauseLivePhoto {fileId: $fileId}"; + } +} + +extension _$_PlayLivePhotoToString on _PlayLivePhoto { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_PlayLivePhoto {fileId: $fileId}"; + } +} + +extension _$_UnfavoriteToString on _Unfavorite { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Unfavorite {fileId: $fileId}"; + } +} + +extension _$_FavoriteToString on _Favorite { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Favorite {fileId: $fileId}"; + } +} + +extension _$_UnarchiveToString on _Unarchive { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Unarchive {fileId: $fileId}"; + } +} + +extension _$_ArchiveToString on _Archive { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Archive {fileId: $fileId}"; + } +} + +extension _$_ShareToString on _Share { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Share {fileId: $fileId}"; + } +} + +extension _$_EditToString on _Edit { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Edit {fileId: $fileId}"; + } +} + +extension _$_EnhanceToString on _Enhance { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Enhance {fileId: $fileId}"; + } +} + +extension _$_DownloadToString on _Download { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Download {fileId: $fileId}"; + } +} + +extension _$_DeleteToString on _Delete { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Delete {fileId: $fileId}"; + } +} + +extension _$_RemoveFromCollectionToString on _RemoveFromCollection { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_RemoveFromCollection {value: $value}"; + } +} + +extension _$_StartSlideshowToString on _StartSlideshow { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_StartSlideshow {fileId: $fileId}"; + } +} + +extension _$_OpenDetailPaneToString on _OpenDetailPane { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_OpenDetailPane {shouldAnimate: $shouldAnimate}"; + } +} + +extension _$_CloseDetailPaneToString on _CloseDetailPane { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_CloseDetailPane {}"; + } +} + +extension _$_DetailPaneClosedToString on _DetailPaneClosed { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_DetailPaneClosed {}"; + } +} + +extension _$_ShowDetailPaneToString on _ShowDetailPane { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_ShowDetailPane {}"; + } +} + +extension _$_SetDetailPaneInactiveToString on _SetDetailPaneInactive { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetDetailPaneInactive {}"; + } +} + +extension _$_SetDetailPaneActiveToString on _SetDetailPaneActive { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetDetailPaneActive {}"; + } +} + +extension _$_SetFileContentHeightToString on _SetFileContentHeight { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetFileContentHeight {fileId: $fileId, value: ${value.toStringAsFixed(3)}}"; + } +} + +extension _$_SetIsZoomedToString on _SetIsZoomed { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetIsZoomed {value: $value}"; + } +} + +extension _$_RemovePageToString on _RemovePage { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_RemovePage {value: $value}"; + } +} + +extension _$_SetErrorToString on _SetError { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetError {error: $error, stackTrace: $stackTrace}"; + } } diff --git a/app/lib/widget/viewer/app_bar.dart b/app/lib/widget/viewer/app_bar.dart new file mode 100644 index 00000000..a4140bf1 --- /dev/null +++ b/app/lib/widget/viewer/app_bar.dart @@ -0,0 +1,213 @@ +part of '../viewer.dart'; + +class _AppBar extends StatelessWidget { + const _AppBar(); + + @override + Widget build(BuildContext context) { + final isTitleCentered = getRawPlatform() == NpPlatform.iOs; + return _BlocBuilder( + buildWhen: (previous, current) => + previous.isDetailPaneActive != current.isDetailPaneActive || + previous.isZoomed != current.isZoomed, + builder: (context, state) => AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: _BlocBuilder( + buildWhen: (previous, current) => + previous.isDetailPaneActive != current.isDetailPaneActive || + previous.currentFile != current.currentFile, + builder: (context, state) => + !state.isDetailPaneActive && state.currentFile != null + ? _AppBarTitle( + file: state.currentFile!, + isCentered: isTitleCentered, + ) + : const SizedBox.shrink(), + ), + titleSpacing: 0, + centerTitle: isTitleCentered, + actions: !state.isDetailPaneActive && !state.isZoomed + ? [ + _BlocBuilder( + buildWhen: (previous, current) => + previous.currentFile != current.currentFile || + previous.currentFileState != current.currentFileState, + builder: (context, state) { + if (state.currentFile?.let(getLivePhotoTypeFromFile) != + null) { + if (state.currentFileState?.shouldPlayLivePhoto ?? + false) { + return IconButton( + icon: const Icon(Icons.motion_photos_pause_outlined), + onPressed: () { + context.state.currentFile?.fdId.let( + (id) => context.addEvent(_PauseLivePhoto(id))); + }, + ); + } else { + return IconButton( + icon: const PngIcon(icMotionPhotosPlay24dp), + onPressed: () { + context.state.currentFile?.fdId.let( + (id) => context.addEvent(_PlayLivePhoto(id))); + }, + ); + } + } else { + return const SizedBox.shrink(); + } + }, + ), + _BlocSelector( + selector: (state) => state.currentFile, + builder: (context, currentFile) => currentFile + ?.fdIsFavorite == + true + ? IconButton( + icon: const Icon(Icons.star), + tooltip: L10n.global().unfavoriteTooltip, + onPressed: () { + context.state.currentFile?.fdId + .let((id) => context.addEvent(_Unfavorite(id))); + }, + ) + : IconButton( + icon: const Icon(Icons.star_border), + tooltip: L10n.global().favoriteTooltip, + onPressed: () { + context.state.currentFile?.fdId + .let((id) => context.addEvent(_Favorite(id))); + }, + ), + ), + IconButton( + icon: const Icon(Icons.more_vert), + tooltip: L10n.global().detailsTooltip, + onPressed: () { + context.addEvent(const _OpenDetailPane(true)); + }, + ), + ] + : null, + ), + ); + } +} + +class _AppBarTitle extends StatelessWidget { + const _AppBarTitle({ + required this.file, + required this.isCentered, + }); + + @override + Widget build(BuildContext context) { + final locale = Localizations.localeOf(context).languageCode; + final localTime = file.fdDateTime.toLocal(); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + isCentered ? CrossAxisAlignment.center : CrossAxisAlignment.start, + children: [ + Text( + (localTime.year == DateTime.now().year + ? DateFormat.MMMd(locale) + : DateFormat.yMMMd(locale)) + .format(localTime), + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + DateFormat.jm(locale).format(localTime), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } + + final FileDescriptor file; + final bool isCentered; +} + +class _BottomAppBar extends StatelessWidget { + const _BottomAppBar(); + + @override + Widget build(BuildContext context) { + return Container( + height: kToolbarHeight, + alignment: Alignment.center, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment(0, -1), + end: Alignment(0, 1), + colors: [ + Color.fromARGB(0, 0, 0, 0), + Color.fromARGB(192, 0, 0, 0), + ], + ), + ), + child: _BlocBuilder( + buildWhen: (previous, current) => + previous.currentFile != current.currentFile || + previous.collection != current.collection, + builder: (context, state) => Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + IconButton( + icon: const Icon(Icons.share_outlined), + tooltip: L10n.global().shareTooltip, + onPressed: () { + context.state.currentFile?.fdId + .let((id) => context.addEvent(_Share(id))); + }, + ), + if (features.isSupportEnhancement && + state.currentFile?.let(ImageEnhancer.isSupportedFormat) == + true) ...[ + IconButton( + icon: const Icon(Icons.tune_outlined), + tooltip: L10n.global().editTooltip, + onPressed: () { + context.state.currentFile?.fdId + .let((id) => context.addEvent(_Edit(id))); + }, + ), + IconButton( + icon: const Icon(Icons.auto_fix_high_outlined), + tooltip: L10n.global().enhanceTooltip, + onPressed: () { + context.state.currentFile?.fdId + .let((id) => context.addEvent(_Enhance(id))); + }, + ), + ], + IconButton( + icon: const Icon(Icons.download_outlined), + tooltip: L10n.global().downloadTooltip, + onPressed: () { + context.state.currentFile?.fdId + .let((id) => context.addEvent(_Download(id))); + }, + ), + if (state.collection == null) + IconButton( + icon: const Icon(Icons.delete_outlined), + tooltip: L10n.global().deleteTooltip, + onPressed: () { + context.state.currentFile?.fdId + .let((id) => context.addEvent(_Delete(id))); + }, + ), + ] + .map((e) => Expanded( + flex: 1, + child: e, + )) + .toList(), + ), + ), + ); + } +} diff --git a/app/lib/widget/viewer/bloc.dart b/app/lib/widget/viewer/bloc.dart new file mode 100644 index 00000000..fdb432c8 --- /dev/null +++ b/app/lib/widget/viewer/bloc.dart @@ -0,0 +1,441 @@ +part of '../viewer.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> + with BlocLogger, BlocForEachMixin<_Event, _State> { + _Bloc( + this._c, { + required this.account, + required this.filesController, + required this.collectionsController, + required this.prefController, + required this.brightness, + required List fileIds, + required int startIndex, + this.collectionId, + }) : super(_State.init( + fileIds: fileIds, + index: startIndex, + currentFile: + filesController.stream.value.dataMap[fileIds[startIndex]]!, + )) { + on<_Init>(_onInit); + on<_SetIndex>(_onSetIndex); + on<_RequestPage>(_onRequestPage); + on<_SetCollection>(_onSetCollection); + on<_SetCollectionItems>(_onSetCollectionItems); + + on<_ToggleAppBar>(_onToggleAppBar); + on<_ShowAppBar>(_onShowAppBar); + on<_HideAppBar>(_onHideAppBar); + on<_PauseLivePhoto>(_onPauseLivePhoto); + on<_PlayLivePhoto>(_onPlayLivePhoto); + on<_Unfavorite>(_onUnfavorite); + on<_Favorite>(_onFavorite); + on<_Unarchive>(_onUnarchive); + on<_Archive>(_onArchive); + on<_Share>(_onShare); + on<_Edit>(_onEdit); + on<_Enhance>(_onEnhance); + on<_Download>(_onDownload); + on<_Delete>(_onDelete); + on<_RemoveFromCollection>(_onRemoveFromCollection); + on<_StartSlideshow>(_onStartSlideshow); + + on<_OpenDetailPane>(_onOpenDetailPane); + on<_CloseDetailPane>(_onCloseDetailPane); + on<_DetailPaneClosed>(_onDetailPaneClosed); + on<_ShowDetailPane>(_onShowDetailPane); + on<_SetDetailPaneInactive>(_onSetDetailPaneInactive); + on<_SetDetailPaneActive>(_onSetDetailPaneActive); + + on<_SetFileContentHeight>(_onSetFileContentHeight); + on<_SetIsZoomed>(_onSetIsZoomed); + + on<_RemovePage>(_onRemovePage); + + on<_SetError>(_onSetError); + + if (collectionId != null) { + _subscriptions.add(collectionsController.stream.listen((event) { + for (final c in event.data) { + if (c.collection.id == collectionId) { + add(_SetCollection(c.collection, c.controller)); + _collectionItemsSubscription?.cancel(); + _collectionItemsSubscription = c.controller.stream.listen((event) { + add(_SetCollectionItems(event.items)); + }); + return; + } + } + _log.warning("[_Bloc] Collection not found: $collectionId"); + add(const _SetCollection(null, null)); + add(const _SetCollectionItems(null)); + _collectionItemsSubscription?.cancel(); + })); + } + + add(_SetIndex(startIndex)); + } + + @override + Future close() { + _collectionItemsSubscription?.cancel(); + for (final s in _subscriptions) { + s.cancel(); + } + return super.close(); + } + + @override + String get tag => _log.fullName; + + @override + void onError(Object error, StackTrace stackTrace) { + // we need this to prevent onError being triggered recursively + if (!isClosed && !_isHandlingError) { + _isHandlingError = true; + try { + add(_SetError(error, stackTrace)); + } catch (_) {} + _isHandlingError = false; + } + super.onError(error, stackTrace); + } + + Future _onInit(_Init ev, _Emitter emit) async { + await Future.wait([ + forEach( + emit, + filesController.stream, + onData: (data) => state.copyWith( + files: data.dataMap, + currentFile: data.dataMap[state.fileIdOrders[state.index]], + ), + ), + forEach( + emit, + filesController.errorStream, + onData: (data) => state.copyWith( + error: ExceptionEvent(data.error, data.stackTrace), + ), + ), + ]); + } + + void _onSetIndex(_SetIndex ev, _Emitter emit) { + _log.info(ev); + final fileId = state.fileIdOrders[ev.index]; + final fileState = state.fileStates[fileId] ?? _PageState.create(); + emit(state.copyWith( + index: ev.index, + currentFile: state.files[fileId], + fileStates: state.fileStates[fileId] == null + ? state.fileStates.addedAll({fileId: fileState}) + : null, + currentFileState: fileState, + isInitialLoad: false, + )); + } + + void _onRequestPage(_RequestPage ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(index: ev.index)); + } + + void _onSetCollection(_SetCollection ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith( + collection: ev.collection, + collectionItemsController: ev.itemsController, + )); + } + + void _onSetCollectionItems(_SetCollectionItems ev, _Emitter emit) { + _log.info(ev); + final itemMap = ev.value + ?.whereType() + .map((e) => MapEntry(e.file.fdId, e)) + .toMap(); + emit(state.copyWith(collectionItems: itemMap)); + } + + void _onToggleAppBar(_ToggleAppBar ev, _Emitter emit) { + _log.info(ev); + final to = !state.isShowAppBar; + emit(state.copyWith(isShowAppBar: to)); + if (to) { + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); + } else { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + } + } + + void _onShowAppBar(_ShowAppBar ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(isShowAppBar: true)); + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); + } + + void _onHideAppBar(_HideAppBar ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(isShowAppBar: false)); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + } + + void _onPauseLivePhoto(_PauseLivePhoto ev, _Emitter emit) { + _log.info(ev); + _updateFileState(ev.fileId, emit, shouldPlayLivePhoto: false); + } + + void _onPlayLivePhoto(_PlayLivePhoto ev, _Emitter emit) { + _log.info(ev); + _updateFileState(ev.fileId, emit, shouldPlayLivePhoto: true); + } + + void _onUnfavorite(_Unfavorite ev, _Emitter emit) { + _log.info(ev); + final f = state.files[ev.fileId]; + if (f == null) { + _log.severe("[_onUnfavorite] file is null: ${ev.fileId}"); + return; + } + filesController.updateProperty([f], isFavorite: false); + } + + void _onFavorite(_Favorite ev, _Emitter emit) { + _log.info(ev); + final f = state.files[ev.fileId]; + if (f == null) { + _log.severe("[_onFavorite] file is null: ${ev.fileId}"); + return; + } + filesController.updateProperty([f], isFavorite: true); + } + + void _onUnarchive(_Unarchive ev, _Emitter emit) { + _log.info(ev); + final f = state.files[ev.fileId]; + if (f == null) { + _log.severe("[_onUnarchive] file is null: ${ev.fileId}"); + return; + } + filesController.updateProperty([f], isArchived: const OrNull(false)); + _removeFileFromStream(ev.fileId, emit); + } + + void _onArchive(_Archive ev, _Emitter emit) { + _log.info(ev); + final f = state.files[ev.fileId]; + if (f == null) { + _log.severe("[_onArchive] file is null: ${ev.fileId}"); + return; + } + filesController.updateProperty([f], isArchived: const OrNull(true)); + _removeFileFromStream(ev.fileId, emit); + } + + void _onShare(_Share ev, _Emitter emit) { + _log.info(ev); + final f = state.files[ev.fileId]; + if (f == null) { + _log.severe("[_onShare] file is null: ${ev.fileId}"); + return; + } + emit(state.copyWith(shareRequest: Unique(_ShareRequest(f)))); + } + + void _onEdit(_Edit ev, _Emitter emit) { + _log.info(ev); + final f = state.files[ev.fileId]; + if (f == null) { + _log.severe("[_onEdit] file is null: ${ev.fileId}"); + return; + } + emit(state.copyWith( + imageEditorRequest: Unique(ImageEditorArguments(account, f)), + )); + } + + void _onEnhance(_Enhance ev, _Emitter emit) { + _log.info(ev); + final f = state.files[ev.fileId]; + if (f == null) { + _log.severe("[_onEnhance] file is null: ${ev.fileId}"); + return; + } + emit(state.copyWith( + imageEnhancerRequest: Unique(ImageEnhancerArguments( + account, + f, + prefController.isSaveEditResultToServerValue, + )), + )); + } + + void _onDownload(_Download ev, _Emitter emit) { + _log.info(ev); + final f = state.files[ev.fileId]; + if (f == null) { + _log.severe("[_onDownload] file is null: ${ev.fileId}"); + return; + } + DownloadHandler(_c).downloadFiles(account, [f]); + } + + void _onDelete(_Delete ev, _Emitter emit) { + _log.info(ev); + final f = state.files[ev.fileId]; + if (f == null) { + _log.severe("[_onDelete] file is null: ${ev.fileId}"); + return; + } + RemoveSelectionHandler(filesController: filesController)( + account: account, + selection: [f], + isRemoveOpened: true, + isMoveToTrash: true, + ); + _removeFileFromStream(f.fdId, emit); + } + + void _onRemoveFromCollection(_RemoveFromCollection ev, _Emitter emit) { + _log.info(ev); + if (!CollectionAdapter.of(_c, account, state.collection!) + .isPermitted(CollectionCapability.manualItem)) { + throw UnsupportedError("Operation not supported by this collection"); + } + state.collectionItemsController!.removeItems([ev.value]); + _removeFileFromStream((ev.value as CollectionFileItem).file.fdId, emit); + } + + void _onStartSlideshow(_StartSlideshow ev, _Emitter emit) { + _log.info(ev); + final files = + state.fileIdOrders.map((id) => state.files[id]).nonNulls.toList(); + final req = _SlideshowRequest( + account: account, + files: files, + startIndex: files + .indexWhere((e) => e.fdId == ev.fileId) + .let((i) => i == -1 ? 0 : i), + ); + emit(state.copyWith(slideshowRequest: Unique(req))); + } + + void _onOpenDetailPane(_OpenDetailPane ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith( + openDetailPaneRequest: Unique(_OpenDetailPaneRequest(ev.shouldAnimate)), + )); + } + + void _onCloseDetailPane(_CloseDetailPane ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith( + closeDetailPane: Unique(true), + isClosingDetailPane: true, + )); + } + + void _onDetailPaneClosed(_DetailPaneClosed ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith( + isShowDetailPane: false, + isClosingDetailPane: false, + )); + } + + void _onShowDetailPane(_ShowDetailPane ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith( + isShowDetailPane: true, + isDetailPaneActive: true, + )); + } + + void _onSetDetailPaneInactive(_SetDetailPaneInactive ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(isDetailPaneActive: false)); + } + + void _onSetDetailPaneActive(_SetDetailPaneActive ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(isDetailPaneActive: true)); + } + + void _onSetFileContentHeight(_SetFileContentHeight ev, _Emitter emit) { + _log.info(ev); + _updateFileState(ev.fileId, emit, itemHeight: ev.value); + } + + void _onSetIsZoomed(_SetIsZoomed ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(isZoomed: ev.value)); + } + + void _onRemovePage(_RemovePage ev, _Emitter emit) { + _log.info(ev); + final newFileIds = state.fileIdOrders.removedAt(ev.value); + emit(state.copyWith( + fileIdOrders: newFileIds, + index: ev.value <= state.index ? state.index - 1 : state.index, + )); + } + + void _onSetError(_SetError ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); + } + + void _updateFileState( + int fileId, + _Emitter emit, { + double? itemHeight, + bool? hasLoaded, + bool? shouldPlayLivePhoto, + }) { + final newStates = Map.of(state.fileStates); + var newState = state.fileStates[fileId] ?? _PageState.create(); + newState = newState.copyWith( + hasLoaded: hasLoaded, + shouldPlayLivePhoto: shouldPlayLivePhoto, + ); + if (itemHeight != null) { + // we don't support resetting itemHeight to null + newState = newState.copyWith(itemHeight: itemHeight); + } + newStates[fileId] = newState; + if (fileId == state.currentFile?.fdId) { + emit(state.copyWith( + fileStates: newStates, + currentFileState: newState, + )); + } else { + emit(state.copyWith(fileStates: newStates)); + } + } + + void _removeFileFromStream(int fileId, _Emitter emit) { + final index = state.fileIdOrders.indexOf(fileId); + emit(state.copyWith(pendingRemovePage: Unique(index))); + } + + final DiContainer _c; + final Account account; + final FilesController filesController; + final CollectionsController collectionsController; + final PrefController prefController; + final Brightness brightness; + final String? collectionId; + + final _subscriptions = []; + StreamSubscription? _collectionItemsSubscription; + var _isHandlingError = false; +} diff --git a/app/lib/widget/viewer/detail_pane.dart b/app/lib/widget/viewer/detail_pane.dart new file mode 100644 index 00000000..47672acb --- /dev/null +++ b/app/lib/widget/viewer/detail_pane.dart @@ -0,0 +1,111 @@ +part of '../viewer.dart'; + +class _DetailPaneContainer extends StatelessWidget { + const _DetailPaneContainer({ + required this.fileId, + }); + + final int fileId; + + @override + Widget build(BuildContext context) { + return _BlocBuilder( + buildWhen: (previous, current) => + previous.isShowDetailPane != current.isShowDetailPane || + previous.isZoomed != current.isZoomed || + previous.fileStates[fileId] != current.fileStates[fileId], + builder: (context, state) => IgnorePointer( + ignoring: !state.isShowDetailPane, + child: Visibility( + visible: !state.isZoomed, + child: AnimatedOpacity( + opacity: state.isShowDetailPane ? 1 : 0, + duration: k.animationDurationNormal, + onEnd: () { + if (!state.isShowDetailPane) { + context.addEvent(const _SetDetailPaneInactive()); + } + }, + child: Theme( + data: buildTheme(context, context.bloc.brightness), + child: Builder( + builder: (context) => Container( + alignment: Alignment.topLeft, + constraints: BoxConstraints( + minHeight: MediaQuery.of(context).size.height, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + ), + margin: EdgeInsets.only( + top: _calcDetailPaneOffset( + state.fileStates[fileId], + MediaQuery.of(context).size.height, + ), + ), + // this visibility widget avoids loading the detail pane + // until it's actually opened, otherwise swiping between + // photos will slow down severely + child: Visibility( + visible: state.isShowDetailPane, + child: _DetailPane(fileId: fileId), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _DetailPane extends StatelessWidget { + const _DetailPane({ + required this.fileId, + }); + + @override + Widget build(BuildContext context) { + return _BlocBuilder( + buildWhen: (previous, current) => + previous.files[fileId] != current.files[fileId] || + previous.collection != current.collection || + previous.collectionItems?[fileId] != current.collectionItems?[fileId], + builder: (context, state) { + final file = state.files[fileId]; + final collection = state.collection; + final collectionItem = state.collectionItems?[fileId]; + return file == null + ? const SizedBox.shrink() + : ViewerDetailPane( + account: context.bloc.account, + fd: file, + fromCollection: collection != null && collectionItem != null + ? ViewerSingleCollectionData(collection, collectionItem) + : null, + onRemoveFromCollectionPressed: (_) { + context.addEvent(_RemoveFromCollection(collectionItem!)); + }, + onArchivePressed: (_) { + context.addEvent(_Archive(fileId)); + }, + onUnarchivePressed: (_) { + context.addEvent(_Unarchive(fileId)); + }, + onSlideshowPressed: () { + context.addEvent(_StartSlideshow(fileId)); + }, + onDeletePressed: (_) { + context.addEvent(_Delete(fileId)); + }, + ); + }, + ); + } + + final int fileId; +} diff --git a/app/lib/widget/viewer/state_event.dart b/app/lib/widget/viewer/state_event.dart new file mode 100644 index 00000000..90c3bfc5 --- /dev/null +++ b/app/lib/widget/viewer/state_event.dart @@ -0,0 +1,412 @@ +part of '../viewer.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + required this.fileIdOrders, + required this.files, + required this.fileStates, + required this.index, + required this.currentFile, + this.currentFileState, + this.collection, + this.collectionItemsController, + this.collectionItems, + required this.isShowDetailPane, + required this.isClosingDetailPane, + required this.isDetailPaneActive, + required this.openDetailPaneRequest, + required this.closeDetailPane, + required this.isZoomed, + required this.isShowAppBar, + required this.isInitialLoad, + required this.pendingRemovePage, + required this.imageEditorRequest, + required this.imageEnhancerRequest, + required this.shareRequest, + required this.slideshowRequest, + this.error, + }); + + factory _State.init({ + required List fileIds, + required int index, + required FileDescriptor currentFile, + }) => + _State( + fileIdOrders: fileIds, + files: const {}, + fileStates: const {}, + index: index, + currentFile: currentFile, + isShowDetailPane: false, + isClosingDetailPane: false, + isDetailPaneActive: false, + openDetailPaneRequest: Unique(const _OpenDetailPaneRequest(false)), + closeDetailPane: Unique(false), + isZoomed: false, + isShowAppBar: true, + isInitialLoad: true, + pendingRemovePage: Unique(null), + imageEditorRequest: Unique(null), + imageEnhancerRequest: Unique(null), + shareRequest: Unique(null), + slideshowRequest: Unique(null), + ); + + @override + String toString() => _$toString(); + + bool get canOpenDetailPane => !isZoomed; + + @Format(r"$$?") + final List fileIdOrders; + final Map files; + final Map fileStates; + final int index; + final FileDescriptor? currentFile; + final _PageState? currentFileState; + final Collection? collection; + final CollectionItemsController? collectionItemsController; + final Map? collectionItems; + final bool isShowDetailPane; + final bool isClosingDetailPane; + final bool isDetailPaneActive; + final Unique<_OpenDetailPaneRequest> openDetailPaneRequest; + final Unique closeDetailPane; + final bool isZoomed; + final bool isShowAppBar; + final bool isInitialLoad; + + final Unique pendingRemovePage; + + final Unique imageEditorRequest; + final Unique imageEnhancerRequest; + final Unique<_ShareRequest?> shareRequest; + final Unique<_SlideshowRequest?> slideshowRequest; + + final ExceptionEvent? error; +} + +@genCopyWith +@toString +class _PageState { + const _PageState({ + required this.itemHeight, + required this.hasLoaded, + required this.shouldPlayLivePhoto, + }); + + factory _PageState.create() { + return const _PageState( + itemHeight: null, + hasLoaded: false, + shouldPlayLivePhoto: false, + ); + } + + @override + String toString() => _$toString(); + + final double? itemHeight; + final bool hasLoaded; + final bool shouldPlayLivePhoto; +} + +abstract class _Event {} + +@toString +class _Init implements _Event { + const _Init(); + + @override + String toString() => _$toString(); +} + +@toString +class _SetIndex implements _Event { + const _SetIndex(this.index); + + @override + String toString() => _$toString(); + + final int index; +} + +@toString +class _RequestPage implements _Event { + const _RequestPage(this.index); + + @override + String toString() => _$toString(); + + final int index; +} + +@toString +class _SetCollection implements _Event { + const _SetCollection(this.collection, this.itemsController); + + @override + String toString() => _$toString(); + + final Collection? collection; + final CollectionItemsController? itemsController; +} + +@toString +class _SetCollectionItems implements _Event { + const _SetCollectionItems(this.value); + + @override + String toString() => _$toString(); + + final List? value; +} + +@toString +class _ToggleAppBar implements _Event { + const _ToggleAppBar(); + + @override + String toString() => _$toString(); +} + +@toString +class _ShowAppBar implements _Event { + const _ShowAppBar(); + + @override + String toString() => _$toString(); +} + +@toString +class _HideAppBar implements _Event { + const _HideAppBar(); + + @override + String toString() => _$toString(); +} + +@toString +class _PauseLivePhoto implements _Event { + const _PauseLivePhoto(this.fileId); + + @override + String toString() => _$toString(); + + final int fileId; +} + +@toString +class _PlayLivePhoto implements _Event { + const _PlayLivePhoto(this.fileId); + + @override + String toString() => _$toString(); + + final int fileId; +} + +@toString +class _Unfavorite implements _Event { + const _Unfavorite(this.fileId); + + @override + String toString() => _$toString(); + + final int fileId; +} + +@toString +class _Favorite implements _Event { + const _Favorite(this.fileId); + + @override + String toString() => _$toString(); + + final int fileId; +} + +@toString +class _Unarchive implements _Event { + const _Unarchive(this.fileId); + + @override + String toString() => _$toString(); + + final int fileId; +} + +@toString +class _Archive implements _Event { + const _Archive(this.fileId); + + @override + String toString() => _$toString(); + + final int fileId; +} + +@toString +class _Share implements _Event { + const _Share(this.fileId); + + @override + String toString() => _$toString(); + + final int fileId; +} + +@toString +class _Edit implements _Event { + const _Edit(this.fileId); + + @override + String toString() => _$toString(); + + final int fileId; +} + +@toString +class _Enhance implements _Event { + const _Enhance(this.fileId); + + @override + String toString() => _$toString(); + + final int fileId; +} + +@toString +class _Download implements _Event { + const _Download(this.fileId); + + @override + String toString() => _$toString(); + + final int fileId; +} + +@toString +class _Delete implements _Event { + const _Delete(this.fileId); + + @override + String toString() => _$toString(); + + final int fileId; +} + +@toString +class _RemoveFromCollection implements _Event { + const _RemoveFromCollection(this.value); + + @override + String toString() => _$toString(); + + final CollectionItem value; +} + +@toString +class _StartSlideshow implements _Event { + const _StartSlideshow(this.fileId); + + @override + String toString() => _$toString(); + + final int fileId; +} + +@toString +class _OpenDetailPane implements _Event { + const _OpenDetailPane(this.shouldAnimate); + + @override + String toString() => _$toString(); + + final bool shouldAnimate; +} + +@toString +class _CloseDetailPane implements _Event { + const _CloseDetailPane(); + + @override + String toString() => _$toString(); +} + +@toString +class _DetailPaneClosed implements _Event { + const _DetailPaneClosed(); + + @override + String toString() => _$toString(); +} + +@toString +class _ShowDetailPane implements _Event { + const _ShowDetailPane(); + + @override + String toString() => _$toString(); +} + +@toString +class _SetDetailPaneInactive implements _Event { + const _SetDetailPaneInactive(); + + @override + String toString() => _$toString(); +} + +@toString +class _SetDetailPaneActive implements _Event { + const _SetDetailPaneActive(); + + @override + String toString() => _$toString(); +} + +@toString +class _SetFileContentHeight implements _Event { + const _SetFileContentHeight(this.fileId, this.value); + + @override + String toString() => _$toString(); + + final int fileId; + final double value; +} + +@toString +class _SetIsZoomed implements _Event { + const _SetIsZoomed(this.value); + + @override + String toString() => _$toString(); + + final bool value; +} + +@toString +class _RemovePage implements _Event { + const _RemovePage(this.value); + + @override + String toString() => _$toString(); + + final int value; +} + +@toString +class _SetError implements _Event { + const _SetError(this.error, [this.stackTrace]); + + @override + String toString() => _$toString(); + + final Object error; + final StackTrace? stackTrace; +} diff --git a/app/lib/widget/viewer/type.dart b/app/lib/widget/viewer/type.dart new file mode 100644 index 00000000..4db56e94 --- /dev/null +++ b/app/lib/widget/viewer/type.dart @@ -0,0 +1,25 @@ +part of '../viewer.dart'; + +class _OpenDetailPaneRequest { + const _OpenDetailPaneRequest(this.shouldAnimate); + + final bool shouldAnimate; +} + +class _ShareRequest { + const _ShareRequest(this.file); + + final FileDescriptor file; +} + +class _SlideshowRequest { + const _SlideshowRequest({ + required this.account, + required this.files, + required this.startIndex, + }); + + final Account account; + final List files; + final int startIndex; +} diff --git a/app/lib/widget/viewer/view.dart b/app/lib/widget/viewer/view.dart new file mode 100644 index 00000000..743f8e8e --- /dev/null +++ b/app/lib/widget/viewer/view.dart @@ -0,0 +1,372 @@ +part of '../viewer.dart'; + +class _ContentBody extends StatefulWidget { + const _ContentBody(); + + @override + State createState() => _ContentBodyState(); +} + +@npLog +class _ContentBodyState extends State<_ContentBody> { + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + _BlocListener( + listenWhen: (previous, current) => + previous.pendingRemovePage != current.pendingRemovePage, + listener: (context, state) { + final index = state.pendingRemovePage.value; + if (index == null) { + return; + } + if (state.fileIdOrders.length <= 1) { + // removing the only item, pop view + Navigator.of(context).pop(); + } else if (index == state.index) { + // removing current page + if (index >= state.fileIdOrders.length - 1) { + // removing the last item, go back + _pageViewController + .previousPage( + duration: k.animationDurationNormal, + curve: Curves.easeInOut, + ) + .then((_) { + if (mounted) { + context.addEvent(_RemovePage(index)); + } + }); + } else { + _pageViewController + .nextPage( + duration: k.animationDurationNormal, + curve: Curves.easeInOut, + ) + .then((_) { + if (mounted) { + context.addEvent(_RemovePage(index)); + } + }); + } + } else { + context.addEvent(_RemovePage(index)); + } + }, + ), + _BlocListenerT( + selector: (state) => state.index, + listener: (context, index) { + if (index != _pageViewController.currentPage) { + _log.info( + "[build] Page out sync, correcting: ${_pageViewController.currentPage} -> $index"); + _pageViewController.jumpToPage(index); + } + }, + ), + ], + child: GestureDetector( + onTap: () { + context.addEvent(const _ToggleAppBar()); + }, + child: Stack( + children: [ + const Positioned.fill( + child: ColoredBox(color: Colors.black), + ), + _BlocBuilder( + buildWhen: (previous, current) => + previous.fileIdOrders != current.fileIdOrders || + previous.isZoomed != current.isZoomed, + builder: (context, state) => HorizontalPageViewer( + key: _key, + pageCount: state.fileIdOrders.length, + pageBuilder: (context, i) => _PageView( + key: Key("FileContentView-${state.fileIdOrders[i]}"), + fileId: state.fileIdOrders[i], + pageHeight: MediaQuery.of(context).size.height, + ), + initialPage: state.index, + controller: _pageViewController, + viewportFraction: _viewportFraction, + canSwitchPage: !state.isZoomed, + onPageChanged: (from, to) { + context.addEvent(_SetIndex(to)); + }, + ), + ), + _BlocSelector( + selector: (state) => state.isShowAppBar, + builder: (context, isShowAppBar) => isShowAppBar + ? Container( + // + status bar height + height: + kToolbarHeight + MediaQuery.of(context).padding.top, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment(0, -1), + end: Alignment(0, 1), + colors: [ + Color.fromARGB(192, 0, 0, 0), + Color.fromARGB(0, 0, 0, 0), + ], + ), + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + ); + } + + // prevent view getting disposed + final _key = GlobalKey(); + final _pageViewController = HorizontalPageViewerController(); +} + +class _PageView extends StatefulWidget { + const _PageView({ + super.key, + required this.fileId, + required this.pageHeight, + }); + + @override + State createState() => _PageViewState(); + + final int fileId; + final double pageHeight; +} + +@npLog +class _PageViewState extends State<_PageView> { + @override + void initState() { + super.initState(); + _scrollController = ScrollController( + initialScrollOffset: + context.state.isShowDetailPane && !context.state.isClosingDetailPane + ? _calcDetailPaneOpenedScrollPosition( + context.state.fileStates[widget.fileId], widget.pageHeight) + : 0, + ); + if (context.state.isShowDetailPane && !context.state.isClosingDetailPane) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final pageState = context.state.fileStates[widget.fileId]; + if (mounted && pageState?.itemHeight != null) { + _hasInitDetailPane = true; + context.addEvent(const _OpenDetailPane(false)); + } + }); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _scrollController.jumpTo(0); + } + }); + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + _BlocListener( + listenWhen: (previous, current) => + previous.openDetailPaneRequest != current.openDetailPaneRequest, + listener: (context, state) { + if (!state.canOpenDetailPane) { + _log.warning("[build] Can't open detail pane right now"); + return; + } + if (state.openDetailPaneRequest.value.shouldAnimate) { + _scrollController.animateTo( + _calcDetailPaneOpenedScrollPosition( + context.state.fileStates[widget.fileId], widget.pageHeight), + duration: k.animationDurationNormal, + curve: Curves.easeOut, + ); + } else { + _scrollController.jumpTo(_calcDetailPaneOpenedScrollPosition( + context.state.fileStates[widget.fileId], widget.pageHeight)); + } + }, + ), + _BlocListenerT( + selector: (state) => state.closeDetailPane, + listener: (context, state) { + _scrollController.animateTo( + 0, + duration: k.animationDurationNormal, + curve: Curves.easeOut, + ); + }, + ), + _BlocListenerT( + selector: (state) => state.fileStates[widget.fileId]?.itemHeight, + listener: (context, itemHeight) { + if (itemHeight != null && !_hasInitDetailPane) { + if (context.state.isShowDetailPane && + !context.state.isClosingDetailPane) { + _hasInitDetailPane = true; + context.addEvent(const _OpenDetailPane(false)); + } + } + }, + ), + ], + child: _BlocSelector( + selector: (state) => state.files[widget.fileId], + builder: (context, file) { + if (file == null) { + return const Center( + child: Text("File not found"), + ); + } else { + return FractionallySizedBox( + widthFactor: 1 / _viewportFraction, + child: NotificationListener( + onNotification: _onPageContentScrolled, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith(scrollbars: false), + child: _BlocBuilder( + buildWhen: (previous, current) => + previous.isZoomed != current.isZoomed, + builder: (context, state) => SingleChildScrollView( + controller: _scrollController, + physics: !state.isZoomed + ? null + : const NeverScrollableScrollPhysics(), + child: Stack( + children: [ + _BlocBuilder( + buildWhen: (previous, current) => + previous.fileStates[widget.fileId] != + current.fileStates[widget.fileId] || + previous.isShowAppBar != current.isShowAppBar || + previous.isDetailPaneActive != + current.isDetailPaneActive, + builder: (context, state) => FileContentView( + fileId: file.fdId, + shouldPlayLivePhoto: state + .fileStates[widget.fileId] + ?.shouldPlayLivePhoto ?? + false, + canZoom: !state.isDetailPaneActive, + canPlay: !state.isDetailPaneActive, + isPlayControlVisible: state.isShowAppBar && + !state.isDetailPaneActive, + onContentHeightChanged: (contentHeight) { + context.addEvent(_SetFileContentHeight( + widget.fileId, contentHeight)); + }, + onZoomChanged: (isZoomed) { + context.addEvent(_SetIsZoomed(isZoomed)); + }, + onVideoPlayingChanged: (isPlaying) { + if (isPlaying) { + context.addEvent(const _HideAppBar()); + } else { + context.addEvent(const _ShowAppBar()); + } + }, + onLivePhotoLoadFailue: () { + context + .addEvent(_PauseLivePhoto(widget.fileId)); + }, + ), + ), + _DetailPaneContainer(fileId: widget.fileId), + ], + ), + ), + ), + ), + ), + ); + } + }, + ), + ); + } + + bool _onPageContentScrolled(ScrollNotification notification) { + if (!context.state.canOpenDetailPane) { + return false; + } + if (notification is ScrollStartNotification) { + _scrollStartPosition = _scrollController.position.pixels; + } + if (notification is ScrollEndNotification) { + _scrollStartPosition = null; + final scrollPos = _scrollController.position; + if (scrollPos.pixels == 0) { + context.addEvent(const _DetailPaneClosed()); + } else if (scrollPos.pixels < + _calcDetailPaneOpenedScrollPosition( + context.state.fileStates[widget.fileId], widget.pageHeight) - + 1) { + if (scrollPos.userScrollDirection == ScrollDirection.reverse) { + // upward, open the pane to its minimal size + context.addEvent(const _OpenDetailPane(true)); + } else if (scrollPos.userScrollDirection == ScrollDirection.forward) { + // downward, close the pane + context.addEvent(const _CloseDetailPane()); + } + } + } else if (notification is ScrollUpdateNotification) { + if (!context.state.isShowDetailPane) { + context.addEvent(const _ShowDetailPane()); + } + } + + if (notification is OverscrollNotification) { + if (_scrollStartPosition == 0) { + // start at top + _overscrollSum += notification.overscroll; + if (_overscrollSum < -144) { + // and scroll downwards + Navigator.of(context).pop(); + } + } + } else { + _overscrollSum = 0; + } + return false; + } + + late final ScrollController _scrollController; + + double? _scrollStartPosition; + var _overscrollSum = 0.0; + var _hasInitDetailPane = false; +} + +double _calcDetailPaneOpenedScrollPosition( + _PageState? pageState, double pageHeight) { + // distance of the detail pane from the top edge + const distanceFromTop = 196; + return max(_calcDetailPaneOffset(pageState, pageHeight) - distanceFromTop, 0); +} + +double _calcDetailPaneOffset(_PageState? pageState, double pageHeight) { + if (pageState?.itemHeight == null) { + return pageHeight; + } else { + return pageState!.itemHeight! + + (pageHeight - pageState.itemHeight!) / 2 - + 4; + } +} + +const _viewportFraction = 1.05; diff --git a/app/lib/widget/viewer_bottom_app_bar.dart b/app/lib/widget/viewer_bottom_app_bar.dart deleted file mode 100644 index 16a6f4cd..00000000 --- a/app/lib/widget/viewer_bottom_app_bar.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Button bar near the bottom of viewer -/// -/// Buttons are spread evenly across the horizontal axis -class ViewerBottomAppBar extends StatelessWidget { - const ViewerBottomAppBar({ - super.key, - required this.children, - }); - - @override - build(BuildContext context) { - return Container( - height: kToolbarHeight, - alignment: Alignment.center, - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment(0, -1), - end: Alignment(0, 1), - colors: [ - Color.fromARGB(0, 0, 0, 0), - Color.fromARGB(192, 0, 0, 0), - ], - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: children - .map((e) => Expanded( - flex: 1, - child: e, - )) - .toList(), - ), - ); - } - - final List children; -} diff --git a/app/lib/widget/zoomable_viewer.dart b/app/lib/widget/zoomable_viewer.dart index c52bf2ad..b0a293f6 100644 --- a/app/lib/widget/zoomable_viewer.dart +++ b/app/lib/widget/zoomable_viewer.dart @@ -87,7 +87,9 @@ class _ZoomableViewerState extends State } void _setIsZooming(bool flag) { - _isZooming = flag; + setState(() { + _isZooming = flag; + }); final next = _isZoomed; if (next != _wasZoomed) { _wasZoomed = next; diff --git a/np_collection/lib/src/list_extension.dart b/np_collection/lib/src/list_extension.dart index 179f87ea..addc165f 100644 --- a/np_collection/lib/src/list_extension.dart +++ b/np_collection/lib/src/list_extension.dart @@ -86,4 +86,6 @@ extension ListExtension on List { List added(T value) => toList()..add(value); List removed(T value) => toList()..remove(value); + + List removedAt(int index) => toList()..removeAt(index); }