diff --git a/app/lib/app_init.dart b/app/lib/app_init.dart index a0a57d91..899e30ad 100644 --- a/app/lib/app_init.dart +++ b/app/lib/app_init.dart @@ -35,7 +35,12 @@ import 'package:nc_photos/pref.dart'; import 'package:nc_photos/pref_util.dart' as pref_util; Future initAppLaunch() async { - _initLog(); + if (_hasInitedInThisIsolate) { + _log.warning("[initAppLaunch] Already initialized in this isolate"); + return; + } + + initLog(); _initKiwi(); await _initPref(); await _initAccountPrefs(); @@ -46,9 +51,15 @@ Future initAppLaunch() async { _initSelfSignedCertManager(); } _initDiContainer(); + + _hasInitedInThisIsolate = true; } -void _initLog() { +void initLog() { + if (_hasInitedInThisIsolate) { + return; + } + Logger.root.level = kReleaseMode ? Level.WARNING : Level.ALL; Logger.root.onRecord.listen((record) { // dev.log( @@ -162,6 +173,7 @@ void _initDiContainer() { } final _log = Logger("app_init"); +var _hasInitedInThisIsolate = false; class _BlocObserver extends BlocObserver { @override diff --git a/app/lib/app_localizations.dart b/app/lib/app_localizations.dart index ba99a9cf..e2a1b663 100644 --- a/app/lib/app_localizations.dart +++ b/app/lib/app_localizations.dart @@ -1,7 +1,24 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations_en.dart'; +import 'package:logging/logging.dart'; import 'package:nc_photos/widget/my_app.dart'; /// Simplify localized string access class L10n { static AppLocalizations global() => AppLocalizations.of(MyApp.globalContext)!; + + static AppLocalizations of(Locale locale) { + try { + return lookupAppLocalizations(locale); + } on FlutterError catch (_) { + // unsupported locale, use default (en) + return AppLocalizationsEn(); + } catch (e, stackTrace) { + _log.shout("[of] Failed while lookupAppLocalizations", e, stackTrace); + return AppLocalizationsEn(); + } + } + + static final _log = Logger("app_localizations.L10n"); } diff --git a/app/lib/compute_queue.dart b/app/lib/compute_queue.dart new file mode 100644 index 00000000..5a8cfb6d --- /dev/null +++ b/app/lib/compute_queue.dart @@ -0,0 +1,40 @@ +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; + +typedef ComputeQueueCallback = void Function(U result); + +/// Compute the jobs in the queue one by one sequentially in isolate +class ComputeQueue { + void addJob(T event, ComputeCallback callback, + ComputeQueueCallback onResult) { + _queue.addLast(_Job(event, callback, onResult)); + if (_queue.length == 1) { + _startProcessing(); + } + } + + bool get isProcessing => _queue.isNotEmpty; + + Future _startProcessing() async { + while (_queue.isNotEmpty) { + final ev = _queue.first; + try { + final result = await compute(ev.callback, ev.message); + ev.onResult(result); + } finally { + _queue.removeFirst(); + } + } + } + + final _queue = Queue<_Job>(); +} + +class _Job { + const _Job(this.message, this.callback, this.onResult); + + final T message; + final ComputeCallback callback; + final ComputeQueueCallback onResult; +} diff --git a/app/lib/entity/file/data_source.dart b/app/lib/entity/file/data_source.dart index 42152dab..759cf1dd 100644 --- a/app/lib/entity/file/data_source.dart +++ b/app/lib/entity/file/data_source.dart @@ -13,12 +13,12 @@ import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/webdav_response_parser.dart'; import 'package:nc_photos/exception.dart'; import 'package:nc_photos/iterable_extension.dart'; -import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/touch_token_manager.dart'; import 'package:nc_photos/use_case/compat/v32.dart'; import 'package:path/path.dart' as path_lib; +import 'package:tuple/tuple.dart'; import 'package:uuid/uuid.dart'; import 'package:xml/xml.dart'; @@ -64,7 +64,7 @@ class FileWebdavDataSource implements FileDataSource { } final xml = XmlDocument.parse(response.body); - var files = WebdavResponseParser().parseFiles(xml); + var files = await WebdavResponseParser().parseFiles(xml); // _log.fine("[list] Parsed files: [$files]"); bool hasNoMediaMarker = false; files = files @@ -269,9 +269,9 @@ class FileAppDbDataSource implements FileDataSource { const FileAppDbDataSource(this.appDb); @override - list(Account account, File dir) { + list(Account account, File dir) async { _log.info("[list] ${dir.path}"); - return appDb.use( + final dbItems = await appDb.use( (db) => db.transaction( [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadOnly), (transaction) async { @@ -284,21 +284,31 @@ class FileAppDbDataSource implements FileDataSource { } final dirEntry = AppDbDirEntry.fromJson(dirItem.cast()); - final entries = await dirEntry.children.mapStream((c) async { - final fileItem = await fileStore - .getObject(AppDbFile2Entry.toPrimaryKey(account, c)) as Map?; - if (fileItem == null) { - _log.warning( - "[list] Missing file ($c) in db for dir: ${logFilename(dir.path)}"); - throw CacheNotFoundException("No entry for dir child: $c"); - } - return AppDbFile2Entry.fromJson(fileItem.cast()); - }, k.simultaneousQuery).toList(); - // we need to add dir to match the remote query - return [dirEntry.dir] + - entries.map((e) => e.file).where((f) => _validateFile(f)).toList(); + return Tuple2( + dirEntry.dir, + await Future.wait( + dirEntry.children.map((c) async { + final fileItem = await fileStore + .getObject(AppDbFile2Entry.toPrimaryKey(account, c)) as Map?; + if (fileItem == null) { + _log.warning( + "[list] Missing file ($c) in db for dir: ${logFilename(dir.path)}"); + throw CacheNotFoundException("No entry for dir child: $c"); + } else { + return fileItem; + } + }), + eagerError: true, + ), + ); }, ); + // we need to add dir to match the remote query + return [ + dbItems.item1, + ...(await dbItems.item2.computeAll(_covertAppDbFile2Entry)) + .where((f) => _validateFile(f)) + ]; } @override @@ -647,20 +657,16 @@ class FileForwardCacheManager { // query other files if (needQuery.isNotEmpty) { - final fileItems = await appDb.use( + final dbItems = await appDb.use( (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), (transaction) async { final store = transaction.objectStore(AppDb.file2StoreName); - return await needQuery - .mapStream( - (id) => store - .getObject(AppDbFile2Entry.toPrimaryKey(account, id)), - k.simultaneousQuery) - .toList(); + return await Future.wait(needQuery.map((id) => + store.getObject(AppDbFile2Entry.toPrimaryKey(account, id)))); }, ); - files.addAll(fileItems.cast().whereType().map( - (i) => AppDbFile2Entry.fromJson(i.cast()).file)); + files.addAll( + await dbItems.whereType().computeAll(_covertAppDbFile2Entry)); } _fileCache.addEntries(files.map((f) => MapEntry(f.fileId!, f))); _log.info( @@ -692,3 +698,6 @@ bool _validateFile(File f) { // See: https://gitlab.com/nkming2/nc-photos/-/issues/9 return f.lastModified != null; } + +File _covertAppDbFile2Entry(Map json) => + AppDbFile2Entry.fromJson(json.cast()).file; diff --git a/app/lib/entity/local_file.dart b/app/lib/entity/local_file.dart index 7e2b30da..f2d8b94e 100644 --- a/app/lib/entity/local_file.dart +++ b/app/lib/entity/local_file.dart @@ -9,6 +9,9 @@ abstract class LocalFile with EquatableMixin { /// careful that this does NOT mean that the two objects are identical bool compareIdentity(LocalFile other); + /// hashCode to be used with [compareIdentity] + int get identityHashCode; + String get logTag; String get filename; @@ -41,6 +44,9 @@ class LocalUriFile with EquatableMixin implements LocalFile { } } + @override + get identityHashCode => uri.hashCode; + @override toString() { var product = "$runtimeType {" diff --git a/app/lib/entity/webdav_response_parser.dart b/app/lib/entity/webdav_response_parser.dart index a56a5f9e..7952ba49 100644 --- a/app/lib/entity/webdav_response_parser.dart +++ b/app/lib/entity/webdav_response_parser.dart @@ -1,7 +1,9 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; +import 'package:nc_photos/app_init.dart' as app_init; import 'package:nc_photos/ci_string.dart'; import 'package:nc_photos/entity/favorite.dart'; import 'package:nc_photos/entity/file.dart'; @@ -11,18 +13,30 @@ import 'package:nc_photos/string_extension.dart'; import 'package:xml/xml.dart'; class WebdavResponseParser { - List parseFiles(XmlDocument xml) => _parse(xml, _toFile); + Future> parseFiles(XmlDocument xml) => + compute(_parseFilesIsolate, xml); - List parseFavorites(XmlDocument xml) => - _parse(xml, _toFavorite); + Future> parseFavorites(XmlDocument xml) => + compute(_parseFavoritesIsolate, xml); - List parseTags(XmlDocument xml) => _parse(xml, _toTag); + Future> parseTags(XmlDocument xml) => + compute(_parseTagsIsolate, xml); - List parseTaggedFiles(XmlDocument xml) => - _parse(xml, _toTaggedFile); + Future> parseTaggedFiles(XmlDocument xml) => + compute(_parseTaggedFilesIsolate, xml); Map get namespaces => _namespaces; + List _parseFiles(XmlDocument xml) => _parse(xml, _toFile); + + List _parseFavorites(XmlDocument xml) => + _parse(xml, _toFavorite); + + List _parseTags(XmlDocument xml) => _parse(xml, _toTag); + + List _parseTaggedFiles(XmlDocument xml) => + _parse(xml, _toTaggedFile); + List _parse(XmlDocument xml, T? Function(XmlElement) mapper) { _namespaces = _parseNamespaces(xml); final body = () { @@ -501,3 +515,23 @@ extension on XmlElement { .any((element) => element.key == name.prefix)); } } + +List _parseFilesIsolate(XmlDocument xml) { + app_init.initLog(); + return WebdavResponseParser()._parseFiles(xml); +} + +List _parseFavoritesIsolate(XmlDocument xml) { + app_init.initLog(); + return WebdavResponseParser()._parseFavorites(xml); +} + +List _parseTagsIsolate(XmlDocument xml) { + app_init.initLog(); + return WebdavResponseParser()._parseTags(xml); +} + +List _parseTaggedFilesIsolate(XmlDocument xml) { + app_init.initLog(); + return WebdavResponseParser()._parseTaggedFiles(xml); +} diff --git a/app/lib/iterable_extension.dart b/app/lib/iterable_extension.dart index 38a1e059..24e039b9 100644 --- a/app/lib/iterable_extension.dart +++ b/app/lib/iterable_extension.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:nc_photos/list_extension.dart'; import 'package:nc_photos/override_comparator.dart'; import 'package:tuple/tuple.dart'; @@ -23,56 +24,6 @@ extension IterableExtension on Iterable { } } - /// The current elements of this iterable modified by async function [fn]. - /// - /// The result of [fn] will be emitted by the returned stream in the same - /// order as this iterable. - /// - /// If [simultaneousFuture] > 1, [fn] will be called multiple times before - /// awaiting their results. - Stream mapStream( - Future Function(T element) fn, [ - simultaneousFuture = 1, - ]) async* { - final container = >[]; - for (final e in this) { - container.add(fn(e)); - if (container.length >= simultaneousFuture) { - for (final result in await Future.wait(container)) { - yield result; - } - container.clear(); - } - } - if (container.isNotEmpty) { - for (final result in await Future.wait(container)) { - yield result; - } - } - } - - /// Invokes async function [fn] on each element of this iterable in iteration - /// order. - /// - /// If [simultaneousFuture] > 1, [fn] will be called multiple times before - /// awaiting their results. - Future forEachAsync( - Future Function(T element) fn, [ - simultaneousFuture = 1, - ]) async { - final container = []; - for (final e in this) { - container.add(fn(e)); - if (container.length >= simultaneousFuture) { - await Future.wait(container); - container.clear(); - } - } - if (container.isNotEmpty) { - await Future.wait(container); - } - } - Iterable> withIndex() => mapWithIndex((i, e) => Tuple2(i, e)); /// Whether the collection contains an element equal to [element] using the @@ -114,4 +65,34 @@ extension IterableExtension on Iterable { yield e; } } + + Future> computeAll(ComputeCallback callback) async { + return await compute( + _computeAllImpl, _ComputeAllMessage(callback, asList())); + } + + /// Return a list containing elements in this iterable + /// + /// If this Iterable is itself a list, this will be returned directly with no + /// copying + List asList() { + if (this is List) { + return this as List; + } else { + return toList(); + } + } +} + +class _ComputeAllMessage { + const _ComputeAllMessage(this.callback, this.data); + + final ComputeCallback callback; + final List data; +} + +Future> _computeAllImpl(_ComputeAllMessage message) async { + final result = await Future.wait( + message.data.map((e) async => await message.callback(e))); + return result; } diff --git a/app/lib/k.dart b/app/lib/k.dart index 1768a90f..372eb354 100644 --- a/app/lib/k.dart +++ b/app/lib/k.dart @@ -36,6 +36,3 @@ const coverSize = 512; /// AppDb lock ID const appDbLockId = 1; - -/// Number of async query task that can be called simultaneously -const simultaneousQuery = 20; diff --git a/app/lib/object_extension.dart b/app/lib/object_extension.dart index d716cf18..62ca1470 100644 --- a/app/lib/object_extension.dart +++ b/app/lib/object_extension.dart @@ -20,4 +20,7 @@ extension ObjectExtension on T { Future runFuture(FutureOr Function(T obj) fn) async { return await fn(this); } + + /// Cast this as U, or null if this is not an object of U + U? as() => this is U ? this as U : null; } diff --git a/app/lib/use_case/find_file.dart b/app/lib/use_case/find_file.dart index 1d024713..0120c30a 100644 --- a/app/lib/use_case/find_file.dart +++ b/app/lib/use_case/find_file.dart @@ -1,11 +1,9 @@ +import 'package:flutter/foundation.dart'; import 'package:idb_shim/idb_client.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/iterable_extension.dart'; -import 'package:nc_photos/k.dart' as k; -import 'package:quiver/iterables.dart'; class FindFile { FindFile(this._c) : assert(require(_c)); @@ -25,27 +23,22 @@ class FindFile { (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), (transaction) async { final fileStore = transaction.objectStore(AppDb.file2StoreName); - return await fileIds - .mapStream( - (id) => fileStore - .getObject(AppDbFile2Entry.toPrimaryKey(account, id)), - k.simultaneousQuery) - .toList(); + return await Future.wait(fileIds.map((id) => + fileStore.getObject(AppDbFile2Entry.toPrimaryKey(account, id)))); }, ); + final fileMap = await compute(_covertFileMap, dbItems); final files = []; - for (final pair in zip([fileIds, dbItems])) { - final dbItem = pair[1] as Map?; - if (dbItem == null) { + for (final id in fileIds) { + final f = fileMap[id]; + if (f == null) { if (onFileNotFound == null) { - throw StateError("File ID not found: ${pair[0]}"); + throw StateError("File ID not found: $id"); } else { - onFileNotFound(pair[0] as int); + onFileNotFound(id); } } else { - final dbEntry = - AppDbFile2Entry.fromJson(dbItem.cast()); - files.add(dbEntry.file); + files.add(f); } } return files; @@ -53,3 +46,10 @@ class FindFile { final DiContainer _c; } + +Map _covertFileMap(List dbItems) { + return Map.fromEntries(dbItems + .whereType() + .map((j) => AppDbFile2Entry.fromJson(j.cast()).file) + .map((f) => MapEntry(f.fileId!, f))); +} diff --git a/app/lib/use_case/resync_album.dart b/app/lib/use_case/resync_album.dart index 190dfb82..dca7bb3a 100644 --- a/app/lib/use_case/resync_album.dart +++ b/app/lib/use_case/resync_album.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:idb_shim/idb_client.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; @@ -6,8 +7,7 @@ import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/provider.dart'; -import 'package:nc_photos/iterable_extension.dart'; -import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/entity/file.dart'; /// Resync files inside an album with the file db class ResyncAlbum { @@ -20,31 +20,21 @@ class ResyncAlbum { "Resync only make sense for static albums: ${album.name}"); } final items = AlbumStaticProvider.of(album).items; - final fileIds = - items.whereType().map((i) => i.file.fileId!).toList(); - final dbItems = Map.fromEntries(await appDb.use( + final dbItems = await appDb.use( (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), (transaction) async { final store = transaction.objectStore(AppDb.file2StoreName); - return await fileIds - .mapStream( - (id) async => MapEntry( - id, - await store.getObject( - AppDbFile2Entry.toPrimaryKey(account, id)) as Map?, - ), - k.simultaneousQuery) - .toList(); + return await Future.wait(items.whereType().map((i) => + store.getObject( + AppDbFile2Entry.toPrimaryKey(account, i.file.fileId!)))); }, - )); + ); + final fileMap = await compute(_covertFileMap, dbItems); return items.map((i) { if (i is AlbumFileItem) { try { - final dbItem = dbItems[i.file.fileId]!; - final dbEntry = - AppDbFile2Entry.fromJson(dbItem.cast()); return i.copyWith( - file: dbEntry.file, + file: fileMap[i.file.fileId]!, ); } catch (e, stackTrace) { _log.shout( @@ -63,3 +53,10 @@ class ResyncAlbum { static final _log = Logger("use_case.resync_album.ResyncAlbum"); } + +Map _covertFileMap(List dbItems) { + return Map.fromEntries(dbItems + .whereType() + .map((j) => AppDbFile2Entry.fromJson(j.cast()).file) + .map((f) => MapEntry(f.fileId!, f))); +} diff --git a/app/lib/use_case/scan_dir_offline.dart b/app/lib/use_case/scan_dir_offline.dart index 9b7da003..dbc3655f 100644 --- a/app/lib/use_case/scan_dir_offline.dart +++ b/app/lib/use_case/scan_dir_offline.dart @@ -4,6 +4,7 @@ import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/iterable_extension.dart'; class ScanDirOffline { ScanDirOffline(this._c) : assert(require(_c)); @@ -11,12 +12,12 @@ class ScanDirOffline { static bool require(DiContainer c) => DiContainer.has(c, DiType.appDb); /// List all files under a dir recursively from the local DB - Future> call( + Future> call( Account account, File root, { bool isOnlySupportedFormat = true, }) async { - return await _c.appDb.use( + final dbItems = await _c.appDb.use( (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), (transaction) async { final store = transaction.objectStore(AppDb.file2StoreName); @@ -25,20 +26,25 @@ class ScanDirOffline { AppDbFile2Entry.toStrippedPathIndexLowerKeyForDir(account, root), AppDbFile2Entry.toStrippedPathIndexUpperKeyForDir(account, root), ); - final product = []; - await for (final c - in index.openCursor(range: range, autoAdvance: false)) { - final e = AppDbFile2Entry.fromJson( - (c.value as Map).cast()); - if (!isOnlySupportedFormat || file_util.isSupportedFormat(e.file)) { - product.add(e.file); - } + return await index + .openCursor(range: range, autoAdvance: false) + .map((c) { + final v = c.value as Map; c.next(); - } - return product; + return v; + }).toList(); }, ); + final results = await dbItems.computeAll(_covertAppDbFile2Entry); + if (isOnlySupportedFormat) { + return results.where((f) => file_util.isSupportedFormat(f)); + } else { + return results; + } } final DiContainer _c; } + +File _covertAppDbFile2Entry(Map json) => + AppDbFile2Entry.fromJson(json.cast()).file; diff --git a/app/lib/widget/album_browser.dart b/app/lib/widget/album_browser.dart index 1bc03cf2..bd25afdc 100644 --- a/app/lib/widget/album_browser.dart +++ b/app/lib/widget/album_browser.dart @@ -18,6 +18,7 @@ import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/list_extension.dart'; +import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/session_storage.dart'; @@ -117,6 +118,11 @@ class _AlbumBrowserState extends State ); } + @override + onItemTap(SelectableItem item, int index) { + item.as<_ListItem>()?.onTap?.call(); + } + @override @protected get canEdit => _album?.albumFile?.isOwned(widget.account.username) == true; @@ -888,19 +894,18 @@ enum _SelectionMenuOption { abstract class _ListItem implements SelectableItem, DraggableItem { const _ListItem({ required this.index, - VoidCallback? onTap, + this.onTap, DragTargetAccept? onDropBefore, DragTargetAccept? onDropAfter, VoidCallback? onDragStarted, VoidCallback? onDragEndedAny, - }) : _onTap = onTap, - _onDropBefore = onDropBefore, + }) : _onDropBefore = onDropBefore, _onDropAfter = onDropAfter, _onDragStarted = onDragStarted, _onDragEndedAny = onDragEndedAny; @override - get onTap => _onTap; + get isTappable => onTap != null; @override get isSelectable => true; @@ -935,7 +940,7 @@ abstract class _ListItem implements SelectableItem, DraggableItem { final int index; - final VoidCallback? _onTap; + final VoidCallback? onTap; final DragTargetAccept? _onDropBefore; final DragTargetAccept? _onDropAfter; final VoidCallback? _onDragStarted; diff --git a/app/lib/widget/archive_browser.dart b/app/lib/widget/archive_browser.dart index 6f770ed3..7bb3a49b 100644 --- a/app/lib/widget/archive_browser.dart +++ b/app/lib/widget/archive_browser.dart @@ -1,23 +1,25 @@ +import 'dart:ui'; + 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/account.dart'; -import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc/scan_account_dir.dart'; +import 'package:nc_photos/compute_queue.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.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/language_util.dart' as language_util; +import 'package:nc_photos/object_extension.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/use_case/update_property.dart'; +import 'package:nc_photos/widget/builder/photo_list_item_builder.dart'; import 'package:nc_photos/widget/empty_list_indicator.dart'; import 'package:nc_photos/widget/photo_list_item.dart'; import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util; @@ -81,6 +83,18 @@ class _ArchiveBrowserState extends State ); } + @override + onItemTap(SelectableItem item, int index) { + item.as()?.run((fileItem) { + Navigator.pushNamed( + context, + Viewer.routeName, + arguments: + ViewerArguments(widget.account, _backingFiles, fileItem.fileIndex), + ); + }); + } + void _initBloc() { if (_bloc.state is ScanAccountDirBlocInit) { _log.info("[_initBloc] Initialize bloc"); @@ -96,7 +110,9 @@ class _ArchiveBrowserState extends State } Widget _buildContent(BuildContext context, ScanAccountDirBlocState state) { - if (state is ScanAccountDirBlocSuccess && itemStreamListItems.isEmpty) { + if (state is ScanAccountDirBlocSuccess && + !_buildItemQueue.isProcessing && + itemStreamListItems.isEmpty) { return Column( children: [ AppBar( @@ -132,7 +148,8 @@ class _ArchiveBrowserState extends State ), ), ), - if (state is ScanAccountDirBlocLoading) + if (state is ScanAccountDirBlocLoading || + _buildItemQueue.isProcessing) const Align( alignment: Alignment.bottomCenter, child: LinearProgressIndicator(), @@ -207,11 +224,6 @@ class _ArchiveBrowserState extends State } } - void _onItemTap(int index) { - Navigator.pushNamed(context, Viewer.routeName, - arguments: ViewerArguments(widget.account, _backingFiles, index)); - } - Future _onSelectionAppBarUnarchivePressed() async { SnackBarManager().showSnackBar(SnackBar( content: Text(L10n.global() @@ -219,7 +231,7 @@ class _ArchiveBrowserState extends State duration: k.snackBarDurationShort, )); final selectedFiles = selectedListItems - .whereType<_FileListItem>() + .whereType() .map((e) => e.file) .toList(); setState(() { @@ -254,37 +266,25 @@ class _ArchiveBrowserState extends State } void _transformItems(List files) { - _backingFiles = files - .where((f) => f.isArchived == true) - .sorted(compareFileDateTimeDescending); - - itemStreamListItems = () sync* { - for (int i = 0; i < _backingFiles.length; ++i) { - final f = _backingFiles[i]; - - 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}"); + _buildItemQueue.addJob( + PhotoListItemBuilderArguments( + widget.account, + files, + isArchived: true, + sorter: photoListFileDateTimeSorter, + locale: language_util.getSelectedLocale() ?? + PlatformDispatcher.instance.locale, + ), + buildPhotoListItem, + (result) { + if (mounted) { + setState(() { + _backingFiles = result.backingFiles; + itemStreamListItems = result.listItems; + }); } - } - }() - .toList(); + }, + ); } void _reqQuery() { @@ -295,83 +295,11 @@ class _ArchiveBrowserState extends State var _backingFiles = []; + final _buildItemQueue = + ComputeQueue(); + var _thumbZoomLevel = 0; int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); static final _log = Logger("widget.archive_browser._ArchiveBrowserState"); } - -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); - - @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; -} diff --git a/app/lib/widget/builder/photo_list_item_builder.dart b/app/lib/widget/builder/photo_list_item_builder.dart new file mode 100644 index 00000000..7b87c359 --- /dev/null +++ b/app/lib/widget/builder/photo_list_item_builder.dart @@ -0,0 +1,183 @@ +import 'package:collection/collection.dart' show compareNatural; +import 'package:flutter/widgets.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_init.dart' as app_init; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/widget/photo_list_item.dart'; +import 'package:nc_photos/widget/photo_list_util.dart'; +import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; + +class PhotoListItemBuilderArguments { + const PhotoListItemBuilderArguments( + this.account, + this.files, { + this.isArchived = false, + required this.sorter, + this.grouper, + this.shouldBuildSmartAlbums = false, + this.shouldShowFavoriteBadge = false, + required this.locale, + }); + + final Account account; + final List files; + final bool isArchived; + final PhotoListItemSorter? sorter; + final PhotoListItemGrouper? grouper; + final bool shouldBuildSmartAlbums; + final bool shouldShowFavoriteBadge; + + /// Locale is needed to get localized string + final Locale locale; +} + +class PhotoListItemBuilderResult { + const PhotoListItemBuilderResult( + this.backingFiles, + this.listItems, { + this.smartAlbums = const [], + }); + + final List backingFiles; + final List listItems; + final List smartAlbums; +} + +typedef PhotoListItemSorter = int Function(File, File); + +abstract class PhotoListItemGrouper { + const PhotoListItemGrouper(); + + SelectableItem? onFile(File file); +} + +class PhotoListFileDateGrouper implements PhotoListItemGrouper { + PhotoListFileDateGrouper({ + required this.isMonthOnly, + }) : helper = DateGroupHelper(isMonthOnly: isMonthOnly); + + @override + onFile(File file) => helper + .onFile(file) + ?.run((date) => PhotoListDateItem(date: date, isMonthOnly: isMonthOnly)); + + final bool isMonthOnly; + final DateGroupHelper helper; +} + +int photoListFileDateTimeSorter(File a, File b) => + compareFileDateTimeDescending(a, b); + +int photoListFilenameSorter(File a, File b) => + compareNatural(b.filename, a.filename); + +PhotoListItemBuilderResult buildPhotoListItem( + PhotoListItemBuilderArguments arg) { + app_init.initLog(); + return _PhotoListItemBuilder( + isArchived: arg.isArchived, + sorter: arg.sorter, + grouper: arg.grouper, + shouldBuildSmartAlbums: arg.shouldBuildSmartAlbums, + shouldShowFavoriteBadge: arg.shouldShowFavoriteBadge, + locale: arg.locale, + )(arg.account, arg.files); +} + +class _PhotoListItemBuilder { + const _PhotoListItemBuilder({ + required this.isArchived, + required this.sorter, + required this.grouper, + required this.shouldBuildSmartAlbums, + required this.shouldShowFavoriteBadge, + required this.locale, + }); + + PhotoListItemBuilderResult call(Account account, List files) { + final s = Stopwatch()..start(); + try { + return _fromSortedItems(account, _sortItems(files)); + } finally { + _log.info("[call] Elapsed time: ${s.elapsedMilliseconds}ms"); + } + } + + List _sortItems(List files) { + final filtered = files.where((f) => (f.isArchived ?? false) == isArchived); + if (sorter == null) { + return filtered.toList(); + } else { + return filtered.stableSorted(sorter); + } + } + + PhotoListItemBuilderResult _fromSortedItems( + Account account, List files) { + final today = DateTime.now(); + final memoryAlbumHelper = + shouldBuildSmartAlbums ? MemoryAlbumHelper(today) : null; + final listItems = []; + for (int i = 0; i < files.length; ++i) { + final f = files[i]; + grouper?.onFile(f)?.run((item) => listItems.add(item)); + memoryAlbumHelper?.addFile(f); + + final item = _buildListItem(i, account, f); + if (item != null) { + listItems.add(item); + } + } + final smartAlbums = memoryAlbumHelper + ?.build((year) => L10n.of(locale).memoryAlbumName(today.year - year)); + return PhotoListItemBuilderResult( + files, + listItems, + smartAlbums: smartAlbums ?? [], + ); + } + + SelectableItem? _buildListItem(int i, Account account, File file) { + final previewUrl = api_util.getFilePreviewUrl(account, file, + width: k.photoThumbSize, height: k.photoThumbSize); + if (file_util.isSupportedImageFormat(file)) { + return PhotoListImageItem( + fileIndex: i, + file: file, + account: account, + previewUrl: previewUrl, + shouldShowFavoriteBadge: shouldShowFavoriteBadge, + ); + } else if (file_util.isSupportedVideoFormat(file)) { + return PhotoListVideoItem( + fileIndex: i, + file: file, + account: account, + previewUrl: previewUrl, + shouldShowFavoriteBadge: shouldShowFavoriteBadge, + ); + } else { + _log.shout( + "[_buildListItem] Unsupported file format: ${file.contentType}"); + return null; + } + } + + final bool isArchived; + final PhotoListItemSorter? sorter; + final PhotoListItemGrouper? grouper; + final bool shouldBuildSmartAlbums; + final bool shouldShowFavoriteBadge; + final Locale locale; + + static final _log = + Logger("widget.builder.photo_list_item_builder._PhotoListItemBuilder"); +} diff --git a/app/lib/widget/dynamic_album_browser.dart b/app/lib/widget/dynamic_album_browser.dart index ee6373a2..6990b8fb 100644 --- a/app/lib/widget/dynamic_album_browser.dart +++ b/app/lib/widget/dynamic_album_browser.dart @@ -20,6 +20,7 @@ import 'package:nc_photos/event/event.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/object_extension.dart'; import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/share_handler.dart'; @@ -111,6 +112,11 @@ class _DynamicAlbumBrowserState extends State ); } + @override + onItemTap(SelectableItem item, int index) { + item.as<_ListItem>()?.onTap?.call(); + } + @override @protected get canEdit => _album?.albumFile?.isOwned(widget.account.username) == true; @@ -654,11 +660,11 @@ enum _SelectionMenuOption { abstract class _ListItem implements SelectableItem { const _ListItem({ required this.index, - VoidCallback? onTap, - }) : _onTap = onTap; + this.onTap, + }); @override - get onTap => _onTap; + get isTappable => onTap != null; @override get isSelectable => true; @@ -675,7 +681,7 @@ abstract class _ListItem implements SelectableItem { final int index; - final VoidCallback? _onTap; + final VoidCallback? onTap; } abstract class _FileListItem extends _ListItem { diff --git a/app/lib/widget/enhanced_photo_browser.dart b/app/lib/widget/enhanced_photo_browser.dart index 2a344249..22bea225 100644 --- a/app/lib/widget/enhanced_photo_browser.dart +++ b/app/lib/widget/enhanced_photo_browser.dart @@ -1,17 +1,18 @@ 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_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/content_uri_image_provider.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'; @@ -20,6 +21,7 @@ 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'; @@ -91,6 +93,17 @@ class _EnhancedPhotoBrowserState extends 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"); @@ -123,6 +136,7 @@ class _EnhancedPhotoBrowserState extends State ], ); } else if (state is ScanLocalDirBlocSuccess && + !_buildItemQueue.isProcessing && itemStreamListItems.isEmpty) { return Column( children: [ @@ -159,7 +173,7 @@ class _EnhancedPhotoBrowserState extends State ), ), ), - if (state is ScanLocalDirBlocLoading) + if (state is ScanLocalDirBlocLoading || _buildItemQueue.isProcessing) const Align( alignment: Alignment.bottomCenter, child: LinearProgressIndicator(), @@ -237,7 +251,7 @@ class _EnhancedPhotoBrowserState extends State Future _onSelectionSharePressed(BuildContext context) async { final selected = selectedListItems - .whereType<_FileListItem>() + .whereType() .map((e) => e.file) .toList(); await ShareHandler( @@ -285,7 +299,7 @@ class _EnhancedPhotoBrowserState extends State } final selectedFiles = selectedListItems - .whereType<_FileListItem>() + .whereType() .map((e) => e.file) .toList(); setState(() { @@ -294,29 +308,17 @@ class _EnhancedPhotoBrowserState extends State 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}"); + _buildItemQueue.addJob( + _BuilderArguments(files), + _buildPhotoListItem, + (result) { + setState(() { + _backingFiles = result.backingFiles; + itemStreamListItems = result.listItems; + }); + }, + ); } void _openInitialImage(String filename) { @@ -361,6 +363,8 @@ class _EnhancedPhotoBrowserState extends State var _backingFiles = []; + final _buildItemQueue = ComputeQueue<_BuilderArguments, _BuilderResult>(); + var _isFirstRun = true; var _thumbZoomLevel = 0; int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); @@ -370,76 +374,67 @@ class _EnhancedPhotoBrowserState extends State 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, } + +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); +} diff --git a/app/lib/widget/favorite_browser.dart b/app/lib/widget/favorite_browser.dart index d3099ef4..d77f885c 100644 --- a/app/lib/widget/favorite_browser.dart +++ b/app/lib/widget/favorite_browser.dart @@ -1,23 +1,25 @@ +import 'dart:ui'; + 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/compute_queue.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/language_util.dart' as language_util; +import 'package:nc_photos/object_extension.dart'; 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/builder/photo_list_item_builder.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'; @@ -84,6 +86,18 @@ class _FavoriteBrowserState extends State ); } + @override + onItemTap(SelectableItem item, int index) { + item.as()?.run((fileItem) { + Navigator.pushNamed( + context, + Viewer.routeName, + arguments: + ViewerArguments(widget.account, _backingFiles, fileItem.fileIndex), + ); + }); + } + void _initBloc() { if (_bloc.state is ListFavoriteBlocInit) { _log.info("[_initBloc] Initialize bloc"); @@ -99,7 +113,9 @@ class _FavoriteBrowserState extends State } Widget _buildContent(BuildContext context, ListFavoriteBlocState state) { - if (state is ListFavoriteBlocSuccess && itemStreamListItems.isEmpty) { + if (state is ListFavoriteBlocSuccess && + !_buildItemQueue.isProcessing && + itemStreamListItems.isEmpty) { return Column( children: [ AppBar( @@ -142,7 +158,7 @@ class _FavoriteBrowserState extends State ), ), ), - if (state is ListFavoriteBlocLoading) + if (state is ListFavoriteBlocLoading || _buildItemQueue.isProcessing) const Align( alignment: Alignment.bottomCenter, child: LinearProgressIndicator(), @@ -211,9 +227,7 @@ class _FavoriteBrowserState extends State minZoom: -1, maxZoom: 2, onZoomChanged: (value) { - setState(() { - _setThumbZoomLevel(value.round()); - }); + _setThumbZoomLevel(value.round()); Pref().setHomePhotosZoomLevel(_thumbZoomLevel); }, ), @@ -236,11 +250,6 @@ class _FavoriteBrowserState extends State } } - void _onItemTap(int index) { - Navigator.pushNamed(context, Viewer.routeName, - arguments: ViewerArguments(widget.account, _backingFiles, index)); - } - void _onRefreshSelected() { _reqRefresh(); } @@ -265,7 +274,7 @@ class _FavoriteBrowserState extends State void _onSelectionSharePressed(BuildContext context) { final selected = selectedListItems - .whereType<_FileListItem>() + .whereType() .map((e) => e.file) .toList(); ShareHandler( @@ -283,7 +292,7 @@ class _FavoriteBrowserState extends State context: context, account: widget.account, selectedFiles: selectedListItems - .whereType<_FileListItem>() + .whereType() .map((e) => e.file) .toList(), clearSelection: () { @@ -298,7 +307,7 @@ class _FavoriteBrowserState extends State void _onSelectionDownloadPressed() { final selected = selectedListItems - .whereType<_FileListItem>() + .whereType() .map((e) => e.file) .toList(); DownloadHandler().downloadFiles(widget.account, selected); @@ -309,7 +318,7 @@ class _FavoriteBrowserState extends State Future _onSelectionArchivePressed(BuildContext context) async { final selectedFiles = selectedListItems - .whereType<_FileListItem>() + .whereType() .map((e) => e.file) .toList(); setState(() { @@ -323,7 +332,7 @@ class _FavoriteBrowserState extends State Future _onSelectionDeletePressed(BuildContext context) async { final selectedFiles = selectedListItems - .whereType<_FileListItem>() + .whereType() .map((e) => e.file) .toList(); setState(() { @@ -336,46 +345,26 @@ class _FavoriteBrowserState extends State ); } - 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, + void _transformItems(List files, {bool isSorted = false}) { + _buildItemQueue.addJob( + PhotoListItemBuilderArguments( + widget.account, + files, + sorter: isSorted ? null : photoListFileDateTimeSorter, + grouper: PhotoListFileDateGrouper(isMonthOnly: _thumbZoomLevel < 0), + locale: language_util.getSelectedLocale() ?? + PlatformDispatcher.instance.locale, + ), + buildPhotoListItem, + (result) { + if (mounted) { + setState(() { + _backingFiles = result.backingFiles; + itemStreamListItems = result.listItems; + }); + } + }, ); - 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() { @@ -397,9 +386,13 @@ class _FavoriteBrowserState extends State void _setThumbZoomLevel(int level) { final prevLevel = _thumbZoomLevel; - _thumbZoomLevel = level; if ((prevLevel >= 0) != (level >= 0)) { - _transformItems(_backingFiles); + _thumbZoomLevel = level; + _transformItems(_backingFiles, isSorted: true); + } else { + setState(() { + _thumbZoomLevel = level; + }); } } @@ -407,111 +400,15 @@ class _FavoriteBrowserState extends State var _backingFiles = []; + final _buildItemQueue = + ComputeQueue(); + 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, diff --git a/app/lib/widget/home_albums.dart b/app/lib/widget/home_albums.dart index 39574324..bfd86415 100644 --- a/app/lib/widget/home_albums.dart +++ b/app/lib/widget/home_albums.dart @@ -16,6 +16,7 @@ import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/platform/features.dart' as features; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/snack_bar_manager.dart'; @@ -82,6 +83,11 @@ class _HomeAlbumsState extends State ); } + @override + onItemTap(SelectableItem item, int index) { + item.as<_ListItem>()?.onTap?.call(); + } + void _initBloc() { if (_bloc.state is ListAlbumBlocInit) { _log.info("[_initBloc] Initialize bloc"); @@ -547,6 +553,8 @@ abstract class _ListItem implements SelectableItem { }) : _myOnTap = onTap; @override + get isTappable => _myOnTap != null; + get onTap => _myOnTap; @override diff --git a/app/lib/widget/home_photos.dart b/app/lib/widget/home_photos.dart index 5541fa05..f935d940 100644 --- a/app/lib/widget/home_photos.dart +++ b/app/lib/widget/home_photos.dart @@ -1,11 +1,9 @@ import 'dart:math' as math; import 'dart:ui'; -import 'package:collection/collection.dart' show compareNatural; import 'package:draggable_scrollbar/draggable_scrollbar.dart'; 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'; @@ -13,6 +11,7 @@ import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc/bloc_util.dart' as bloc_util; import 'package:nc_photos/bloc/scan_account_dir.dart'; +import 'package:nc_photos/compute_queue.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/download_handler.dart'; import 'package:nc_photos/entity/album.dart'; @@ -21,9 +20,10 @@ import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/exception.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/language_util.dart' as language_util; import 'package:nc_photos/metadata_task_manager.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/primitive.dart'; @@ -33,6 +33,7 @@ import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/sync_favorite.dart'; import 'package:nc_photos/widget/album_browser_util.dart' as album_browser_util; +import 'package:nc_photos/widget/builder/photo_list_item_builder.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'; @@ -92,6 +93,18 @@ class _HomePhotosState extends State ); } + @override + onItemTap(SelectableItem item, int index) { + item.as()?.run((fileItem) { + Navigator.pushNamed( + context, + Viewer.routeName, + arguments: + ViewerArguments(widget.account, _backingFiles, fileItem.fileIndex), + ); + }); + } + void _initBloc() { if (_bloc.state is ScanAccountDirBlocInit) { _log.info("[_initBloc] Initialize bloc"); @@ -168,7 +181,8 @@ class _HomePhotosState extends State child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (state is ScanAccountDirBlocLoading) + if (state is ScanAccountDirBlocLoading || + _buildItemQueue.isProcessing) const LinearProgressIndicator(), SizedBox( width: double.infinity, @@ -254,9 +268,7 @@ class _HomePhotosState extends State minZoom: -1, maxZoom: 2, onZoomChanged: (value) { - setState(() { - _setThumbZoomLevel(value.round()); - }); + _setThumbZoomLevel(value.round()); Pref().setHomePhotosZoomLevel(_thumbZoomLevel); }, ), @@ -329,11 +341,6 @@ class _HomePhotosState extends State } } - void _onItemTap(int index) { - Navigator.pushNamed(context, Viewer.routeName, - arguments: ViewerArguments(widget.account, _backingFiles, index)); - } - void _onRefreshSelected() { _hasFiredMetadataTask.value = false; _reqRefresh(); @@ -359,7 +366,7 @@ class _HomePhotosState extends State void _onSelectionSharePressed(BuildContext context) { final selected = selectedListItems - .whereType<_FileListItem>() + .whereType() .map((e) => e.file) .toList(); ShareHandler( @@ -377,7 +384,7 @@ class _HomePhotosState extends State context: context, account: widget.account, selectedFiles: selectedListItems - .whereType<_FileListItem>() + .whereType() .map((e) => e.file) .toList(), clearSelection: () { @@ -392,7 +399,7 @@ class _HomePhotosState extends State void _onSelectionDownloadPressed() { final selected = selectedListItems - .whereType<_FileListItem>() + .whereType() .map((e) => e.file) .toList(); DownloadHandler().downloadFiles(widget.account, selected); @@ -403,7 +410,7 @@ class _HomePhotosState extends State Future _onSelectionArchivePressed(BuildContext context) async { final selectedFiles = selectedListItems - .whereType<_FileListItem>() + .whereType() .map((e) => e.file) .toList(); setState(() { @@ -417,7 +424,7 @@ class _HomePhotosState extends State Future _onSelectionDeletePressed(BuildContext context) async { final selectedFiles = selectedListItems - .whereType<_FileListItem>() + .whereType() .map((e) => e.file) .toList(); setState(() { @@ -440,9 +447,7 @@ class _HomePhotosState extends State } else if (ev.key == PrefKey.isPhotosTabSortByName) { if (_bloc.state is! ScanAccountDirBlocInit) { _log.info("[_onPrefUpdated] Update view after changing sort option"); - setState(() { - _transformItems(_bloc.state.files); - }); + _transformItems(_bloc.state.files); } } } @@ -491,83 +496,40 @@ class _HomePhotosState extends State } /// Transform a File list to grid items - void _transformItems(List files) { - if (!Pref().isPhotosTabSortByNameOr()) { - _transformItemsByDate(files); + void _transformItems(List files, {bool isSorted = false}) { + _log.info("[_transformItems] Queue ${files.length} items"); + final PhotoListItemSorter? sorter; + final PhotoListItemGrouper? grouper; + if (Pref().isPhotosTabSortByNameOr()) { + sorter = isSorted ? null : photoListFilenameSorter; + grouper = null; } else { - _transformItemsByName(files); + sorter = isSorted ? null : photoListFileDateTimeSorter; + grouper = PhotoListFileDateGrouper(isMonthOnly: _thumbZoomLevel < 0); } - } - void _transformItemsByName(List files) { - _backingFiles = files - .where((f) => f.isArchived != true) - .sorted((a, b) => compareNatural(b.filename, a.filename)); - - itemStreamListItems = () sync* { - for (int i = 0; i < _backingFiles.length; ++i) { - final item = _transformItemToListItem(i, _backingFiles[i]); - if (item != null) { - yield item; + _buildItemQueue.addJob( + PhotoListItemBuilderArguments( + widget.account, + files, + sorter: sorter, + grouper: grouper, + shouldBuildSmartAlbums: true, + shouldShowFavoriteBadge: true, + locale: language_util.getSelectedLocale() ?? + PlatformDispatcher.instance.locale, + ), + buildPhotoListItem, + (result) { + if (mounted) { + setState(() { + _backingFiles = result.backingFiles; + itemStreamListItems = result.listItems; + _smartAlbums = result.smartAlbums; + }); } - } - }() - .toList(); - } - - void _transformItemsByDate(List files) { - _backingFiles = files - .where((f) => f.isArchived != true) - .sorted(compareFileDateTimeDescending); - - final isMonthOnly = _thumbZoomLevel < 0; - final dateHelper = photo_list_util.DateGroupHelper( - isMonthOnly: isMonthOnly, + }, ); - final today = DateTime.now(); - final memoryAlbumHelper = photo_list_util.MemoryAlbumHelper(today); - 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); - } - memoryAlbumHelper.addFile(f); - - final item = _transformItemToListItem(i, f); - if (item != null) { - yield item; - } - } - }() - .toList(); - _smartAlbums = memoryAlbumHelper - .build((year) => L10n.global().memoryAlbumName(today.year - year)); - } - - _ListItem? _transformItemToListItem(int i, File f) { - final previewUrl = api_util.getFilePreviewUrl(widget.account, f, - width: k.photoThumbSize, height: k.photoThumbSize); - if (file_util.isSupportedImageFormat(f)) { - return _ImageListItem( - file: f, - account: widget.account, - previewUrl: previewUrl, - onTap: () => _onItemTap(i), - ); - } else if (file_util.isSupportedVideoFormat(f)) { - return _VideoListItem( - file: f, - account: widget.account, - previewUrl: previewUrl, - onTap: () => _onItemTap(i), - ); - } else { - _log.shout( - "[_transformItemToListItem] Unsupported file format: ${f.contentType}"); - return null; - } } void _reqQuery() { @@ -589,9 +551,13 @@ class _HomePhotosState extends State void _setThumbZoomLevel(int level) { final prevLevel = _thumbZoomLevel; - _thumbZoomLevel = level; if ((prevLevel >= 0) != (level >= 0)) { - _transformItems(_backingFiles); + _thumbZoomLevel = level; + _transformItems(_backingFiles, isSorted: true); + } else { + setState(() { + _thumbZoomLevel = level; + }); } } @@ -664,6 +630,9 @@ class _HomePhotosState extends State var _backingFiles = []; var _smartAlbums = []; + final _buildItemQueue = + ComputeQueue(); + var _thumbZoomLevel = 0; int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); @@ -854,107 +823,6 @@ class _Web { static const _metadataTaskHeaderHeight = 32.0; } -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", - isFavorite: file.isFavorite == true, - ); - } - - 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, - isFavorite: file.isFavorite == true, - ); - } - - final Account account; - final String previewUrl; -} - class _MetadataTaskHeaderDelegate extends SliverPersistentHeaderDelegate { const _MetadataTaskHeaderDelegate({ required this.extent, diff --git a/app/lib/widget/person_browser.dart b/app/lib/widget/person_browser.dart index 3be440b7..24bef6b2 100644 --- a/app/lib/widget/person_browser.dart +++ b/app/lib/widget/person_browser.dart @@ -22,6 +22,7 @@ import 'package:nc_photos/event/event.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/object_extension.dart'; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/snack_bar_manager.dart'; @@ -106,6 +107,11 @@ class _PersonBrowserState extends State ); } + @override + onItemTap(SelectableItem item, int index) { + item.as<_ListItem>()?.onTap?.call(); + } + void _initBloc() { _log.info("[_initBloc] Initialize bloc"); _reqQuery(); @@ -463,11 +469,11 @@ class _ListItem implements SelectableItem { required this.file, required this.account, required this.previewUrl, - VoidCallback? onTap, - }) : _onTap = onTap; + this.onTap, + }); @override - get onTap => _onTap; + get isTappable => onTap != null; @override get isSelectable => true; @@ -503,7 +509,7 @@ class _ListItem implements SelectableItem { final File file; final Account account; final String previewUrl; - final VoidCallback? _onTap; + final VoidCallback? onTap; } enum _SelectionMenuOption { diff --git a/app/lib/widget/photo_list_item.dart b/app/lib/widget/photo_list_item.dart index 1adba821..748c49ba 100644 --- a/app/lib/widget/photo_list_item.dart +++ b/app/lib/widget/photo_list_item.dart @@ -1,12 +1,194 @@ 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/material.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:intl/intl.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/cache_manager_util.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/local_file.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/mobile/android/content_uri_image_provider.dart'; import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; + +abstract class PhotoListFileItem extends SelectableItem { + const PhotoListFileItem({ + required this.fileIndex, + required this.file, + required this.shouldShowFavoriteBadge, + }); + + @override + get isTappable => true; + + @override + get isSelectable => true; + + @override + operator ==(Object other) => + other is PhotoListFileItem && file.compareServerIdentity(other.file); + + @override + get hashCode => file.path.hashCode; + + @override + toString() => "$runtimeType {" + "fileIndex: $fileIndex, " + "file: ${file.path}, " + "shouldShowFavoriteBadge: $shouldShowFavoriteBadge, " + "}"; + + final int fileIndex; + final File file; + final bool shouldShowFavoriteBadge; +} + +class PhotoListImageItem extends PhotoListFileItem { + const PhotoListImageItem({ + required int fileIndex, + required File file, + required this.account, + required this.previewUrl, + required bool shouldShowFavoriteBadge, + }) : super( + fileIndex: fileIndex, + file: file, + shouldShowFavoriteBadge: shouldShowFavoriteBadge, + ); + + @override + buildWidget(BuildContext context) => PhotoListImage( + account: account, + previewUrl: previewUrl, + isGif: file.contentType == "image/gif", + isFavorite: shouldShowFavoriteBadge && file.isFavorite == true, + ); + + final Account account; + final String previewUrl; +} + +class PhotoListVideoItem extends PhotoListFileItem { + const PhotoListVideoItem({ + required int fileIndex, + required File file, + required this.account, + required this.previewUrl, + required bool shouldShowFavoriteBadge, + }) : super( + fileIndex: fileIndex, + file: file, + shouldShowFavoriteBadge: shouldShowFavoriteBadge, + ); + + @override + buildWidget(BuildContext context) => PhotoListVideo( + account: account, + previewUrl: previewUrl, + isFavorite: shouldShowFavoriteBadge && file.isFavorite == true, + ); + + final Account account; + final String previewUrl; +} + +class PhotoListDateItem extends SelectableItem { + const PhotoListDateItem({ + required this.date, + this.isMonthOnly = false, + }); + + @override + get isTappable => false; + + @override + get isSelectable => false; + + @override + get staggeredTile => const StaggeredTile.extent(99, 32); + + @override + buildWidget(BuildContext context) => PhotoListDate( + date: date, + isMonthOnly: isMonthOnly, + ); + + final DateTime date; + final bool isMonthOnly; +} + +abstract class PhotoListLocalFileItem extends SelectableItem { + const PhotoListLocalFileItem({ + required this.fileIndex, + required this.file, + }); + + @override + get isTappable => true; + + @override + get isSelectable => true; + + @override + operator ==(Object other) => + other is PhotoListLocalFileItem && file.compareIdentity(other.file); + + @override + get hashCode => file.identityHashCode; + + final int fileIndex; + final LocalFile file; +} + +class PhotoListLocalImageItem extends PhotoListLocalFileItem { + const PhotoListLocalImageItem({ + required int fileIndex, + required LocalFile file, + }) : super( + fileIndex: fileIndex, + file: file, + ); + + @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), + ), + ); + }, + ), + ), + ), + ); + } +} class PhotoListImage extends StatelessWidget { const PhotoListImage({ diff --git a/app/lib/widget/selectable_item_stream_list_mixin.dart b/app/lib/widget/selectable_item_stream_list_mixin.dart index 1206c7d8..c61d5e43 100644 --- a/app/lib/widget/selectable_item_stream_list_mixin.dart +++ b/app/lib/widget/selectable_item_stream_list_mixin.dart @@ -14,9 +14,11 @@ import 'package:nc_photos/widget/measurable_item_list.dart'; import 'package:nc_photos/widget/selectable.dart'; abstract class SelectableItem { + const SelectableItem(); + Widget buildWidget(BuildContext context); - VoidCallback? get onTap => null; + bool get isTappable => false; bool get isSelectable => false; StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1); } @@ -28,6 +30,9 @@ mixin SelectableItemStreamListMixin on State { _keyboardFocus.requestFocus(); } + @protected + void onItemTap(SelectableItem item, int index); + @protected Widget buildItemStreamListOuter( BuildContext context, { @@ -170,7 +175,9 @@ mixin SelectableItemStreamListMixin on State { } } } else { - item.onTap?.call(); + if (item.isTappable) { + onItemTap(item, index); + } } } diff --git a/app/lib/widget/smart_album_browser.dart b/app/lib/widget/smart_album_browser.dart index 847bb9d1..f94a884b 100644 --- a/app/lib/widget/smart_album_browser.dart +++ b/app/lib/widget/smart_album_browser.dart @@ -12,6 +12,7 @@ import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/preprocess_album.dart'; @@ -76,6 +77,11 @@ class _SmartAlbumBrowserState extends State ); } + @override + onItemTap(SelectableItem item, int index) { + item.as<_ListItem>()?.onTap?.call(); + } + @override @protected get canEdit => false; @@ -324,11 +330,11 @@ enum _SelectionMenuOption { abstract class _ListItem implements SelectableItem { const _ListItem({ required this.index, - VoidCallback? onTap, - }) : _onTap = onTap; + this.onTap, + }); @override - get onTap => _onTap; + get isTappable => onTap != null; @override get isSelectable => true; @@ -345,7 +351,7 @@ abstract class _ListItem implements SelectableItem { final int index; - final VoidCallback? _onTap; + final VoidCallback? onTap; } abstract class _FileListItem extends _ListItem { diff --git a/app/lib/widget/trashbin_browser.dart b/app/lib/widget/trashbin_browser.dart index ac6cb225..feda3334 100644 --- a/app/lib/widget/trashbin_browser.dart +++ b/app/lib/widget/trashbin_browser.dart @@ -1,23 +1,25 @@ +import 'dart:ui'; + 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/ls_trashbin.dart'; +import 'package:nc_photos/compute_queue.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/di_container.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/language_util.dart' as language_util; +import 'package:nc_photos/object_extension.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/use_case/restore_trashbin.dart'; +import 'package:nc_photos/widget/builder/photo_list_item_builder.dart'; import 'package:nc_photos/widget/empty_list_indicator.dart'; import 'package:nc_photos/widget/handler/remove_selection_handler.dart'; import 'package:nc_photos/widget/photo_list_item.dart'; @@ -82,6 +84,18 @@ class _TrashbinBrowserState extends State ); } + @override + onItemTap(SelectableItem item, int index) { + item.as()?.run((fileItem) { + Navigator.pushNamed( + context, + TrashbinViewer.routeName, + arguments: TrashbinViewerArguments( + widget.account, _backingFiles, fileItem.fileIndex), + ); + }); + } + void _initBloc() { _bloc = LsTrashbinBloc.of(widget.account); if (_bloc.state is LsTrashbinBlocInit) { @@ -99,7 +113,9 @@ class _TrashbinBrowserState extends State } Widget _buildContent(BuildContext context, LsTrashbinBlocState state) { - if (state is LsTrashbinBlocSuccess && itemStreamListItems.isEmpty) { + if (state is LsTrashbinBlocSuccess && + !_buildItemQueue.isProcessing && + itemStreamListItems.isEmpty) { return Column( children: [ AppBar( @@ -135,7 +151,7 @@ class _TrashbinBrowserState extends State ), ), ), - if (state is LsTrashbinBlocLoading) + if (state is LsTrashbinBlocLoading || _buildItemQueue.isProcessing) const Align( alignment: Alignment.bottomCenter, child: LinearProgressIndicator(), @@ -250,12 +266,6 @@ class _TrashbinBrowserState extends State } } - void _onItemTap(int index) { - Navigator.pushNamed(context, TrashbinViewer.routeName, - arguments: - TrashbinViewerArguments(widget.account, _backingFiles, index)); - } - void _onEmptyTrashPressed(BuildContext context) async { showDialog( context: context, @@ -282,7 +292,7 @@ class _TrashbinBrowserState extends State duration: k.snackBarDurationShort, )); final selectedFiles = selectedListItems - .whereType<_FileListItem>() + .whereType() .map((e) => e.file) .toList(); setState(() { @@ -335,51 +345,29 @@ class _TrashbinBrowserState extends State } void _transformItems(List files) { - _backingFiles = files.sorted((a, b) { - if (a.trashbinDeletionTime == null && b.trashbinDeletionTime == null) { - // ? - return 0; - } else if (a.trashbinDeletionTime == null) { - return -1; - } else if (b.trashbinDeletionTime == null) { - return 1; - } else { - return b.trashbinDeletionTime!.compareTo(a.trashbinDeletionTime!); - } - }); - - itemStreamListItems = () sync* { - for (int i = 0; i < _backingFiles.length; ++i) { - final f = _backingFiles[i]; - - 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}"); + _buildItemQueue.addJob( + PhotoListItemBuilderArguments( + widget.account, + files, + sorter: _fileSorter, + locale: language_util.getSelectedLocale() ?? + PlatformDispatcher.instance.locale, + ), + buildPhotoListItem, + (result) { + if (mounted) { + setState(() { + _backingFiles = result.backingFiles; + itemStreamListItems = result.listItems; + }); } - } - }() - .toList(); + }, + ); } Future _deleteSelected() async { final selectedFiles = selectedListItems - .whereType<_FileListItem>() + .whereType() .map((e) => e.file) .toList(); setState(() { @@ -404,87 +392,15 @@ class _TrashbinBrowserState extends State var _backingFiles = []; + final _buildItemQueue = + ComputeQueue(); + var _thumbZoomLevel = 0; int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); static final _log = Logger("widget.trashbin_browser._TrashbinBrowserState"); } -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); - - @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 _AppBarMenuOption { empty, } @@ -492,3 +408,16 @@ enum _AppBarMenuOption { enum _SelectionAppBarMenuOption { delete, } + +int _fileSorter(File a, File b) { + if (a.trashbinDeletionTime == null && b.trashbinDeletionTime == null) { + // ? + return 0; + } else if (a.trashbinDeletionTime == null) { + return -1; + } else if (b.trashbinDeletionTime == null) { + return 1; + } else { + return b.trashbinDeletionTime!.compareTo(a.trashbinDeletionTime!); + } +} diff --git a/app/test/entity/webdav_response_parser_test.dart b/app/test/entity/webdav_response_parser_test.dart index ee53d80a..64aa77da 100644 --- a/app/test/entity/webdav_response_parser_test.dart +++ b/app/test/entity/webdav_response_parser_test.dart @@ -18,7 +18,7 @@ void main() { }); } -void _files() { +Future _files() async { final xml = XmlDocument.parse(""" """); - final results = WebdavResponseParser().parseFiles(xml); + final results = await WebdavResponseParser().parseFiles(xml); expect(results, [ File( path: "remote.php/dav/files/admin/Nextcloud intro.mp4", @@ -57,7 +57,7 @@ void _files() { ]); } -void _files404props() { +Future _files404props() async { final xml = XmlDocument.parse(""" """); - final results = WebdavResponseParser().parseFiles(xml); + final results = await WebdavResponseParser().parseFiles(xml); expect(results, [ File( path: "remote.php/dav/files/admin/Nextcloud intro.mp4", @@ -103,7 +103,7 @@ void _files404props() { ]); } -void _filesMetadata() { +Future _filesMetadata() async { final xml = XmlDocument.parse(""" """); - final results = WebdavResponseParser().parseFiles(xml); + final results = await WebdavResponseParser().parseFiles(xml); expect(results, [ File( path: "remote.php/dav/files/admin/Photos/Nextcloud community.jpg", @@ -149,7 +149,7 @@ void _filesMetadata() { ]); } -void _filesIsArchived() { +Future _filesIsArchived() async { final xml = XmlDocument.parse(""" """); - final results = WebdavResponseParser().parseFiles(xml); + final results = await WebdavResponseParser().parseFiles(xml); expect(results, [ File( path: "remote.php/dav/files/admin/Photos/Nextcloud community.jpg", @@ -188,7 +188,7 @@ void _filesIsArchived() { ]); } -void _filesOverrideDateTime() { +Future _filesOverrideDateTime() async { final xml = XmlDocument.parse(""" """); - final results = WebdavResponseParser().parseFiles(xml); + final results = await WebdavResponseParser().parseFiles(xml); expect(results, [ File( path: "remote.php/dav/files/admin/Photos/Nextcloud community.jpg", @@ -227,7 +227,7 @@ void _filesOverrideDateTime() { ]); } -void _filesMultiple() { +Future _filesMultiple() async { final xml = XmlDocument.parse(""" """); - final results = WebdavResponseParser().parseFiles(xml); + final results = await WebdavResponseParser().parseFiles(xml); expect(results, [ File( path: "remote.php/dav/files/admin/Nextcloud intro.mp4", @@ -298,7 +298,7 @@ void _filesMultiple() { ]); } -void _filesDir() { +Future _filesDir() async { final xml = XmlDocument.parse(""" """); - final results = WebdavResponseParser().parseFiles(xml); + final results = await WebdavResponseParser().parseFiles(xml); expect(results, [ File( path: "remote.php/dav/files/admin/Photos", @@ -343,7 +343,7 @@ void _filesDir() { ]); } -void _filesServerHostedInSubdir() { +Future _filesServerHostedInSubdir() async { final xml = XmlDocument.parse(""" """); - final results = WebdavResponseParser().parseFiles(xml); + final results = await WebdavResponseParser().parseFiles(xml); expect(results, [ File( path: "remote.php/dav/files/admin/Nextcloud intro.mp4",