import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api.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_album_share_outlier.dart'; import 'package:nc_photos/cache_manager_util.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/share/data_source.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/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/create_share.dart'; import 'package:nc_photos/use_case/remove_share.dart'; import 'package:nc_photos/widget/empty_list_indicator.dart'; import 'package:nc_photos/widget/unbounded_list_tile.dart'; class AlbumShareOutlierBrowserArguments { const AlbumShareOutlierBrowserArguments(this.account, this.album); final Account account; final Album album; } class AlbumShareOutlierBrowser extends StatefulWidget { static const routeName = "/album-share-outlier-browser"; static Route buildRoute(AlbumShareOutlierBrowserArguments args) => MaterialPageRoute( builder: (context) => AlbumShareOutlierBrowser.fromArgs(args), ); const AlbumShareOutlierBrowser({ Key? key, required this.account, required this.album, }) : super(key: key); AlbumShareOutlierBrowser.fromArgs(AlbumShareOutlierBrowserArguments args, {Key? key}) : this( key: key, account: args.account, album: args.album, ); @override createState() => _AlbumShareOutlierBrowserState(); final Account account; final Album album; } class _AlbumShareOutlierBrowserState extends State { @override initState() { super.initState(); _initBloc(); } @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 ListAlbumShareOutlierBlocInit) { _log.info("[_initBloc] Initialize bloc"); } else { // process the current state WidgetsBinding.instance!.addPostFrameCallback((_) { setState(() { _onStateChange(context, _bloc.state); }); }); } _reqQuery(); } Widget _buildContent( BuildContext context, ListAlbumShareOutlierBlocState state) { if ((state is ListAlbumShareOutlierBlocSuccess || state is ListAlbumShareOutlierBlocFailure) && state.items.isEmpty) { return _buildEmptyContent(context); } else { return Stack( children: [ Theme( data: Theme.of(context).copyWith( colorScheme: Theme.of(context).colorScheme.copyWith( secondary: AppTheme.getOverscrollIndicatorColor(context), ), ), child: CustomScrollView( slivers: [ _buildAppBar(context), SliverList( delegate: SliverChildBuilderDelegate( (context, index) => _buildItem(context, _items[index]), childCount: _items.length, ), ), ], ), ), if (state is ListAlbumShareOutlierBlocLoading) const Align( alignment: Alignment.bottomCenter, child: LinearProgressIndicator(), ), ], ); } } Widget _buildAppBar(BuildContext context) { return SliverAppBar( title: Text(L10n.global().fixSharesTooltip), floating: true, actions: [ PopupMenuButton<_MenuOption>( tooltip: MaterialLocalizations.of(context).moreButtonTooltip, itemBuilder: (context) => [ PopupMenuItem( value: _MenuOption.fixAll, child: Text(L10n.global().fixAllTooltip), ), ], onSelected: (option) { switch (option) { case _MenuOption.fixAll: _onFixAllPressed(context); break; default: _log.shout("[_buildAppBar] Unknown option: $option"); break; } }, ), ], ); } Widget _buildEmptyContent(BuildContext context) { return Column( children: [ AppBar( title: Text(L10n.global().fixSharesTooltip), elevation: 0, ), Expanded( child: EmptyListIndicator( icon: Icons.share_outlined, text: L10n.global().listEmptyText, ), ), ], ); } Widget _buildItem(BuildContext context, _ListItem item) { if (item is _MissingShareeItem) { return _buildMissingShareeItem(context, item); } else if (item is _ExtraShareItem) { return _buildExtraShareItem(context, item); } else { throw StateError("Unknown item type: ${item.runtimeType}"); } } Widget _buildMissingShareeItem( BuildContext context, _MissingShareeItem item) { final Widget trailing; switch (_getItemStatus(item.file.path, item.shareWith)) { case null: trailing = _buildFixButton( onPressed: () { _fixMissingSharee(item); }, ); break; case _ItemStatus.processing: trailing = _buildProcessingIcon(context); break; case _ItemStatus.fixed: trailing = _buildFixedIcon(context); break; } return UnboundedListTile( leading: _buildFileThumbnail(item.file), title: Text( item.file.filename, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( L10n.global().missingShareDescription(item.shareWithDisplayName)), trailing: trailing, ); } Widget _buildExtraShareItem(BuildContext context, _ExtraShareItem item) { final Widget trailing; switch (_getItemStatus(item.file.path, item.share.shareWith!)) { case null: trailing = _buildFixButton( onPressed: () { _fixExtraShare(item); }, ); break; case _ItemStatus.processing: trailing = _buildProcessingIcon(context); break; case _ItemStatus.fixed: trailing = _buildFixedIcon(context); break; } return UnboundedListTile( leading: _buildFileThumbnail(item.file), title: Text( item.file.filename, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( L10n.global().extraShareDescription(item.share.shareWithDisplayName)), trailing: trailing, ); } Widget _buildFileThumbnail(File file) { return CachedNetworkImage( width: 56, height: 56, cacheManager: ThumbnailCacheManager.inst, imageUrl: api_util.getFilePreviewUrl(widget.account, file, width: k.photoThumbSize, height: k.photoThumbSize), httpHeaders: { "Authorization": Api.getAuthorizationHeaderValue(widget.account), }, fadeInDuration: const Duration(), filterQuality: FilterQuality.high, imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, errorWidget: (context, url, error) => Icon( Icons.image_not_supported, size: 32, color: AppTheme.getUnfocusedIconColor(context), ), ); } Widget _buildFixButton({ required VoidCallback onPressed, }) { return IconButton( onPressed: onPressed, icon: Icon( Icons.handyman_outlined, color: AppTheme.getUnfocusedIconColor(context), ), tooltip: L10n.global().fixTooltip, ); } Widget _buildProcessingIcon(BuildContext context) { return Padding( padding: const EdgeInsets.all(12), child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator( color: AppTheme.getUnfocusedIconColor(context), ), ), ); } Widget _buildFixedIcon(BuildContext context) { return Padding( padding: const EdgeInsets.all(12), child: Icon( Icons.check, color: AppTheme.getUnfocusedIconColor(context), ), ); } void _onStateChange( BuildContext context, ListAlbumShareOutlierBlocState state) { if (state is ListAlbumShareOutlierBlocInit) { _items = []; } else if (state is ListAlbumShareOutlierBlocSuccess || state is ListAlbumShareOutlierBlocLoading) { _transformItems(state.albumShares, state.items); } else if (state is ListAlbumShareOutlierBlocFailure) { _transformItems(state.albumShares, state.items); SnackBarManager().showSnackBar(SnackBar( content: Text(exception_util.toUserString(state.exception)), duration: k.snackBarDurationNormal, )); } } Future _onFixAllPressed(BuildContext context) async { // select only items that are not fixed/being fixed final items = _items.where((i) { if (i is _MissingShareeItem) { return _getItemStatus(i.file.path, i.shareWith) == null; } else if (i is _ExtraShareItem) { return _getItemStatus(i.file.path, i.share.shareWith!) == null; } else { // ? return false; } }).toList(); setState(() { for (final i in items) { if (i is _MissingShareeItem) { _setItemStatus(i.file.path, i.shareWith, _ItemStatus.processing); } else if (i is _ExtraShareItem) { _setItemStatus( i.file.path, i.share.shareWith!, _ItemStatus.processing); } } }); for (final i in items) { if (i is _MissingShareeItem) { await _fixMissingSharee(i); } else if (i is _ExtraShareItem) { await _fixExtraShare(i); } } } void _transformItems( List albumShares, List items) { final to = albumShares.sorted((a, b) => a.shareWith!.compareTo(b.shareWith!)); for (final i in items) { _transformItem(to, i); } } void _transformItem(List albumShares, ListAlbumShareOutlierItem item) { final from = item.shares.sorted((a, b) => a.shareWith!.compareTo(b.shareWith!)); final to = albumShares; var fromI = 0, toI = 0; while (fromI < from.length && toI < to.length) { final fromShare = from[fromI]; final toShare = to[toI]; if (fromShare.shareWith == toShare.shareWith) { ++fromI; ++toI; } else { final diff = fromShare.shareWith!.compareTo(toShare.shareWith!); if (diff < 0) { // extra element in from _items.add(_ExtraShareItem(item.file, fromShare)); ++fromI; } else { // extra element in to _items.add(_MissingShareeItem( item.file, toShare.shareWith!, toShare.shareWithDisplayName)); ++toI; } } } for (var i = fromI; i < from.length; ++i) { _items.add(_ExtraShareItem(item.file, from[i])); } for (var i = toI; i < to.length; ++i) { _items.add(_MissingShareeItem( item.file, to[i].shareWith!, to[i].shareWithDisplayName)); } } Future _fixMissingSharee(_MissingShareeItem item) async { final shareRepo = ShareRepo(ShareRemoteDataSource()); setState(() { _setItemStatus(item.file.path, item.shareWith, _ItemStatus.processing); }); try { await CreateUserShare(shareRepo)( widget.account, item.file, item.shareWith); if (mounted) { setState(() { _setItemStatus(item.file.path, item.shareWith, _ItemStatus.fixed); }); } } catch (e, stackTrace) { _log.shout( "[_fixMissingSharee] Failed while CreateUserShare", e, stackTrace); SnackBarManager().showSnackBar(SnackBar( content: Text(exception_util.toUserString(e)), duration: k.snackBarDurationNormal, )); if (mounted) { setState(() { _removeItemStatus(item.file.path, item.shareWith); }); } } } Future _fixExtraShare(_ExtraShareItem item) async { final shareRepo = ShareRepo(ShareRemoteDataSource()); setState(() { _setItemStatus( item.file.path, item.share.shareWith!, _ItemStatus.processing); }); try { await RemoveShare(shareRepo)(widget.account, item.share); if (mounted) { setState(() { _setItemStatus( item.file.path, item.share.shareWith!, _ItemStatus.fixed); }); } } catch (e, stackTrace) { _log.shout("[_fixExtraShare] Failed while RemoveShare", e, stackTrace); SnackBarManager().showSnackBar(SnackBar( content: Text(exception_util.toUserString(e)), duration: k.snackBarDurationNormal, )); if (mounted) { setState(() { _removeItemStatus(item.file.path, item.share.shareWith!); }); } } } void _reqQuery() { _bloc.add(ListAlbumShareOutlierBlocQuery(widget.account, widget.album)); } _ItemStatus? _getItemStatus(String fileKey, String shareeKey) { final temp = _itemStatuses[fileKey]; if (temp == null) { return null; } else { return temp[shareeKey]; } } void _setItemStatus(String fileKey, String shareeKey, _ItemStatus value) { if (!_itemStatuses.containsKey(fileKey)) { _itemStatuses[fileKey] = {}; } _itemStatuses[fileKey]![shareeKey] = value; } void _removeItemStatus(String fileKey, String shareeKey) { if (!_itemStatuses.containsKey(fileKey)) { return; } _itemStatuses[fileKey]!.remove(shareeKey); } late final _bloc = ListAlbumShareOutlierBloc(); var _items = <_ListItem>[]; final _itemStatuses = >{}; static final _log = Logger( "widget.album_share_outlier_browser._AlbumShareOutlierBrowserState"); } abstract class _ListItem { const _ListItem(); } class _ExtraShareItem extends _ListItem { const _ExtraShareItem(this.file, this.share); final File file; final Share share; } class _MissingShareeItem extends _ListItem { const _MissingShareeItem( this.file, this.shareWith, this.shareWithDisplayName); final File file; final String shareWith; final String shareWithDisplayName; } enum _ItemStatus { processing, fixed, } enum _MenuOption { fixAll, }