diff --git a/app/lib/bloc/scan_local_dir.dart b/app/lib/bloc/scan_local_dir.dart new file mode 100644 index 00000000..8d092b22 --- /dev/null +++ b/app/lib/bloc/scan_local_dir.dart @@ -0,0 +1,131 @@ +import 'package:bloc/bloc.dart'; +import 'package:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/local_file.dart'; +import 'package:nc_photos/event/event.dart'; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/use_case/scan_local_dir.dart'; + +abstract class ScanLocalDirBlocEvent { + const ScanLocalDirBlocEvent(); +} + +class ScanLocalDirBlocQuery extends ScanLocalDirBlocEvent { + const ScanLocalDirBlocQuery(this.relativePaths); + + @override + toString() => "$runtimeType {" + "relativePaths: ${relativePaths.toReadableString()}, " + "}"; + + final List relativePaths; +} + +class _ScanLocalDirBlocFileDeleted extends ScanLocalDirBlocEvent { + const _ScanLocalDirBlocFileDeleted(this.files); + + @override + toString() => "$runtimeType {" + "files: ${files.map((f) => f.logTag).toReadableString()}, " + "}"; + + final List files; +} + +abstract class ScanLocalDirBlocState { + const ScanLocalDirBlocState(this.files); + + @override + toString() => "$runtimeType {" + "files: List {length: ${files.length}}, " + "}"; + + final List files; +} + +class ScanLocalDirBlocInit extends ScanLocalDirBlocState { + const ScanLocalDirBlocInit() : super(const []); +} + +class ScanLocalDirBlocLoading extends ScanLocalDirBlocState { + const ScanLocalDirBlocLoading(List files) : super(files); +} + +class ScanLocalDirBlocSuccess extends ScanLocalDirBlocState { + const ScanLocalDirBlocSuccess(List files) : super(files); +} + +class ScanLocalDirBlocFailure extends ScanLocalDirBlocState { + const ScanLocalDirBlocFailure(List files, this.exception) + : super(files); + + @override + toString() => "$runtimeType {" + "super: ${super.toString()}, " + "exception: $exception, " + "}"; + + final dynamic exception; +} + +class ScanLocalDirBloc + extends Bloc { + ScanLocalDirBloc() : super(const ScanLocalDirBlocInit()) { + on(_onScanLocalDirBlocQuery); + on<_ScanLocalDirBlocFileDeleted>(_onScanLocalDirBlocFileDeleted); + + _fileDeletedEventListener.begin(); + } + + @override + close() { + _fileDeletedEventListener.end(); + return super.close(); + } + + Future _onScanLocalDirBlocQuery( + ScanLocalDirBlocQuery event, Emitter emit) async { + final shouldEmitIntermediate = state.files.isEmpty; + try { + emit(ScanLocalDirBlocLoading(state.files)); + final c = KiwiContainer().resolve(); + final products = []; + for (final p in event.relativePaths) { + if (shouldEmitIntermediate) { + emit(ScanLocalDirBlocLoading(products)); + } + final files = await ScanLocalDir(c)(p); + products.addAll(files); + } + emit(ScanLocalDirBlocSuccess(products)); + } catch (e, stackTrace) { + _log.severe( + "[_onScanLocalDirBlocQuery] Exception while request", e, stackTrace); + emit(ScanLocalDirBlocFailure(state.files, e)); + } + } + + Future _onScanLocalDirBlocFileDeleted( + _ScanLocalDirBlocFileDeleted event, + Emitter emit) async { + final newFiles = state.files + .where((f) => !event.files.any((d) => d.compareIdentity(f))) + .toList(); + if (newFiles.length != state.files.length) { + emit(ScanLocalDirBlocSuccess(newFiles)); + } + } + + void _onFileDeletedEvent(LocalFileDeletedEvent ev) { + if (state is ScanLocalDirBlocInit) { + return; + } + add(_ScanLocalDirBlocFileDeleted(ev.files)); + } + + late final _fileDeletedEventListener = + AppEventListener(_onFileDeletedEvent); + + static final _log = Logger("bloc.scan_local_dir.ScanLocalDirBloc"); +} diff --git a/app/lib/entity/file_util.dart b/app/lib/entity/file_util.dart index 4b73aa39..5fe11c95 100644 --- a/app/lib/entity/file_util.dart +++ b/app/lib/entity/file_util.dart @@ -11,8 +11,11 @@ bool isSupportedMime(String mime) => _supportedFormatMimes.contains(mime); bool isSupportedFormat(File file) => isSupportedMime(file.contentType ?? ""); +bool isSupportedImageMime(String mime) => + isSupportedMime(mime) && mime.startsWith("image/") == true; + bool isSupportedImageFormat(File file) => - isSupportedFormat(file) && file.contentType?.startsWith("image/") == true; + isSupportedImageMime(file.contentType ?? ""); bool isSupportedVideoFormat(File file) => isSupportedFormat(file) && file.contentType?.startsWith("video/") == true; diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index dea73037..283fc0ec 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1179,6 +1179,14 @@ "@enhanceLowLightTitle": { "description": "Enhance a photo taken in low-light environment" }, + "collectionEnhancedPhotosLabel": "Enhanced photos", + "@collectionEnhancedPhotosLabel": { + "description": "List photos enhanced by the app" + }, + "deletePermanentlyLocalConfirmationDialogContent": "Selected items will be deleted permanently from this device.\n\nThis action is nonreversible", + "@deletePermanentlyLocalConfirmationDialogContent": { + "description": "Make sure the user wants to delete the items from the current device" + }, "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index 4f52f171..f28e5354 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -86,6 +86,8 @@ "metadataTaskPauseLowBatteryNotification", "enhanceTooltip", "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent", "errorAlbumDowngrade" ], @@ -190,6 +192,8 @@ "metadataTaskPauseLowBatteryNotification", "enhanceTooltip", "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent", "errorAlbumDowngrade" ], @@ -349,6 +353,8 @@ "metadataTaskPauseLowBatteryNotification", "enhanceTooltip", "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent", "errorAlbumDowngrade" ], @@ -358,12 +364,16 @@ "backgroundServiceStopping", "metadataTaskPauseLowBatteryNotification", "enhanceTooltip", - "enhanceLowLightTitle" + "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent" ], "fi": [ "enhanceTooltip", - "enhanceLowLightTitle" + "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent" ], "fr": [ @@ -372,7 +382,9 @@ "helpButtonLabel", "removeFromAlbumTooltip", "enhanceTooltip", - "enhanceLowLightTitle" + "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent" ], "pl": [ @@ -398,16 +410,22 @@ "backgroundServiceStopping", "metadataTaskPauseLowBatteryNotification", "enhanceTooltip", - "enhanceLowLightTitle" + "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent" ], "pt": [ "enhanceTooltip", - "enhanceLowLightTitle" + "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent" ], "ru": [ "enhanceTooltip", - "enhanceLowLightTitle" + "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent" ] } diff --git a/app/lib/widget/enhanced_photo_browser.dart b/app/lib/widget/enhanced_photo_browser.dart new file mode 100644 index 00000000..8db7eb16 --- /dev/null +++ b/app/lib/widget/enhanced_photo_browser.dart @@ -0,0 +1,369 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/bloc/scan_local_dir.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/entity/local_file.dart'; +import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/mobile/android/content_uri_image_provider.dart'; +import 'package:nc_photos/pref.dart'; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/widget/empty_list_indicator.dart'; +import 'package:nc_photos/widget/handler/delete_local_selection_handler.dart'; +import 'package:nc_photos/widget/local_file_viewer.dart'; +import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util; +import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; +import 'package:nc_photos/widget/selection_app_bar.dart'; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; + +class EnhancedPhotoBrowserArguments { + const EnhancedPhotoBrowserArguments(this.filename); + + final String? filename; +} + +class EnhancedPhotoBrowser extends StatefulWidget { + static const routeName = "/enhanced-photo-browser"; + + static Route buildRoute(EnhancedPhotoBrowserArguments args) => + MaterialPageRoute( + builder: (context) => EnhancedPhotoBrowser.fromArgs(args), + ); + + const EnhancedPhotoBrowser({ + Key? key, + required this.filename, + }) : super(key: key); + + EnhancedPhotoBrowser.fromArgs(EnhancedPhotoBrowserArguments args, {Key? key}) + : this( + key: key, + filename: args.filename, + ); + + @override + createState() => _EnhancedPhotoBrowserState(); + + final String? filename; +} + +class _EnhancedPhotoBrowserState extends State + with SelectableItemStreamListMixin { + @override + initState() { + super.initState(); + _initBloc(); + _thumbZoomLevel = Pref().getAlbumBrowserZoomLevelOr(0); + } + + @override + build(BuildContext context) { + return AppTheme( + child: Scaffold( + body: BlocListener( + bloc: _bloc, + listener: (context, state) => _onStateChange(context, state), + child: BlocBuilder( + bloc: _bloc, + builder: (context, state) => _buildContent(context, state), + ), + ), + ), + ); + } + + void _initBloc() { + if (_bloc.state is ScanLocalDirBlocInit) { + _log.info("[_initBloc] Initialize bloc"); + _reqQuery(); + } else { + // process the current state + WidgetsBinding.instance!.addPostFrameCallback((_) { + setState(() { + _onStateChange(context, _bloc.state); + }); + _reqQuery(); + }); + } + } + + Widget _buildContent(BuildContext context, ScanLocalDirBlocState state) { + if (state is ScanLocalDirBlocSuccess && itemStreamListItems.isEmpty) { + return Column( + children: [ + AppBar( + title: Text(L10n.global().collectionEnhancedPhotosLabel), + elevation: 0, + ), + Expanded( + child: EmptyListIndicator( + icon: Icons.folder_outlined, + text: L10n.global().listEmptyText, + ), + ), + ], + ); + } else { + return Stack( + children: [ + buildItemStreamListOuter( + context, + child: Theme( + data: Theme.of(context).copyWith( + colorScheme: Theme.of(context).colorScheme.copyWith( + secondary: AppTheme.getOverscrollIndicatorColor(context), + ), + ), + child: CustomScrollView( + slivers: [ + _buildAppBar(context), + buildItemStreamList( + maxCrossAxisExtent: _thumbSize.toDouble(), + ), + ], + ), + ), + ), + if (state is ScanLocalDirBlocLoading) + const Align( + alignment: Alignment.bottomCenter, + child: LinearProgressIndicator(), + ), + ], + ); + } + } + + Widget _buildAppBar(BuildContext context) { + if (isSelectionMode) { + return _buildSelectionAppBar(context); + } else { + return _buildNormalAppBar(context); + } + } + + Widget _buildNormalAppBar(BuildContext context) => SliverAppBar( + title: Text(L10n.global().collectionEnhancedPhotosLabel), + ); + + Widget _buildSelectionAppBar(BuildContext context) { + return SelectionAppBar( + count: selectedListItems.length, + onClosePressed: () { + setState(() { + clearSelectedItems(); + }); + }, + actions: [ + PopupMenuButton<_SelectionMenuOption>( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + itemBuilder: (context) => [ + PopupMenuItem( + value: _SelectionMenuOption.delete, + child: Text(L10n.global().deletePermanentlyTooltip), + ), + ], + onSelected: (option) => _onSelectionMenuSelected(context, option), + ), + ], + ); + } + + void _onStateChange(BuildContext context, ScanLocalDirBlocState state) { + if (state is ScanLocalDirBlocInit) { + itemStreamListItems = []; + } else if (state is ScanLocalDirBlocLoading) { + _transformItems(state.files); + } else if (state is ScanLocalDirBlocSuccess) { + _transformItems(state.files); + if (_isFirstRun) { + _isFirstRun = false; + if (widget.filename != null) { + _openInitialImage(widget.filename!); + } + } + } else if (state is ScanLocalDirBlocFailure) { + _transformItems(state.files); + SnackBarManager().showSnackBar(SnackBar( + content: Text(state.exception is PermissionException + ? L10n.global().errorNoStoragePermission + : exception_util.toUserString(state.exception)), + duration: k.snackBarDurationNormal, + )); + } + } + + void _onSelectionMenuSelected( + BuildContext context, _SelectionMenuOption option) { + switch (option) { + case _SelectionMenuOption.delete: + _onSelectionDeletePressed(context); + break; + default: + _log.shout("[_onSelectionMenuSelected] Unknown option: $option"); + break; + } + } + + Future _onSelectionDeletePressed(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(L10n.global().deletePermanentlyConfirmationDialogTitle), + content: Text( + L10n.global().deletePermanentlyLocalConfirmationDialogContent, + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: Text(L10n.global().confirmButtonLabel), + ), + ], + ), + ); + if (result != true) { + return; + } + + final selectedFiles = selectedListItems + .whereType<_FileListItem>() + .map((e) => e.file) + .toList(); + setState(() { + clearSelectedItems(); + }); + await const DeleteLocalSelectionHandler()(selectedFiles: selectedFiles); + } + + void _onItemTap(int index) { + Navigator.pushNamed(context, LocalFileViewer.routeName, + arguments: LocalFileViewerArguments(_backingFiles, index)); + } + + void _transformItems(List files) { + // we use last modified here to keep newly enhanced photo at the top + _backingFiles = + files.stableSorted((a, b) => b.lastModified.compareTo(a.lastModified)); + + itemStreamListItems = () sync* { + for (int i = 0; i < _backingFiles.length; ++i) { + final f = _backingFiles[i]; + if (file_util.isSupportedImageMime(f.mime ?? "")) { + yield _ImageListItem( + file: f, + onTap: () => _onItemTap(i), + ); + } + } + }() + .toList(); + _log.info("[_transformItems] Length: ${itemStreamListItems.length}"); + } + + void _openInitialImage(String filename) { + final index = _backingFiles.indexWhere((f) => f.filename == filename); + if (index == -1) { + _log.severe("[openInitialImage] Filename not found: $filename"); + return; + } + Navigator.pushNamed(context, LocalFileViewer.routeName, + arguments: LocalFileViewerArguments(_backingFiles, index)); + } + + void _reqQuery() { + _bloc.add(const ScanLocalDirBlocQuery( + ["Download/Photos (for Nextcloud)/Enhanced Photos"])); + } + + final _bloc = ScanLocalDirBloc(); + + var _backingFiles = []; + + var _isFirstRun = true; + var _thumbZoomLevel = 0; + int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); + + static final _log = + Logger("widget.enhanced_photo_browser._EnhancedPhotoBrowserState"); +} + +abstract class _ListItem implements SelectableItem { + _ListItem({ + VoidCallback? onTap, + }) : _onTap = onTap; + + @override + get onTap => _onTap; + + @override + get isSelectable => true; + + @override + get staggeredTile => const StaggeredTile.count(1, 1); + + final VoidCallback? _onTap; +} + +abstract class _FileListItem extends _ListItem { + _FileListItem({ + required this.file, + VoidCallback? onTap, + }) : super(onTap: onTap); + + final LocalFile file; +} + +class _ImageListItem extends _FileListItem { + _ImageListItem({ + required LocalFile file, + VoidCallback? onTap, + }) : super(file: file, onTap: onTap); + + @override + buildWidget(BuildContext context) { + final ImageProvider provider; + if (file is LocalUriFile) { + provider = ContentUriImage((file as LocalUriFile).uri); + } else { + throw ArgumentError("Invalid file"); + } + + return Padding( + padding: const EdgeInsets.all(2), + child: FittedBox( + clipBehavior: Clip.hardEdge, + fit: BoxFit.cover, + child: Container( + // arbitrary size here + constraints: BoxConstraints.tight(const Size(128, 128)), + color: AppTheme.getListItemBackgroundColor(context), + child: Image( + image: ResizeImage.resizeIfNeeded(k.photoThumbSize, null, provider), + filterQuality: FilterQuality.high, + fit: BoxFit.cover, + errorBuilder: (context, e, stackTrace) { + return Center( + child: Icon( + Icons.image_not_supported, + size: 64, + color: Colors.white.withOpacity(.8), + ), + ); + }, + ), + ), + ), + ); + } +} + +enum _SelectionMenuOption { + delete, +} diff --git a/app/lib/widget/handler/delete_local_selection_handler.dart b/app/lib/widget/handler/delete_local_selection_handler.dart new file mode 100644 index 00000000..8b253e64 --- /dev/null +++ b/app/lib/widget/handler/delete_local_selection_handler.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/debug_util.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/local_file.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/use_case/delete_local.dart'; + +class DeleteLocalSelectionHandler { + const DeleteLocalSelectionHandler(); + + /// Delete [selectedFiles] permanently from device + Future call({ + required List selectedFiles, + bool isRemoveOpened = false, + }) async { + final c = KiwiContainer().resolve(); + var failureCount = 0; + await DeleteLocal(c)( + selectedFiles, + onFailure: (file, e, stackTrace) { + if (e != null) { + _log.shout( + "[call] Failed while deleting file: ${logFilename(file.logTag)}", + e, + stackTrace); + } + ++failureCount; + }, + ); + if (failureCount == 0) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().deleteSelectedSuccessNotification), + duration: k.snackBarDurationNormal, + )); + } else { + SnackBarManager().showSnackBar(SnackBar( + content: + Text(L10n.global().deleteSelectedFailureNotification(failureCount)), + duration: k.snackBarDurationNormal, + )); + } + return selectedFiles.length - failureCount; + } + + static final _log = Logger( + "widget.handler.delete_local_selection_handler.DeleteLocalSelectionHandler"); +} diff --git a/app/lib/widget/home_albums.dart b/app/lib/widget/home_albums.dart index bb03da8a..804130be 100644 --- a/app/lib/widget/home_albums.dart +++ b/app/lib/widget/home_albums.dart @@ -27,6 +27,7 @@ import 'package:nc_photos/widget/album_search_delegate.dart'; import 'package:nc_photos/widget/archive_browser.dart'; import 'package:nc_photos/widget/builder/album_grid_item_builder.dart'; import 'package:nc_photos/widget/dynamic_album_browser.dart'; +import 'package:nc_photos/widget/enhanced_photo_browser.dart'; import 'package:nc_photos/widget/fancy_option_picker.dart'; import 'package:nc_photos/widget/favorite_browser.dart'; import 'package:nc_photos/widget/home_app_bar.dart'; @@ -37,6 +38,7 @@ import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; import 'package:nc_photos/widget/selection_app_bar.dart'; import 'package:nc_photos/widget/sharing_browser.dart'; import 'package:nc_photos/widget/trashbin_browser.dart'; +import 'package:nc_photos/platform/features.dart' as features; class HomeAlbums extends StatefulWidget { const HomeAlbums({ @@ -285,6 +287,19 @@ class _HomeAlbumsState extends State ); } + SelectableItem _buildEnhancedPhotosItem(BuildContext context) { + return _ButtonListItem( + icon: Icons.auto_fix_high_outlined, + label: L10n.global().collectionEnhancedPhotosLabel, + onTap: () { + if (!isSelectionMode) { + Navigator.of(context).pushNamed(EnhancedPhotoBrowser.routeName, + arguments: const EnhancedPhotoBrowserArguments(null)); + } + }, + ); + } + SelectableItem _buildNewAlbumItem(BuildContext context) { return _ButtonListItem( icon: Icons.add, @@ -483,6 +498,7 @@ class _HomeAlbumsState extends State if (AccountPref.of(widget.account).isEnableFaceRecognitionAppOr()) _buildPersonItem(context), _buildSharingItem(context), + if (features.isSupportEnhancement) _buildEnhancedPhotosItem(context), _buildArchiveItem(context), _buildTrashbinItem(context), _buildNewAlbumItem(context), diff --git a/app/lib/widget/local_file_viewer.dart b/app/lib/widget/local_file_viewer.dart new file mode 100644 index 00000000..8102525c --- /dev/null +++ b/app/lib/widget/local_file_viewer.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/entity/local_file.dart'; +import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/widget/handler/delete_local_selection_handler.dart'; +import 'package:nc_photos/widget/horizontal_page_viewer.dart'; +import 'package:nc_photos/widget/image_viewer.dart'; + +class LocalFileViewerArguments { + LocalFileViewerArguments(this.streamFiles, this.startIndex); + + final List streamFiles; + final int startIndex; +} + +class LocalFileViewer extends StatefulWidget { + static const routeName = "/local-file-viewer"; + + static Route buildRoute(LocalFileViewerArguments args) => MaterialPageRoute( + builder: (context) => LocalFileViewer.fromArgs(args), + ); + + const LocalFileViewer({ + Key? key, + required this.streamFiles, + required this.startIndex, + }) : super(key: key); + + LocalFileViewer.fromArgs(LocalFileViewerArguments args, {Key? key}) + : this( + key: key, + streamFiles: args.streamFiles, + startIndex: args.startIndex, + ); + + @override + createState() => _LocalFileViewerState(); + + final List streamFiles; + final int startIndex; +} + +class _LocalFileViewerState extends State { + @override + build(BuildContext context) { + return AppTheme( + child: Scaffold( + body: Builder( + builder: _buildContent, + ), + ), + ); + } + + Widget _buildContent(BuildContext context) { + return GestureDetector( + onTap: () { + setState(() { + _isShowVideoControl = !_isShowVideoControl; + }); + }, + child: Stack( + children: [ + Container(color: Colors.black), + if (!_isViewerLoaded || + !_pageStates[_viewerController.currentPage]!.hasLoaded) + const Align( + alignment: Alignment.center, + child: CircularProgressIndicator(), + ), + HorizontalPageViewer( + pageCount: widget.streamFiles.length, + pageBuilder: _buildPage, + initialPage: widget.startIndex, + controller: _viewerController, + viewportFraction: _viewportFraction, + canSwitchPage: _canSwitchPage, + ), + _buildAppBar(context), + ], + ), + ); + } + + Widget _buildAppBar(BuildContext context) { + return Wrap( + children: [ + Stack( + children: [ + 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), + ], + ), + ), + ), + AppBar( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + foregroundColor: Colors.white.withOpacity(.87), + actions: [ + PopupMenuButton<_AppBarMenuOption>( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + itemBuilder: (context) => [ + PopupMenuItem( + value: _AppBarMenuOption.delete, + child: Text(L10n.global().deletePermanentlyTooltip), + ), + ], + onSelected: (option) => _onMenuSelected(context, option), + ), + ], + ), + ], + ), + ], + ); + } + + void _onMenuSelected(BuildContext context, _AppBarMenuOption option) { + switch (option) { + case _AppBarMenuOption.delete: + _onDeletePressed(context); + break; + default: + _log.shout("[_onMenuSelected] Unknown option: $option"); + break; + } + } + + Future _onDeletePressed(BuildContext context) async { + final file = widget.streamFiles[_viewerController.currentPage]; + _log.info("[_onDeletePressed] Deleting file: ${file.logTag}"); + final count = await const DeleteLocalSelectionHandler()( + selectedFiles: [file], + isRemoveOpened: true, + ); + if (count > 0) { + Navigator.of(context).pop(); + } + } + + Widget _buildPage(BuildContext context, int index) { + if (_pageStates[index] == null) { + _pageStates[index] = _PageState(); + } + return FractionallySizedBox( + widthFactor: 1 / _viewportFraction, + child: _buildItemView(context, index), + ); + } + + Widget _buildItemView(BuildContext context, int index) { + final file = widget.streamFiles[index]; + if (file_util.isSupportedImageMime(file.mime ?? "")) { + return _buildImageView(context, index); + } else { + _log.shout("[_buildItemView] Unknown file format: ${file.mime}"); + return Container(); + } + } + + Widget _buildImageView(BuildContext context, int index) => LocalImageViewer( + file: widget.streamFiles[index], + canZoom: true, + onLoaded: () => _onImageLoaded(index), + onZoomStarted: () { + setState(() { + _isZoomed = true; + }); + }, + onZoomEnded: () { + setState(() { + _isZoomed = false; + }); + }, + ); + + void _onImageLoaded(int index) { + if (_viewerController.currentPage == index && + !_pageStates[index]!.hasLoaded) { + setState(() { + _pageStates[index]!.hasLoaded = true; + _isViewerLoaded = true; + }); + } + } + + bool get _canSwitchPage => !_isZoomed; + + var _isShowVideoControl = true; + var _isZoomed = false; + + final _viewerController = HorizontalPageViewerController(); + bool _isViewerLoaded = false; + final _pageStates = {}; + + static final _log = Logger("widget.local_file_viewer._LocalFileViewerState"); + + static const _viewportFraction = 1.05; +} + +class _PageState { + bool hasLoaded = false; +} + +enum _AppBarMenuOption { + delete, +} diff --git a/app/lib/widget/my_app.dart b/app/lib/widget/my_app.dart index db3269f6..42176e6e 100644 --- a/app/lib/widget/my_app.dart +++ b/app/lib/widget/my_app.dart @@ -16,8 +16,10 @@ import 'package:nc_photos/widget/album_share_outlier_browser.dart'; import 'package:nc_photos/widget/archive_browser.dart'; import 'package:nc_photos/widget/connect.dart'; import 'package:nc_photos/widget/dynamic_album_browser.dart'; +import 'package:nc_photos/widget/enhanced_photo_browser.dart'; import 'package:nc_photos/widget/favorite_browser.dart'; import 'package:nc_photos/widget/home.dart'; +import 'package:nc_photos/widget/local_file_viewer.dart'; import 'package:nc_photos/widget/people_browser.dart'; import 'package:nc_photos/widget/person_browser.dart'; import 'package:nc_photos/widget/root_picker.dart'; @@ -168,6 +170,8 @@ class _MyAppState extends State route ??= _handleAlbumPickerRoute(settings); route ??= _handleSmartAlbumBrowserRoute(settings); route ??= _handleFavoriteBrowserRoute(settings); + route ??= _handleEnhancedPhotoBrowserRoute(settings); + route ??= _handleLocalFileViewerRoute(settings); return route; } @@ -498,6 +502,34 @@ class _MyAppState extends State return null; } + Route? _handleEnhancedPhotoBrowserRoute(RouteSettings settings) { + try { + if (settings.name == EnhancedPhotoBrowser.routeName && + settings.arguments != null) { + final args = settings.arguments as EnhancedPhotoBrowserArguments; + return EnhancedPhotoBrowser.buildRoute(args); + } + } catch (e) { + _log.severe( + "[_handleEnhancedPhotoBrowserRoute] Failed while handling route", e); + } + return null; + } + + Route? _handleLocalFileViewerRoute(RouteSettings settings) { + try { + if (settings.name == LocalFileViewer.routeName && + settings.arguments != null) { + final args = settings.arguments as LocalFileViewerArguments; + return LocalFileViewer.buildRoute(args); + } + } catch (e) { + _log.severe( + "[_handleLocalFileViewerRoute] Failed while handling route", e); + } + return null; + } + final _scaffoldMessengerKey = GlobalKey(); final _navigatorKey = GlobalKey();