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:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc/list_favorite.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/download_handler.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; 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/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/add_selection_to_album_handler.dart'; import 'package:nc_photos/widget/handler/archive_selection_handler.dart'; import 'package:nc_photos/widget/handler/remove_selection_handler.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/widget/viewer.dart'; import 'package:nc_photos/widget/zoom_menu_button.dart'; class FavoriteBrowserArguments { FavoriteBrowserArguments(this.account); final Account account; } class FavoriteBrowser extends StatefulWidget { static const routeName = "/favorite-browser"; static Route buildRoute(FavoriteBrowserArguments args) => MaterialPageRoute( builder: (context) => FavoriteBrowser.fromArgs(args), ); const FavoriteBrowser({ Key? key, required this.account, }) : super(key: key); FavoriteBrowser.fromArgs(FavoriteBrowserArguments args, {Key? key}) : this( key: key, account: args.account, ); @override createState() => _FavoriteBrowserState(); final Account account; } class _FavoriteBrowserState 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 ListFavoriteBlocInit) { _log.info("[_initBloc] Initialize bloc"); _reqQuery(); } else { // process the current state WidgetsBinding.instance!.addPostFrameCallback((_) { setState(() { _onStateChange(context, _bloc.state); }); }); } } Widget _buildContent(BuildContext context, ListFavoriteBlocState state) { if (state is ListFavoriteBlocSuccess && itemStreamListItems.isEmpty) { return Column( children: [ AppBar( title: Text(L10n.global().collectionFavoritesLabel), elevation: 0, ), Expanded( child: EmptyListIndicator( icon: Icons.star_border, 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: RefreshIndicator( backgroundColor: Colors.grey[100], onRefresh: () async { _onRefreshSelected(); await _waitRefresh(); }, child: CustomScrollView( slivers: [ _buildAppBar(context), buildItemStreamList( maxCrossAxisExtent: _thumbSize.toDouble(), ), ], ), ), ), ), if (state is ListFavoriteBlocLoading) const Align( alignment: Alignment.bottomCenter, child: LinearProgressIndicator(), ), ], ); } } Widget _buildAppBar(BuildContext context) { if (isSelectionMode) { return _buildSelectionAppBar(context); } else { return _buildNormalAppBar(context); } } Widget _buildSelectionAppBar(BuildContext conetxt) { return SelectionAppBar( count: selectedListItems.length, onClosePressed: () { setState(() { clearSelectedItems(); }); }, actions: [ IconButton( icon: const Icon(Icons.share), tooltip: L10n.global().shareTooltip, onPressed: () => _onSelectionSharePressed(context), ), IconButton( icon: const Icon(Icons.add), tooltip: L10n.global().addToAlbumTooltip, onPressed: () => _onSelectionAddToAlbumPressed(context), ), PopupMenuButton<_SelectionMenuOption>( tooltip: MaterialLocalizations.of(context).moreButtonTooltip, itemBuilder: (context) => [ PopupMenuItem( value: _SelectionMenuOption.download, child: Text(L10n.global().downloadTooltip), ), PopupMenuItem( value: _SelectionMenuOption.archive, child: Text(L10n.global().archiveTooltip), ), PopupMenuItem( value: _SelectionMenuOption.delete, child: Text(L10n.global().deleteTooltip), ), ], onSelected: (option) => _onSelectionMenuSelected(context, option), ), ], ); } Widget _buildNormalAppBar(BuildContext context) { return SliverAppBar( title: Text(L10n.global().collectionFavoritesLabel), floating: true, actions: [ ZoomMenuButton( initialZoom: _thumbZoomLevel, minZoom: -1, maxZoom: 2, onZoomChanged: (value) { setState(() { _setThumbZoomLevel(value.round()); }); Pref().setHomePhotosZoomLevel(_thumbZoomLevel); }, ), ], ); } void _onStateChange(BuildContext context, ListFavoriteBlocState state) { if (state is ListFavoriteBlocInit) { itemStreamListItems = []; } else if (state is ListFavoriteBlocSuccess || state is ListFavoriteBlocLoading) { _transformItems(state.items); } else if (state is ListFavoriteBlocFailure) { _transformItems(state.items); SnackBarManager().showSnackBar(SnackBar( content: Text(exception_util.toUserString(state.exception)), duration: k.snackBarDurationNormal, )); } } void _onItemTap(int index) { Navigator.pushNamed(context, Viewer.routeName, arguments: ViewerArguments(widget.account, _backingFiles, index)); } void _onRefreshSelected() { _reqRefresh(); } void _onSelectionMenuSelected( BuildContext context, _SelectionMenuOption option) { switch (option) { case _SelectionMenuOption.archive: _onSelectionArchivePressed(context); break; case _SelectionMenuOption.delete: _onSelectionDeletePressed(context); break; case _SelectionMenuOption.download: _onSelectionDownloadPressed(); break; default: _log.shout("[_onSelectionMenuSelected] Unknown option: $option"); break; } } void _onSelectionSharePressed(BuildContext context) { final selected = selectedListItems .whereType<_FileListItem>() .map((e) => e.file) .toList(); ShareHandler( context: context, clearSelection: () { setState(() { clearSelectedItems(); }); }, ).shareFiles(widget.account, selected); } Future _onSelectionAddToAlbumPressed(BuildContext context) { return AddSelectionToAlbumHandler()( context: context, account: widget.account, selectedFiles: selectedListItems .whereType<_FileListItem>() .map((e) => e.file) .toList(), clearSelection: () { if (mounted) { setState(() { clearSelectedItems(); }); } }, ); } void _onSelectionDownloadPressed() { final selected = selectedListItems .whereType<_FileListItem>() .map((e) => e.file) .toList(); DownloadHandler().downloadFiles(widget.account, selected); setState(() { clearSelectedItems(); }); } Future _onSelectionArchivePressed(BuildContext context) async { final selectedFiles = selectedListItems .whereType<_FileListItem>() .map((e) => e.file) .toList(); setState(() { clearSelectedItems(); }); await ArchiveSelectionHandler(KiwiContainer().resolve())( account: widget.account, selectedFiles: selectedFiles, ); } Future _onSelectionDeletePressed(BuildContext context) async { final selectedFiles = selectedListItems .whereType<_FileListItem>() .map((e) => e.file) .toList(); setState(() { clearSelectedItems(); }); await RemoveSelectionHandler()( account: widget.account, selectedFiles: selectedFiles, isMoveToTrash: true, ); } void _transformItems(List files) { _backingFiles = files .where((f) => f.isArchived != true) .sorted(compareFileDateTimeDescending); final isMonthOnly = _thumbZoomLevel < 0; final dateHelper = photo_list_util.DateGroupHelper( isMonthOnly: isMonthOnly, ); itemStreamListItems = () sync* { for (int i = 0; i < _backingFiles.length; ++i) { final f = _backingFiles[i]; final date = dateHelper.onFile(f); if (date != null) { yield _DateListItem(date: date, isMonthOnly: isMonthOnly); } final previewUrl = api_util.getFilePreviewUrl(widget.account, f, width: k.photoThumbSize, height: k.photoThumbSize); if (file_util.isSupportedImageFormat(f)) { yield _ImageListItem( file: f, account: widget.account, previewUrl: previewUrl, onTap: () => _onItemTap(i), ); } else if (file_util.isSupportedVideoFormat(f)) { yield _VideoListItem( file: f, account: widget.account, previewUrl: previewUrl, onTap: () => _onItemTap(i), ); } else { _log.shout( "[_transformItems] Unsupported file format: ${f.contentType}"); } } }() .toList(); } void _reqQuery() { _bloc.add(ListFavoriteBlocQuery(widget.account)); } void _reqRefresh() { _bloc.add(ListFavoriteBlocQuery(widget.account)); } Future _waitRefresh() async { while (true) { await Future.delayed(const Duration(seconds: 1)); if (_bloc.state is! ListFavoriteBlocLoading) { return; } } } void _setThumbZoomLevel(int level) { final prevLevel = _thumbZoomLevel; _thumbZoomLevel = level; if ((prevLevel >= 0) != (level >= 0)) { _transformItems(_backingFiles); } } late final _bloc = ListFavoriteBloc.of(widget.account); var _backingFiles = []; var _thumbZoomLevel = 0; int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); static final _log = Logger("widget.archive_browser._FavoriteBrowserState"); } 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; } class _DateListItem extends _ListItem { _DateListItem({ required this.date, this.isMonthOnly = false, }); @override get isSelectable => false; @override get staggeredTile => const StaggeredTile.extent(99, 32); @override buildWidget(BuildContext context) { return PhotoListDate( date: date, isMonthOnly: isMonthOnly, ); } final DateTime date; final bool isMonthOnly; } abstract class _FileListItem extends _ListItem { _FileListItem({ required this.file, VoidCallback? onTap, }) : super(onTap: onTap); @override operator ==(Object other) { return other is _FileListItem && file.path == other.file.path; } @override get hashCode => file.path.hashCode; final File file; } class _ImageListItem extends _FileListItem { _ImageListItem({ required File file, required this.account, required this.previewUrl, VoidCallback? onTap, }) : super(file: file, onTap: onTap); @override buildWidget(BuildContext context) { return PhotoListImage( account: account, previewUrl: previewUrl, isGif: file.contentType == "image/gif", ); } final Account account; final String previewUrl; } class _VideoListItem extends _FileListItem { _VideoListItem({ required File file, required this.account, required this.previewUrl, VoidCallback? onTap, }) : super(file: file, onTap: onTap); @override buildWidget(BuildContext context) { return PhotoListVideo( account: account, previewUrl: previewUrl, ); } final Account account; final String previewUrl; } enum _SelectionMenuOption { archive, delete, download, }