import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/app_init.dart' as app_init; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc/scan_local_dir.dart'; import 'package:nc_photos/compute_queue.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/android_info.dart'; import 'package:nc_photos/mobile/android/permission_util.dart'; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/pref.dart'; 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/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_item.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(); _thumbZoomLevel = Pref().getAlbumBrowserZoomLevelOr(0); _ensurePermission().then((value) { if (value) { _initBloc(); } else { if (mounted) { setState(() { _isNoPermission = true; }); } } }); } @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), ), ), ), ); } @override onItemTap(SelectableItem item, int index) { item.as()?.run((fileItem) { Navigator.pushNamed( context, LocalFileViewer.routeName, arguments: LocalFileViewerArguments(_backingFiles, fileItem.fileIndex), ); }); } 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 (_isNoPermission) { return Column( children: [ AppBar( title: Text(L10n.global().collectionEnhancedPhotosLabel), elevation: 0, ), Expanded( child: EmptyListIndicator( icon: Icons.folder_off_outlined, text: L10n.global().errorNoStoragePermission, ), ), ], ); } else if (state is ScanLocalDirBlocSuccess && !_buildItemQueue.isProcessing && 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 || _buildItemQueue.isProcessing) 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: [ IconButton( icon: const Icon(Icons.share), tooltip: L10n.global().shareTooltip, onPressed: () { _onSelectionSharePressed(context); }, ), 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, )); } } Future _onSelectionSharePressed(BuildContext context) async { final selected = selectedListItems .whereType() .map((e) => e.file) .toList(); await ShareHandler( context: context, clearSelection: () { setState(() { clearSelectedItems(); }); }, ).shareLocalFiles(selected); } 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() .map((e) => e.file) .toList(); setState(() { clearSelectedItems(); }); await const DeleteLocalSelectionHandler()(selectedFiles: selectedFiles); } void _transformItems(List files) { _buildItemQueue.addJob( _BuilderArguments(files), _buildPhotoListItem, (result) { setState(() { _backingFiles = result.backingFiles; itemStreamListItems = result.listItems; }); }, ); } 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)); } Future _ensurePermission() async { if (platform_k.isAndroid) { if (AndroidInfo().sdkInt >= AndroidVersion.R) { if (!await Permission.hasReadExternalStorage()) { final results = await requestPermissionsForResult([ Permission.READ_EXTERNAL_STORAGE, ]); return results[Permission.READ_EXTERNAL_STORAGE] == PermissionRequestResult.granted; } } else { if (!await Permission.hasWriteExternalStorage()) { final results = await requestPermissionsForResult([ Permission.WRITE_EXTERNAL_STORAGE, ]); return results[Permission.WRITE_EXTERNAL_STORAGE] == PermissionRequestResult.granted; } } } return true; } void _reqQuery() { _bloc.add(const ScanLocalDirBlocQuery([ "Download/Photos (for Nextcloud)/Enhanced Photos", "Download/Photos (for Nextcloud)/Edited Photos", ])); } final _bloc = ScanLocalDirBloc(); var _backingFiles = []; final _buildItemQueue = ComputeQueue<_BuilderArguments, _BuilderResult>(); var _isFirstRun = true; var _thumbZoomLevel = 0; int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); var _isNoPermission = false; static final _log = Logger("widget.enhanced_photo_browser._EnhancedPhotoBrowserState"); } enum _SelectionMenuOption { delete, } class _BuilderResult { const _BuilderResult(this.backingFiles, this.listItems); final List backingFiles; final List listItems; } class _BuilderArguments { const _BuilderArguments(this.files); final List files; } class _Builder { _BuilderResult call(List files) { final s = Stopwatch()..start(); try { return _fromSortedItems(_sortItems(files)); } finally { _log.info("[call] Elapsed time: ${s.elapsedMilliseconds}ms"); } } List _sortItems(List files) { // we use last modified here to keep newly enhanced photo at the top return files .stableSorted((a, b) => b.lastModified.compareTo(a.lastModified)); } _BuilderResult _fromSortedItems(List files) { final listItems = []; for (int i = 0; i < files.length; ++i) { final f = files[i]; final item = _buildListItem(i, f); if (item != null) { listItems.add(item); } } return _BuilderResult(files, listItems); } SelectableItem? _buildListItem(int i, LocalFile file) { if (file_util.isSupportedImageMime(file.mime ?? "")) { return PhotoListLocalImageItem( fileIndex: i, file: file, ); } else { _log.shout("[_buildListItem] Unsupported file format: ${file.mime}"); return null; } } static final _log = Logger("widget.enhanced_photo_browser._Builder"); } _BuilderResult _buildPhotoListItem(_BuilderArguments arg) { app_init.initLog(); return _Builder()(arg.files); }