Merge branch 'isolate' into dev

This commit is contained in:
Ming Ming 2022-06-08 19:09:28 +08:00
commit 4df248e93a
26 changed files with 988 additions and 866 deletions

View file

@ -35,7 +35,12 @@ import 'package:nc_photos/pref.dart';
import 'package:nc_photos/pref_util.dart' as pref_util; import 'package:nc_photos/pref_util.dart' as pref_util;
Future<void> initAppLaunch() async { Future<void> initAppLaunch() async {
_initLog(); if (_hasInitedInThisIsolate) {
_log.warning("[initAppLaunch] Already initialized in this isolate");
return;
}
initLog();
_initKiwi(); _initKiwi();
await _initPref(); await _initPref();
await _initAccountPrefs(); await _initAccountPrefs();
@ -46,9 +51,15 @@ Future<void> initAppLaunch() async {
_initSelfSignedCertManager(); _initSelfSignedCertManager();
} }
_initDiContainer(); _initDiContainer();
_hasInitedInThisIsolate = true;
}
void initLog() {
if (_hasInitedInThisIsolate) {
return;
} }
void _initLog() {
Logger.root.level = kReleaseMode ? Level.WARNING : Level.ALL; Logger.root.level = kReleaseMode ? Level.WARNING : Level.ALL;
Logger.root.onRecord.listen((record) { Logger.root.onRecord.listen((record) {
// dev.log( // dev.log(
@ -162,6 +173,7 @@ void _initDiContainer() {
} }
final _log = Logger("app_init"); final _log = Logger("app_init");
var _hasInitedInThisIsolate = false;
class _BlocObserver extends BlocObserver { class _BlocObserver extends BlocObserver {
@override @override

View file

@ -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.dart';
import 'package:flutter_gen/gen_l10n/app_localizations_en.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/widget/my_app.dart'; import 'package:nc_photos/widget/my_app.dart';
/// Simplify localized string access /// Simplify localized string access
class L10n { class L10n {
static AppLocalizations global() => AppLocalizations.of(MyApp.globalContext)!; 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");
} }

View file

@ -0,0 +1,40 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
typedef ComputeQueueCallback<U> = void Function(U result);
/// Compute the jobs in the queue one by one sequentially in isolate
class ComputeQueue<T, U> {
void addJob(T event, ComputeCallback<T, U> callback,
ComputeQueueCallback<U> onResult) {
_queue.addLast(_Job(event, callback, onResult));
if (_queue.length == 1) {
_startProcessing();
}
}
bool get isProcessing => _queue.isNotEmpty;
Future<void> _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<T, U>>();
}
class _Job<T, U> {
const _Job(this.message, this.callback, this.onResult);
final T message;
final ComputeCallback<T, U> callback;
final ComputeQueueCallback<U> onResult;
}

View file

@ -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/entity/webdav_response_parser.dart';
import 'package:nc_photos/exception.dart'; import 'package:nc_photos/exception.dart';
import 'package:nc_photos/iterable_extension.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/object_extension.dart';
import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/or_null.dart';
import 'package:nc_photos/touch_token_manager.dart'; import 'package:nc_photos/touch_token_manager.dart';
import 'package:nc_photos/use_case/compat/v32.dart'; import 'package:nc_photos/use_case/compat/v32.dart';
import 'package:path/path.dart' as path_lib; import 'package:path/path.dart' as path_lib;
import 'package:tuple/tuple.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:xml/xml.dart'; import 'package:xml/xml.dart';
@ -64,7 +64,7 @@ class FileWebdavDataSource implements FileDataSource {
} }
final xml = XmlDocument.parse(response.body); final xml = XmlDocument.parse(response.body);
var files = WebdavResponseParser().parseFiles(xml); var files = await WebdavResponseParser().parseFiles(xml);
// _log.fine("[list] Parsed files: [$files]"); // _log.fine("[list] Parsed files: [$files]");
bool hasNoMediaMarker = false; bool hasNoMediaMarker = false;
files = files files = files
@ -269,9 +269,9 @@ class FileAppDbDataSource implements FileDataSource {
const FileAppDbDataSource(this.appDb); const FileAppDbDataSource(this.appDb);
@override @override
list(Account account, File dir) { list(Account account, File dir) async {
_log.info("[list] ${dir.path}"); _log.info("[list] ${dir.path}");
return appDb.use( final dbItems = await appDb.use(
(db) => db.transaction( (db) => db.transaction(
[AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadOnly), [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadOnly),
(transaction) async { (transaction) async {
@ -284,21 +284,31 @@ class FileAppDbDataSource implements FileDataSource {
} }
final dirEntry = final dirEntry =
AppDbDirEntry.fromJson(dirItem.cast<String, dynamic>()); AppDbDirEntry.fromJson(dirItem.cast<String, dynamic>());
final entries = await dirEntry.children.mapStream((c) async { return Tuple2(
dirEntry.dir,
await Future.wait(
dirEntry.children.map((c) async {
final fileItem = await fileStore final fileItem = await fileStore
.getObject(AppDbFile2Entry.toPrimaryKey(account, c)) as Map?; .getObject(AppDbFile2Entry.toPrimaryKey(account, c)) as Map?;
if (fileItem == null) { if (fileItem == null) {
_log.warning( _log.warning(
"[list] Missing file ($c) in db for dir: ${logFilename(dir.path)}"); "[list] Missing file ($c) in db for dir: ${logFilename(dir.path)}");
throw CacheNotFoundException("No entry for dir child: $c"); throw CacheNotFoundException("No entry for dir child: $c");
} else {
return fileItem;
} }
return AppDbFile2Entry.fromJson(fileItem.cast<String, dynamic>()); }),
}, k.simultaneousQuery).toList(); eagerError: true,
// we need to add dir to match the remote query ),
return [dirEntry.dir] + );
entries.map((e) => e.file).where((f) => _validateFile(f)).toList();
}, },
); );
// we need to add dir to match the remote query
return [
dbItems.item1,
...(await dbItems.item2.computeAll(_covertAppDbFile2Entry))
.where((f) => _validateFile(f))
];
} }
@override @override
@ -647,20 +657,16 @@ class FileForwardCacheManager {
// query other files // query other files
if (needQuery.isNotEmpty) { if (needQuery.isNotEmpty) {
final fileItems = await appDb.use( final dbItems = await appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly),
(transaction) async { (transaction) async {
final store = transaction.objectStore(AppDb.file2StoreName); final store = transaction.objectStore(AppDb.file2StoreName);
return await needQuery return await Future.wait(needQuery.map((id) =>
.mapStream( store.getObject(AppDbFile2Entry.toPrimaryKey(account, id))));
(id) => store
.getObject(AppDbFile2Entry.toPrimaryKey(account, id)),
k.simultaneousQuery)
.toList();
}, },
); );
files.addAll(fileItems.cast<Map?>().whereType<Map>().map( files.addAll(
(i) => AppDbFile2Entry.fromJson(i.cast<String, dynamic>()).file)); await dbItems.whereType<Map>().computeAll(_covertAppDbFile2Entry));
} }
_fileCache.addEntries(files.map((f) => MapEntry(f.fileId!, f))); _fileCache.addEntries(files.map((f) => MapEntry(f.fileId!, f)));
_log.info( _log.info(
@ -692,3 +698,6 @@ bool _validateFile(File f) {
// See: https://gitlab.com/nkming2/nc-photos/-/issues/9 // See: https://gitlab.com/nkming2/nc-photos/-/issues/9
return f.lastModified != null; return f.lastModified != null;
} }
File _covertAppDbFile2Entry(Map json) =>
AppDbFile2Entry.fromJson(json.cast<String, dynamic>()).file;

View file

@ -9,6 +9,9 @@ abstract class LocalFile with EquatableMixin {
/// careful that this does NOT mean that the two objects are identical /// careful that this does NOT mean that the two objects are identical
bool compareIdentity(LocalFile other); bool compareIdentity(LocalFile other);
/// hashCode to be used with [compareIdentity]
int get identityHashCode;
String get logTag; String get logTag;
String get filename; String get filename;
@ -41,6 +44,9 @@ class LocalUriFile with EquatableMixin implements LocalFile {
} }
} }
@override
get identityHashCode => uri.hashCode;
@override @override
toString() { toString() {
var product = "$runtimeType {" var product = "$runtimeType {"

View file

@ -1,7 +1,9 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.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/ci_string.dart';
import 'package:nc_photos/entity/favorite.dart'; import 'package:nc_photos/entity/favorite.dart';
import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file.dart';
@ -11,18 +13,30 @@ import 'package:nc_photos/string_extension.dart';
import 'package:xml/xml.dart'; import 'package:xml/xml.dart';
class WebdavResponseParser { class WebdavResponseParser {
List<File> parseFiles(XmlDocument xml) => _parse<File>(xml, _toFile); Future<List<File>> parseFiles(XmlDocument xml) =>
compute(_parseFilesIsolate, xml);
List<Favorite> parseFavorites(XmlDocument xml) => Future<List<Favorite>> parseFavorites(XmlDocument xml) =>
_parse<Favorite>(xml, _toFavorite); compute(_parseFavoritesIsolate, xml);
List<Tag> parseTags(XmlDocument xml) => _parse<Tag>(xml, _toTag); Future<List<Tag>> parseTags(XmlDocument xml) =>
compute(_parseTagsIsolate, xml);
List<TaggedFile> parseTaggedFiles(XmlDocument xml) => Future<List<TaggedFile>> parseTaggedFiles(XmlDocument xml) =>
_parse<TaggedFile>(xml, _toTaggedFile); compute(_parseTaggedFilesIsolate, xml);
Map<String, String> get namespaces => _namespaces; Map<String, String> get namespaces => _namespaces;
List<File> _parseFiles(XmlDocument xml) => _parse<File>(xml, _toFile);
List<Favorite> _parseFavorites(XmlDocument xml) =>
_parse<Favorite>(xml, _toFavorite);
List<Tag> _parseTags(XmlDocument xml) => _parse<Tag>(xml, _toTag);
List<TaggedFile> _parseTaggedFiles(XmlDocument xml) =>
_parse<TaggedFile>(xml, _toTaggedFile);
List<T> _parse<T>(XmlDocument xml, T? Function(XmlElement) mapper) { List<T> _parse<T>(XmlDocument xml, T? Function(XmlElement) mapper) {
_namespaces = _parseNamespaces(xml); _namespaces = _parseNamespaces(xml);
final body = () { final body = () {
@ -501,3 +515,23 @@ extension on XmlElement {
.any((element) => element.key == name.prefix)); .any((element) => element.key == name.prefix));
} }
} }
List<File> _parseFilesIsolate(XmlDocument xml) {
app_init.initLog();
return WebdavResponseParser()._parseFiles(xml);
}
List<Favorite> _parseFavoritesIsolate(XmlDocument xml) {
app_init.initLog();
return WebdavResponseParser()._parseFavorites(xml);
}
List<Tag> _parseTagsIsolate(XmlDocument xml) {
app_init.initLog();
return WebdavResponseParser()._parseTags(xml);
}
List<TaggedFile> _parseTaggedFilesIsolate(XmlDocument xml) {
app_init.initLog();
return WebdavResponseParser()._parseTaggedFiles(xml);
}

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:nc_photos/list_extension.dart';
import 'package:nc_photos/override_comparator.dart'; import 'package:nc_photos/override_comparator.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
@ -23,56 +24,6 @@ extension IterableExtension<T> on Iterable<T> {
} }
} }
/// 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<U> mapStream<U>(
Future<U> Function(T element) fn, [
simultaneousFuture = 1,
]) async* {
final container = <Future<U>>[];
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<void> forEachAsync(
Future Function(T element) fn, [
simultaneousFuture = 1,
]) async {
final container = <Future>[];
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<Tuple2<int, T>> withIndex() => mapWithIndex((i, e) => Tuple2(i, e)); Iterable<Tuple2<int, T>> withIndex() => mapWithIndex((i, e) => Tuple2(i, e));
/// Whether the collection contains an element equal to [element] using the /// Whether the collection contains an element equal to [element] using the
@ -114,4 +65,34 @@ extension IterableExtension<T> on Iterable<T> {
yield e; yield e;
} }
} }
Future<List<U>> computeAll<U>(ComputeCallback<T, U> callback) async {
return await compute(
_computeAllImpl<T, U>, _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<T> asList() {
if (this is List) {
return this as List<T>;
} else {
return toList();
}
}
}
class _ComputeAllMessage<T, U> {
const _ComputeAllMessage(this.callback, this.data);
final ComputeCallback<T, U> callback;
final List<T> data;
}
Future<List<U>> _computeAllImpl<T, U>(_ComputeAllMessage<T, U> message) async {
final result = await Future.wait(
message.data.map((e) async => await message.callback(e)));
return result;
} }

View file

@ -36,6 +36,3 @@ const coverSize = 512;
/// AppDb lock ID /// AppDb lock ID
const appDbLockId = 1; const appDbLockId = 1;
/// Number of async query task that can be called simultaneously
const simultaneousQuery = 20;

View file

@ -20,4 +20,7 @@ extension ObjectExtension<T> on T {
Future<U> runFuture<U>(FutureOr<U> Function(T obj) fn) async { Future<U> runFuture<U>(FutureOr<U> Function(T obj) fn) async {
return await fn(this); return await fn(this);
} }
/// Cast this as U, or null if this is not an object of U
U? as<U>() => this is U ? this as U : null;
} }

View file

@ -1,11 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:idb_shim/idb_client.dart'; import 'package:idb_shim/idb_client.dart';
import 'package:nc_photos/account.dart'; import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.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 { class FindFile {
FindFile(this._c) : assert(require(_c)); FindFile(this._c) : assert(require(_c));
@ -25,27 +23,22 @@ class FindFile {
(db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly),
(transaction) async { (transaction) async {
final fileStore = transaction.objectStore(AppDb.file2StoreName); final fileStore = transaction.objectStore(AppDb.file2StoreName);
return await fileIds return await Future.wait(fileIds.map((id) =>
.mapStream( fileStore.getObject(AppDbFile2Entry.toPrimaryKey(account, id))));
(id) => fileStore
.getObject(AppDbFile2Entry.toPrimaryKey(account, id)),
k.simultaneousQuery)
.toList();
}, },
); );
final fileMap = await compute(_covertFileMap, dbItems);
final files = <File>[]; final files = <File>[];
for (final pair in zip([fileIds, dbItems])) { for (final id in fileIds) {
final dbItem = pair[1] as Map?; final f = fileMap[id];
if (dbItem == null) { if (f == null) {
if (onFileNotFound == null) { if (onFileNotFound == null) {
throw StateError("File ID not found: ${pair[0]}"); throw StateError("File ID not found: $id");
} else { } else {
onFileNotFound(pair[0] as int); onFileNotFound(id);
} }
} else { } else {
final dbEntry = files.add(f);
AppDbFile2Entry.fromJson(dbItem.cast<String, dynamic>());
files.add(dbEntry.file);
} }
} }
return files; return files;
@ -53,3 +46,10 @@ class FindFile {
final DiContainer _c; final DiContainer _c;
} }
Map<int, File> _covertFileMap(List<Object?> dbItems) {
return Map.fromEntries(dbItems
.whereType<Map>()
.map((j) => AppDbFile2Entry.fromJson(j.cast<String, dynamic>()).file)
.map((f) => MapEntry(f.fileId!, f)));
}

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:idb_shim/idb_client.dart'; import 'package:idb_shim/idb_client.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:nc_photos/account.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.dart';
import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/item.dart';
import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/album/provider.dart';
import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/k.dart' as k;
/// Resync files inside an album with the file db /// Resync files inside an album with the file db
class ResyncAlbum { class ResyncAlbum {
@ -20,31 +20,21 @@ class ResyncAlbum {
"Resync only make sense for static albums: ${album.name}"); "Resync only make sense for static albums: ${album.name}");
} }
final items = AlbumStaticProvider.of(album).items; final items = AlbumStaticProvider.of(album).items;
final fileIds = final dbItems = await appDb.use(
items.whereType<AlbumFileItem>().map((i) => i.file.fileId!).toList();
final dbItems = Map.fromEntries(await appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly),
(transaction) async { (transaction) async {
final store = transaction.objectStore(AppDb.file2StoreName); final store = transaction.objectStore(AppDb.file2StoreName);
return await fileIds return await Future.wait(items.whereType<AlbumFileItem>().map((i) =>
.mapStream( store.getObject(
(id) async => MapEntry( AppDbFile2Entry.toPrimaryKey(account, i.file.fileId!))));
id,
await store.getObject(
AppDbFile2Entry.toPrimaryKey(account, id)) as Map?,
),
k.simultaneousQuery)
.toList();
}, },
)); );
final fileMap = await compute(_covertFileMap, dbItems);
return items.map((i) { return items.map((i) {
if (i is AlbumFileItem) { if (i is AlbumFileItem) {
try { try {
final dbItem = dbItems[i.file.fileId]!;
final dbEntry =
AppDbFile2Entry.fromJson(dbItem.cast<String, dynamic>());
return i.copyWith( return i.copyWith(
file: dbEntry.file, file: fileMap[i.file.fileId]!,
); );
} catch (e, stackTrace) { } catch (e, stackTrace) {
_log.shout( _log.shout(
@ -63,3 +53,10 @@ class ResyncAlbum {
static final _log = Logger("use_case.resync_album.ResyncAlbum"); static final _log = Logger("use_case.resync_album.ResyncAlbum");
} }
Map<int, File> _covertFileMap(List<Object?> dbItems) {
return Map.fromEntries(dbItems
.whereType<Map>()
.map((j) => AppDbFile2Entry.fromJson(j.cast<String, dynamic>()).file)
.map((f) => MapEntry(f.fileId!, f)));
}

View file

@ -4,6 +4,7 @@ import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/iterable_extension.dart';
class ScanDirOffline { class ScanDirOffline {
ScanDirOffline(this._c) : assert(require(_c)); ScanDirOffline(this._c) : assert(require(_c));
@ -11,12 +12,12 @@ class ScanDirOffline {
static bool require(DiContainer c) => DiContainer.has(c, DiType.appDb); static bool require(DiContainer c) => DiContainer.has(c, DiType.appDb);
/// List all files under a dir recursively from the local DB /// List all files under a dir recursively from the local DB
Future<List<File>> call( Future<Iterable<File>> call(
Account account, Account account,
File root, { File root, {
bool isOnlySupportedFormat = true, bool isOnlySupportedFormat = true,
}) async { }) async {
return await _c.appDb.use( final dbItems = await _c.appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly),
(transaction) async { (transaction) async {
final store = transaction.objectStore(AppDb.file2StoreName); final store = transaction.objectStore(AppDb.file2StoreName);
@ -25,20 +26,25 @@ class ScanDirOffline {
AppDbFile2Entry.toStrippedPathIndexLowerKeyForDir(account, root), AppDbFile2Entry.toStrippedPathIndexLowerKeyForDir(account, root),
AppDbFile2Entry.toStrippedPathIndexUpperKeyForDir(account, root), AppDbFile2Entry.toStrippedPathIndexUpperKeyForDir(account, root),
); );
final product = <File>[]; return await index
await for (final c .openCursor(range: range, autoAdvance: false)
in index.openCursor(range: range, autoAdvance: false)) { .map((c) {
final e = AppDbFile2Entry.fromJson( final v = c.value as Map;
(c.value as Map).cast<String, dynamic>());
if (!isOnlySupportedFormat || file_util.isSupportedFormat(e.file)) {
product.add(e.file);
}
c.next(); c.next();
} return v;
return product; }).toList();
}, },
); );
final results = await dbItems.computeAll(_covertAppDbFile2Entry);
if (isOnlySupportedFormat) {
return results.where((f) => file_util.isSupportedFormat(f));
} else {
return results;
}
} }
final DiContainer _c; final DiContainer _c;
} }
File _covertAppDbFile2Entry(Map json) =>
AppDbFile2Entry.fromJson(json.cast<String, dynamic>()).file;

View file

@ -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/exception_util.dart' as exception_util;
import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/list_extension.dart'; 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/or_null.dart';
import 'package:nc_photos/pref.dart'; import 'package:nc_photos/pref.dart';
import 'package:nc_photos/session_storage.dart'; import 'package:nc_photos/session_storage.dart';
@ -117,6 +118,11 @@ class _AlbumBrowserState extends State<AlbumBrowser>
); );
} }
@override
onItemTap(SelectableItem item, int index) {
item.as<_ListItem>()?.onTap?.call();
}
@override @override
@protected @protected
get canEdit => _album?.albumFile?.isOwned(widget.account.username) == true; get canEdit => _album?.albumFile?.isOwned(widget.account.username) == true;
@ -888,19 +894,18 @@ enum _SelectionMenuOption {
abstract class _ListItem implements SelectableItem, DraggableItem { abstract class _ListItem implements SelectableItem, DraggableItem {
const _ListItem({ const _ListItem({
required this.index, required this.index,
VoidCallback? onTap, this.onTap,
DragTargetAccept<DraggableItem>? onDropBefore, DragTargetAccept<DraggableItem>? onDropBefore,
DragTargetAccept<DraggableItem>? onDropAfter, DragTargetAccept<DraggableItem>? onDropAfter,
VoidCallback? onDragStarted, VoidCallback? onDragStarted,
VoidCallback? onDragEndedAny, VoidCallback? onDragEndedAny,
}) : _onTap = onTap, }) : _onDropBefore = onDropBefore,
_onDropBefore = onDropBefore,
_onDropAfter = onDropAfter, _onDropAfter = onDropAfter,
_onDragStarted = onDragStarted, _onDragStarted = onDragStarted,
_onDragEndedAny = onDragEndedAny; _onDragEndedAny = onDragEndedAny;
@override @override
get onTap => _onTap; get isTappable => onTap != null;
@override @override
get isSelectable => true; get isSelectable => true;
@ -935,7 +940,7 @@ abstract class _ListItem implements SelectableItem, DraggableItem {
final int index; final int index;
final VoidCallback? _onTap; final VoidCallback? onTap;
final DragTargetAccept<DraggableItem>? _onDropBefore; final DragTargetAccept<DraggableItem>? _onDropBefore;
final DragTargetAccept<DraggableItem>? _onDropAfter; final DragTargetAccept<DraggableItem>? _onDropAfter;
final VoidCallback? _onDragStarted; final VoidCallback? _onDragStarted;

View file

@ -1,23 +1,25 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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:logging/logging.dart';
import 'package:nc_photos/account.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_db.dart';
import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/scan_account_dir.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/debug_util.dart';
import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.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/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/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/pref.dart';
import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart'; import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/update_property.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/empty_list_indicator.dart';
import 'package:nc_photos/widget/photo_list_item.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/photo_list_util.dart' as photo_list_util;
@ -81,6 +83,18 @@ class _ArchiveBrowserState extends State<ArchiveBrowser>
); );
} }
@override
onItemTap(SelectableItem item, int index) {
item.as<PhotoListFileItem>()?.run((fileItem) {
Navigator.pushNamed(
context,
Viewer.routeName,
arguments:
ViewerArguments(widget.account, _backingFiles, fileItem.fileIndex),
);
});
}
void _initBloc() { void _initBloc() {
if (_bloc.state is ScanAccountDirBlocInit) { if (_bloc.state is ScanAccountDirBlocInit) {
_log.info("[_initBloc] Initialize bloc"); _log.info("[_initBloc] Initialize bloc");
@ -96,7 +110,9 @@ class _ArchiveBrowserState extends State<ArchiveBrowser>
} }
Widget _buildContent(BuildContext context, ScanAccountDirBlocState state) { Widget _buildContent(BuildContext context, ScanAccountDirBlocState state) {
if (state is ScanAccountDirBlocSuccess && itemStreamListItems.isEmpty) { if (state is ScanAccountDirBlocSuccess &&
!_buildItemQueue.isProcessing &&
itemStreamListItems.isEmpty) {
return Column( return Column(
children: [ children: [
AppBar( AppBar(
@ -132,7 +148,8 @@ class _ArchiveBrowserState extends State<ArchiveBrowser>
), ),
), ),
), ),
if (state is ScanAccountDirBlocLoading) if (state is ScanAccountDirBlocLoading ||
_buildItemQueue.isProcessing)
const Align( const Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: LinearProgressIndicator(), child: LinearProgressIndicator(),
@ -207,11 +224,6 @@ class _ArchiveBrowserState extends State<ArchiveBrowser>
} }
} }
void _onItemTap(int index) {
Navigator.pushNamed(context, Viewer.routeName,
arguments: ViewerArguments(widget.account, _backingFiles, index));
}
Future<void> _onSelectionAppBarUnarchivePressed() async { Future<void> _onSelectionAppBarUnarchivePressed() async {
SnackBarManager().showSnackBar(SnackBar( SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global() content: Text(L10n.global()
@ -219,7 +231,7 @@ class _ArchiveBrowserState extends State<ArchiveBrowser>
duration: k.snackBarDurationShort, duration: k.snackBarDurationShort,
)); ));
final selectedFiles = selectedListItems final selectedFiles = selectedListItems
.whereType<_FileListItem>() .whereType<PhotoListFileItem>()
.map((e) => e.file) .map((e) => e.file)
.toList(); .toList();
setState(() { setState(() {
@ -254,37 +266,25 @@ class _ArchiveBrowserState extends State<ArchiveBrowser>
} }
void _transformItems(List<File> files) { void _transformItems(List<File> files) {
_backingFiles = files _buildItemQueue.addJob(
.where((f) => f.isArchived == true) PhotoListItemBuilderArguments(
.sorted(compareFileDateTimeDescending); widget.account,
files,
itemStreamListItems = () sync* { isArchived: true,
for (int i = 0; i < _backingFiles.length; ++i) { sorter: photoListFileDateTimeSorter,
final f = _backingFiles[i]; locale: language_util.getSelectedLocale() ??
PlatformDispatcher.instance.locale,
final previewUrl = api_util.getFilePreviewUrl(widget.account, f, ),
width: k.photoThumbSize, height: k.photoThumbSize); buildPhotoListItem,
if (file_util.isSupportedImageFormat(f)) { (result) {
yield _ImageListItem( if (mounted) {
file: f, setState(() {
account: widget.account, _backingFiles = result.backingFiles;
previewUrl: previewUrl, itemStreamListItems = result.listItems;
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() { void _reqQuery() {
@ -295,83 +295,11 @@ class _ArchiveBrowserState extends State<ArchiveBrowser>
var _backingFiles = <File>[]; var _backingFiles = <File>[];
final _buildItemQueue =
ComputeQueue<PhotoListItemBuilderArguments, PhotoListItemBuilderResult>();
var _thumbZoomLevel = 0; var _thumbZoomLevel = 0;
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
static final _log = Logger("widget.archive_browser._ArchiveBrowserState"); 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;
}

View file

@ -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<File> 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<File> backingFiles;
final List<SelectableItem> listItems;
final List<Album> 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<File> files) {
final s = Stopwatch()..start();
try {
return _fromSortedItems(account, _sortItems(files));
} finally {
_log.info("[call] Elapsed time: ${s.elapsedMilliseconds}ms");
}
}
List<File> _sortItems(List<File> 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<File> files) {
final today = DateTime.now();
final memoryAlbumHelper =
shouldBuildSmartAlbums ? MemoryAlbumHelper(today) : null;
final listItems = <SelectableItem>[];
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");
}

View file

@ -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/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k; 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/or_null.dart';
import 'package:nc_photos/pref.dart'; import 'package:nc_photos/pref.dart';
import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/share_handler.dart';
@ -111,6 +112,11 @@ class _DynamicAlbumBrowserState extends State<DynamicAlbumBrowser>
); );
} }
@override
onItemTap(SelectableItem item, int index) {
item.as<_ListItem>()?.onTap?.call();
}
@override @override
@protected @protected
get canEdit => _album?.albumFile?.isOwned(widget.account.username) == true; get canEdit => _album?.albumFile?.isOwned(widget.account.username) == true;
@ -654,11 +660,11 @@ enum _SelectionMenuOption {
abstract class _ListItem implements SelectableItem { abstract class _ListItem implements SelectableItem {
const _ListItem({ const _ListItem({
required this.index, required this.index,
VoidCallback? onTap, this.onTap,
}) : _onTap = onTap; });
@override @override
get onTap => _onTap; get isTappable => onTap != null;
@override @override
get isSelectable => true; get isSelectable => true;
@ -675,7 +681,7 @@ abstract class _ListItem implements SelectableItem {
final int index; final int index;
final VoidCallback? _onTap; final VoidCallback? onTap;
} }
abstract class _FileListItem extends _ListItem { abstract class _FileListItem extends _ListItem {

View file

@ -1,17 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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:logging/logging.dart';
import 'package:nc_photos/app_init.dart' as app_init;
import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/scan_local_dir.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/file_util.dart' as file_util;
import 'package:nc_photos/entity/local_file.dart'; import 'package:nc_photos/entity/local_file.dart';
import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/mobile/android/android_info.dart'; 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/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/platform/k.dart' as platform_k;
import 'package:nc_photos/pref.dart'; import 'package:nc_photos/pref.dart';
import 'package:nc_photos/share_handler.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/empty_list_indicator.dart';
import 'package:nc_photos/widget/handler/delete_local_selection_handler.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/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/photo_list_util.dart' as photo_list_util;
import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart';
import 'package:nc_photos/widget/selection_app_bar.dart'; import 'package:nc_photos/widget/selection_app_bar.dart';
@ -91,6 +93,17 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
); );
} }
@override
onItemTap(SelectableItem item, int index) {
item.as<PhotoListLocalFileItem>()?.run((fileItem) {
Navigator.pushNamed(
context,
LocalFileViewer.routeName,
arguments: LocalFileViewerArguments(_backingFiles, fileItem.fileIndex),
);
});
}
void _initBloc() { void _initBloc() {
if (_bloc.state is ScanLocalDirBlocInit) { if (_bloc.state is ScanLocalDirBlocInit) {
_log.info("[_initBloc] Initialize bloc"); _log.info("[_initBloc] Initialize bloc");
@ -123,6 +136,7 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
], ],
); );
} else if (state is ScanLocalDirBlocSuccess && } else if (state is ScanLocalDirBlocSuccess &&
!_buildItemQueue.isProcessing &&
itemStreamListItems.isEmpty) { itemStreamListItems.isEmpty) {
return Column( return Column(
children: [ children: [
@ -159,7 +173,7 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
), ),
), ),
), ),
if (state is ScanLocalDirBlocLoading) if (state is ScanLocalDirBlocLoading || _buildItemQueue.isProcessing)
const Align( const Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: LinearProgressIndicator(), child: LinearProgressIndicator(),
@ -237,7 +251,7 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
Future<void> _onSelectionSharePressed(BuildContext context) async { Future<void> _onSelectionSharePressed(BuildContext context) async {
final selected = selectedListItems final selected = selectedListItems
.whereType<_FileListItem>() .whereType<PhotoListLocalFileItem>()
.map((e) => e.file) .map((e) => e.file)
.toList(); .toList();
await ShareHandler( await ShareHandler(
@ -285,7 +299,7 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
} }
final selectedFiles = selectedListItems final selectedFiles = selectedListItems
.whereType<_FileListItem>() .whereType<PhotoListLocalFileItem>()
.map((e) => e.file) .map((e) => e.file)
.toList(); .toList();
setState(() { setState(() {
@ -294,30 +308,18 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
await const DeleteLocalSelectionHandler()(selectedFiles: selectedFiles); await const DeleteLocalSelectionHandler()(selectedFiles: selectedFiles);
} }
void _onItemTap(int index) {
Navigator.pushNamed(context, LocalFileViewer.routeName,
arguments: LocalFileViewerArguments(_backingFiles, index));
}
void _transformItems(List<LocalFile> files) { void _transformItems(List<LocalFile> files) {
// we use last modified here to keep newly enhanced photo at the top _buildItemQueue.addJob(
_backingFiles = _BuilderArguments(files),
files.stableSorted((a, b) => b.lastModified.compareTo(a.lastModified)); _buildPhotoListItem,
(result) {
itemStreamListItems = () sync* { setState(() {
for (int i = 0; i < _backingFiles.length; ++i) { _backingFiles = result.backingFiles;
final f = _backingFiles[i]; itemStreamListItems = result.listItems;
if (file_util.isSupportedImageMime(f.mime ?? "")) { });
yield _ImageListItem( },
file: f,
onTap: () => _onItemTap(i),
); );
} }
}
}()
.toList();
_log.info("[_transformItems] Length: ${itemStreamListItems.length}");
}
void _openInitialImage(String filename) { void _openInitialImage(String filename) {
final index = _backingFiles.indexWhere((f) => f.filename == filename); final index = _backingFiles.indexWhere((f) => f.filename == filename);
@ -361,6 +363,8 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
var _backingFiles = <LocalFile>[]; var _backingFiles = <LocalFile>[];
final _buildItemQueue = ComputeQueue<_BuilderArguments, _BuilderResult>();
var _isFirstRun = true; var _isFirstRun = true;
var _thumbZoomLevel = 0; var _thumbZoomLevel = 0;
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
@ -370,76 +374,67 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
Logger("widget.enhanced_photo_browser._EnhancedPhotoBrowserState"); 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 { enum _SelectionMenuOption {
delete, delete,
} }
class _BuilderResult {
const _BuilderResult(this.backingFiles, this.listItems);
final List<LocalFile> backingFiles;
final List<SelectableItem> listItems;
}
class _BuilderArguments {
const _BuilderArguments(this.files);
final List<LocalFile> files;
}
class _Builder {
_BuilderResult call(List<LocalFile> files) {
final s = Stopwatch()..start();
try {
return _fromSortedItems(_sortItems(files));
} finally {
_log.info("[call] Elapsed time: ${s.elapsedMilliseconds}ms");
}
}
List<LocalFile> _sortItems(List<LocalFile> 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<LocalFile> files) {
final listItems = <SelectableItem>[];
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);
}

View file

@ -1,23 +1,25 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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:kiwi/kiwi.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:nc_photos/account.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/app_localizations.dart';
import 'package:nc_photos/bloc/list_favorite.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/di_container.dart';
import 'package:nc_photos/download_handler.dart'; import 'package:nc_photos/download_handler.dart';
import 'package:nc_photos/entity/file.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/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/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/pref.dart';
import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/share_handler.dart';
import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.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/empty_list_indicator.dart';
import 'package:nc_photos/widget/handler/add_selection_to_album_handler.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/archive_selection_handler.dart';
@ -84,6 +86,18 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
); );
} }
@override
onItemTap(SelectableItem item, int index) {
item.as<PhotoListFileItem>()?.run((fileItem) {
Navigator.pushNamed(
context,
Viewer.routeName,
arguments:
ViewerArguments(widget.account, _backingFiles, fileItem.fileIndex),
);
});
}
void _initBloc() { void _initBloc() {
if (_bloc.state is ListFavoriteBlocInit) { if (_bloc.state is ListFavoriteBlocInit) {
_log.info("[_initBloc] Initialize bloc"); _log.info("[_initBloc] Initialize bloc");
@ -99,7 +113,9 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
} }
Widget _buildContent(BuildContext context, ListFavoriteBlocState state) { Widget _buildContent(BuildContext context, ListFavoriteBlocState state) {
if (state is ListFavoriteBlocSuccess && itemStreamListItems.isEmpty) { if (state is ListFavoriteBlocSuccess &&
!_buildItemQueue.isProcessing &&
itemStreamListItems.isEmpty) {
return Column( return Column(
children: [ children: [
AppBar( AppBar(
@ -142,7 +158,7 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
), ),
), ),
), ),
if (state is ListFavoriteBlocLoading) if (state is ListFavoriteBlocLoading || _buildItemQueue.isProcessing)
const Align( const Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: LinearProgressIndicator(), child: LinearProgressIndicator(),
@ -211,9 +227,7 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
minZoom: -1, minZoom: -1,
maxZoom: 2, maxZoom: 2,
onZoomChanged: (value) { onZoomChanged: (value) {
setState(() {
_setThumbZoomLevel(value.round()); _setThumbZoomLevel(value.round());
});
Pref().setHomePhotosZoomLevel(_thumbZoomLevel); Pref().setHomePhotosZoomLevel(_thumbZoomLevel);
}, },
), ),
@ -236,11 +250,6 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
} }
} }
void _onItemTap(int index) {
Navigator.pushNamed(context, Viewer.routeName,
arguments: ViewerArguments(widget.account, _backingFiles, index));
}
void _onRefreshSelected() { void _onRefreshSelected() {
_reqRefresh(); _reqRefresh();
} }
@ -265,7 +274,7 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
void _onSelectionSharePressed(BuildContext context) { void _onSelectionSharePressed(BuildContext context) {
final selected = selectedListItems final selected = selectedListItems
.whereType<_FileListItem>() .whereType<PhotoListFileItem>()
.map((e) => e.file) .map((e) => e.file)
.toList(); .toList();
ShareHandler( ShareHandler(
@ -283,7 +292,7 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
context: context, context: context,
account: widget.account, account: widget.account,
selectedFiles: selectedListItems selectedFiles: selectedListItems
.whereType<_FileListItem>() .whereType<PhotoListFileItem>()
.map((e) => e.file) .map((e) => e.file)
.toList(), .toList(),
clearSelection: () { clearSelection: () {
@ -298,7 +307,7 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
void _onSelectionDownloadPressed() { void _onSelectionDownloadPressed() {
final selected = selectedListItems final selected = selectedListItems
.whereType<_FileListItem>() .whereType<PhotoListFileItem>()
.map((e) => e.file) .map((e) => e.file)
.toList(); .toList();
DownloadHandler().downloadFiles(widget.account, selected); DownloadHandler().downloadFiles(widget.account, selected);
@ -309,7 +318,7 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
Future<void> _onSelectionArchivePressed(BuildContext context) async { Future<void> _onSelectionArchivePressed(BuildContext context) async {
final selectedFiles = selectedListItems final selectedFiles = selectedListItems
.whereType<_FileListItem>() .whereType<PhotoListFileItem>()
.map((e) => e.file) .map((e) => e.file)
.toList(); .toList();
setState(() { setState(() {
@ -323,7 +332,7 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
Future<void> _onSelectionDeletePressed(BuildContext context) async { Future<void> _onSelectionDeletePressed(BuildContext context) async {
final selectedFiles = selectedListItems final selectedFiles = selectedListItems
.whereType<_FileListItem>() .whereType<PhotoListFileItem>()
.map((e) => e.file) .map((e) => e.file)
.toList(); .toList();
setState(() { setState(() {
@ -336,46 +345,26 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
); );
} }
void _transformItems(List<File> files) { void _transformItems(List<File> files, {bool isSorted = false}) {
_backingFiles = files _buildItemQueue.addJob(
.where((f) => f.isArchived != true) PhotoListItemBuilderArguments(
.sorted(compareFileDateTimeDescending); widget.account,
files,
final isMonthOnly = _thumbZoomLevel < 0; sorter: isSorted ? null : photoListFileDateTimeSorter,
final dateHelper = photo_list_util.DateGroupHelper( grouper: PhotoListFileDateGrouper(isMonthOnly: _thumbZoomLevel < 0),
isMonthOnly: isMonthOnly, locale: language_util.getSelectedLocale() ??
); PlatformDispatcher.instance.locale,
itemStreamListItems = () sync* { ),
for (int i = 0; i < _backingFiles.length; ++i) { buildPhotoListItem,
final f = _backingFiles[i]; (result) {
final date = dateHelper.onFile(f); if (mounted) {
if (date != null) { setState(() {
yield _DateListItem(date: date, isMonthOnly: isMonthOnly); _backingFiles = result.backingFiles;
itemStreamListItems = result.listItems;
});
} }
},
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() { void _reqQuery() {
@ -397,9 +386,13 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
void _setThumbZoomLevel(int level) { void _setThumbZoomLevel(int level) {
final prevLevel = _thumbZoomLevel; final prevLevel = _thumbZoomLevel;
_thumbZoomLevel = level;
if ((prevLevel >= 0) != (level >= 0)) { 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<FavoriteBrowser>
var _backingFiles = <File>[]; var _backingFiles = <File>[];
final _buildItemQueue =
ComputeQueue<PhotoListItemBuilderArguments, PhotoListItemBuilderResult>();
var _thumbZoomLevel = 0; var _thumbZoomLevel = 0;
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
static final _log = Logger("widget.archive_browser._FavoriteBrowserState"); 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 { enum _SelectionMenuOption {
archive, archive,
delete, delete,

View file

@ -16,6 +16,7 @@ import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/k.dart' as k; 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/platform/features.dart' as features;
import 'package:nc_photos/pref.dart'; import 'package:nc_photos/pref.dart';
import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/snack_bar_manager.dart';
@ -82,6 +83,11 @@ class _HomeAlbumsState extends State<HomeAlbums>
); );
} }
@override
onItemTap(SelectableItem item, int index) {
item.as<_ListItem>()?.onTap?.call();
}
void _initBloc() { void _initBloc() {
if (_bloc.state is ListAlbumBlocInit) { if (_bloc.state is ListAlbumBlocInit) {
_log.info("[_initBloc] Initialize bloc"); _log.info("[_initBloc] Initialize bloc");
@ -547,6 +553,8 @@ abstract class _ListItem implements SelectableItem {
}) : _myOnTap = onTap; }) : _myOnTap = onTap;
@override @override
get isTappable => _myOnTap != null;
get onTap => _myOnTap; get onTap => _myOnTap;
@override @override

View file

@ -1,11 +1,9 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui'; import 'dart:ui';
import 'package:collection/collection.dart' show compareNatural;
import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:draggable_scrollbar/draggable_scrollbar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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:kiwi/kiwi.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:nc_photos/account.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/app_localizations.dart';
import 'package:nc_photos/bloc/bloc_util.dart' as bloc_util; import 'package:nc_photos/bloc/bloc_util.dart' as bloc_util;
import 'package:nc_photos/bloc/scan_account_dir.dart'; 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/di_container.dart';
import 'package:nc_photos/download_handler.dart'; import 'package:nc_photos/download_handler.dart';
import 'package:nc_photos/entity/album.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/event/event.dart';
import 'package:nc_photos/exception.dart'; import 'package:nc_photos/exception.dart';
import 'package:nc_photos/exception_util.dart' as exception_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/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/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/platform/k.dart' as platform_k;
import 'package:nc_photos/pref.dart'; import 'package:nc_photos/pref.dart';
import 'package:nc_photos/primitive.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/theme.dart';
import 'package:nc_photos/use_case/sync_favorite.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/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/add_selection_to_album_handler.dart';
import 'package:nc_photos/widget/handler/archive_selection_handler.dart'; import 'package:nc_photos/widget/handler/archive_selection_handler.dart';
import 'package:nc_photos/widget/handler/remove_selection_handler.dart'; import 'package:nc_photos/widget/handler/remove_selection_handler.dart';
@ -92,6 +93,18 @@ class _HomePhotosState extends State<HomePhotos>
); );
} }
@override
onItemTap(SelectableItem item, int index) {
item.as<PhotoListFileItem>()?.run((fileItem) {
Navigator.pushNamed(
context,
Viewer.routeName,
arguments:
ViewerArguments(widget.account, _backingFiles, fileItem.fileIndex),
);
});
}
void _initBloc() { void _initBloc() {
if (_bloc.state is ScanAccountDirBlocInit) { if (_bloc.state is ScanAccountDirBlocInit) {
_log.info("[_initBloc] Initialize bloc"); _log.info("[_initBloc] Initialize bloc");
@ -168,7 +181,8 @@ class _HomePhotosState extends State<HomePhotos>
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (state is ScanAccountDirBlocLoading) if (state is ScanAccountDirBlocLoading ||
_buildItemQueue.isProcessing)
const LinearProgressIndicator(), const LinearProgressIndicator(),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@ -254,9 +268,7 @@ class _HomePhotosState extends State<HomePhotos>
minZoom: -1, minZoom: -1,
maxZoom: 2, maxZoom: 2,
onZoomChanged: (value) { onZoomChanged: (value) {
setState(() {
_setThumbZoomLevel(value.round()); _setThumbZoomLevel(value.round());
});
Pref().setHomePhotosZoomLevel(_thumbZoomLevel); Pref().setHomePhotosZoomLevel(_thumbZoomLevel);
}, },
), ),
@ -329,11 +341,6 @@ class _HomePhotosState extends State<HomePhotos>
} }
} }
void _onItemTap(int index) {
Navigator.pushNamed(context, Viewer.routeName,
arguments: ViewerArguments(widget.account, _backingFiles, index));
}
void _onRefreshSelected() { void _onRefreshSelected() {
_hasFiredMetadataTask.value = false; _hasFiredMetadataTask.value = false;
_reqRefresh(); _reqRefresh();
@ -359,7 +366,7 @@ class _HomePhotosState extends State<HomePhotos>
void _onSelectionSharePressed(BuildContext context) { void _onSelectionSharePressed(BuildContext context) {
final selected = selectedListItems final selected = selectedListItems
.whereType<_FileListItem>() .whereType<PhotoListFileItem>()
.map((e) => e.file) .map((e) => e.file)
.toList(); .toList();
ShareHandler( ShareHandler(
@ -377,7 +384,7 @@ class _HomePhotosState extends State<HomePhotos>
context: context, context: context,
account: widget.account, account: widget.account,
selectedFiles: selectedListItems selectedFiles: selectedListItems
.whereType<_FileListItem>() .whereType<PhotoListFileItem>()
.map((e) => e.file) .map((e) => e.file)
.toList(), .toList(),
clearSelection: () { clearSelection: () {
@ -392,7 +399,7 @@ class _HomePhotosState extends State<HomePhotos>
void _onSelectionDownloadPressed() { void _onSelectionDownloadPressed() {
final selected = selectedListItems final selected = selectedListItems
.whereType<_FileListItem>() .whereType<PhotoListFileItem>()
.map((e) => e.file) .map((e) => e.file)
.toList(); .toList();
DownloadHandler().downloadFiles(widget.account, selected); DownloadHandler().downloadFiles(widget.account, selected);
@ -403,7 +410,7 @@ class _HomePhotosState extends State<HomePhotos>
Future<void> _onSelectionArchivePressed(BuildContext context) async { Future<void> _onSelectionArchivePressed(BuildContext context) async {
final selectedFiles = selectedListItems final selectedFiles = selectedListItems
.whereType<_FileListItem>() .whereType<PhotoListFileItem>()
.map((e) => e.file) .map((e) => e.file)
.toList(); .toList();
setState(() { setState(() {
@ -417,7 +424,7 @@ class _HomePhotosState extends State<HomePhotos>
Future<void> _onSelectionDeletePressed(BuildContext context) async { Future<void> _onSelectionDeletePressed(BuildContext context) async {
final selectedFiles = selectedListItems final selectedFiles = selectedListItems
.whereType<_FileListItem>() .whereType<PhotoListFileItem>()
.map((e) => e.file) .map((e) => e.file)
.toList(); .toList();
setState(() { setState(() {
@ -440,9 +447,7 @@ class _HomePhotosState extends State<HomePhotos>
} else if (ev.key == PrefKey.isPhotosTabSortByName) { } else if (ev.key == PrefKey.isPhotosTabSortByName) {
if (_bloc.state is! ScanAccountDirBlocInit) { if (_bloc.state is! ScanAccountDirBlocInit) {
_log.info("[_onPrefUpdated] Update view after changing sort option"); _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<HomePhotos>
} }
/// Transform a File list to grid items /// Transform a File list to grid items
void _transformItems(List<File> files) { void _transformItems(List<File> files, {bool isSorted = false}) {
if (!Pref().isPhotosTabSortByNameOr()) { _log.info("[_transformItems] Queue ${files.length} items");
_transformItemsByDate(files); final PhotoListItemSorter? sorter;
final PhotoListItemGrouper? grouper;
if (Pref().isPhotosTabSortByNameOr()) {
sorter = isSorted ? null : photoListFilenameSorter;
grouper = null;
} else { } else {
_transformItemsByName(files); sorter = isSorted ? null : photoListFileDateTimeSorter;
} grouper = PhotoListFileDateGrouper(isMonthOnly: _thumbZoomLevel < 0);
} }
void _transformItemsByName(List<File> files) { _buildItemQueue.addJob(
_backingFiles = files PhotoListItemBuilderArguments(
.where((f) => f.isArchived != true) widget.account,
.sorted((a, b) => compareNatural(b.filename, a.filename)); files,
sorter: sorter,
itemStreamListItems = () sync* { grouper: grouper,
for (int i = 0; i < _backingFiles.length; ++i) { shouldBuildSmartAlbums: true,
final item = _transformItemToListItem(i, _backingFiles[i]); shouldShowFavoriteBadge: true,
if (item != null) { locale: language_util.getSelectedLocale() ??
yield item; PlatformDispatcher.instance.locale,
),
buildPhotoListItem,
(result) {
if (mounted) {
setState(() {
_backingFiles = result.backingFiles;
itemStreamListItems = result.listItems;
_smartAlbums = result.smartAlbums;
});
} }
} },
}()
.toList();
}
void _transformItemsByDate(List<File> 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() { void _reqQuery() {
@ -589,9 +551,13 @@ class _HomePhotosState extends State<HomePhotos>
void _setThumbZoomLevel(int level) { void _setThumbZoomLevel(int level) {
final prevLevel = _thumbZoomLevel; final prevLevel = _thumbZoomLevel;
_thumbZoomLevel = level;
if ((prevLevel >= 0) != (level >= 0)) { 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<HomePhotos>
var _backingFiles = <File>[]; var _backingFiles = <File>[];
var _smartAlbums = <Album>[]; var _smartAlbums = <Album>[];
final _buildItemQueue =
ComputeQueue<PhotoListItemBuilderArguments, PhotoListItemBuilderResult>();
var _thumbZoomLevel = 0; var _thumbZoomLevel = 0;
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
@ -854,107 +823,6 @@ class _Web {
static const _metadataTaskHeaderHeight = 32.0; 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 { class _MetadataTaskHeaderDelegate extends SliverPersistentHeaderDelegate {
const _MetadataTaskHeaderDelegate({ const _MetadataTaskHeaderDelegate({
required this.extent, required this.extent,

View file

@ -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/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k; 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/pref.dart';
import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/share_handler.dart';
import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/snack_bar_manager.dart';
@ -106,6 +107,11 @@ class _PersonBrowserState extends State<PersonBrowser>
); );
} }
@override
onItemTap(SelectableItem item, int index) {
item.as<_ListItem>()?.onTap?.call();
}
void _initBloc() { void _initBloc() {
_log.info("[_initBloc] Initialize bloc"); _log.info("[_initBloc] Initialize bloc");
_reqQuery(); _reqQuery();
@ -463,11 +469,11 @@ class _ListItem implements SelectableItem {
required this.file, required this.file,
required this.account, required this.account,
required this.previewUrl, required this.previewUrl,
VoidCallback? onTap, this.onTap,
}) : _onTap = onTap; });
@override @override
get onTap => _onTap; get isTappable => onTap != null;
@override @override
get isSelectable => true; get isSelectable => true;
@ -503,7 +509,7 @@ class _ListItem implements SelectableItem {
final File file; final File file;
final Account account; final Account account;
final String previewUrl; final String previewUrl;
final VoidCallback? _onTap; final VoidCallback? onTap;
} }
enum _SelectionMenuOption { enum _SelectionMenuOption {

View file

@ -1,12 +1,194 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'; import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:nc_photos/account.dart'; import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart'; import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/cache_manager_util.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/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 { class PhotoListImage extends StatelessWidget {
const PhotoListImage({ const PhotoListImage({

View file

@ -14,9 +14,11 @@ import 'package:nc_photos/widget/measurable_item_list.dart';
import 'package:nc_photos/widget/selectable.dart'; import 'package:nc_photos/widget/selectable.dart';
abstract class SelectableItem { abstract class SelectableItem {
const SelectableItem();
Widget buildWidget(BuildContext context); Widget buildWidget(BuildContext context);
VoidCallback? get onTap => null; bool get isTappable => false;
bool get isSelectable => false; bool get isSelectable => false;
StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1); StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1);
} }
@ -28,6 +30,9 @@ mixin SelectableItemStreamListMixin<T extends StatefulWidget> on State<T> {
_keyboardFocus.requestFocus(); _keyboardFocus.requestFocus();
} }
@protected
void onItemTap(SelectableItem item, int index);
@protected @protected
Widget buildItemStreamListOuter( Widget buildItemStreamListOuter(
BuildContext context, { BuildContext context, {
@ -170,7 +175,9 @@ mixin SelectableItemStreamListMixin<T extends StatefulWidget> on State<T> {
} }
} }
} else { } else {
item.onTap?.call(); if (item.isTappable) {
onItemTap(item, index);
}
} }
} }

View file

@ -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.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/k.dart' as k; 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/share_handler.dart';
import 'package:nc_photos/theme.dart'; import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/preprocess_album.dart'; import 'package:nc_photos/use_case/preprocess_album.dart';
@ -76,6 +77,11 @@ class _SmartAlbumBrowserState extends State<SmartAlbumBrowser>
); );
} }
@override
onItemTap(SelectableItem item, int index) {
item.as<_ListItem>()?.onTap?.call();
}
@override @override
@protected @protected
get canEdit => false; get canEdit => false;
@ -324,11 +330,11 @@ enum _SelectionMenuOption {
abstract class _ListItem implements SelectableItem { abstract class _ListItem implements SelectableItem {
const _ListItem({ const _ListItem({
required this.index, required this.index,
VoidCallback? onTap, this.onTap,
}) : _onTap = onTap; });
@override @override
get onTap => _onTap; get isTappable => onTap != null;
@override @override
get isSelectable => true; get isSelectable => true;
@ -345,7 +351,7 @@ abstract class _ListItem implements SelectableItem {
final int index; final int index;
final VoidCallback? _onTap; final VoidCallback? onTap;
} }
abstract class _FileListItem extends _ListItem { abstract class _FileListItem extends _ListItem {

View file

@ -1,23 +1,25 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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:kiwi/kiwi.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:nc_photos/account.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/app_localizations.dart';
import 'package:nc_photos/bloc/ls_trashbin.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/debug_util.dart';
import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.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/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/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/pref.dart';
import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart'; import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/restore_trashbin.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/empty_list_indicator.dart';
import 'package:nc_photos/widget/handler/remove_selection_handler.dart'; import 'package:nc_photos/widget/handler/remove_selection_handler.dart';
import 'package:nc_photos/widget/photo_list_item.dart'; import 'package:nc_photos/widget/photo_list_item.dart';
@ -82,6 +84,18 @@ class _TrashbinBrowserState extends State<TrashbinBrowser>
); );
} }
@override
onItemTap(SelectableItem item, int index) {
item.as<PhotoListFileItem>()?.run((fileItem) {
Navigator.pushNamed(
context,
TrashbinViewer.routeName,
arguments: TrashbinViewerArguments(
widget.account, _backingFiles, fileItem.fileIndex),
);
});
}
void _initBloc() { void _initBloc() {
_bloc = LsTrashbinBloc.of(widget.account); _bloc = LsTrashbinBloc.of(widget.account);
if (_bloc.state is LsTrashbinBlocInit) { if (_bloc.state is LsTrashbinBlocInit) {
@ -99,7 +113,9 @@ class _TrashbinBrowserState extends State<TrashbinBrowser>
} }
Widget _buildContent(BuildContext context, LsTrashbinBlocState state) { Widget _buildContent(BuildContext context, LsTrashbinBlocState state) {
if (state is LsTrashbinBlocSuccess && itemStreamListItems.isEmpty) { if (state is LsTrashbinBlocSuccess &&
!_buildItemQueue.isProcessing &&
itemStreamListItems.isEmpty) {
return Column( return Column(
children: [ children: [
AppBar( AppBar(
@ -135,7 +151,7 @@ class _TrashbinBrowserState extends State<TrashbinBrowser>
), ),
), ),
), ),
if (state is LsTrashbinBlocLoading) if (state is LsTrashbinBlocLoading || _buildItemQueue.isProcessing)
const Align( const Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: LinearProgressIndicator(), child: LinearProgressIndicator(),
@ -250,12 +266,6 @@ class _TrashbinBrowserState extends State<TrashbinBrowser>
} }
} }
void _onItemTap(int index) {
Navigator.pushNamed(context, TrashbinViewer.routeName,
arguments:
TrashbinViewerArguments(widget.account, _backingFiles, index));
}
void _onEmptyTrashPressed(BuildContext context) async { void _onEmptyTrashPressed(BuildContext context) async {
showDialog( showDialog(
context: context, context: context,
@ -282,7 +292,7 @@ class _TrashbinBrowserState extends State<TrashbinBrowser>
duration: k.snackBarDurationShort, duration: k.snackBarDurationShort,
)); ));
final selectedFiles = selectedListItems final selectedFiles = selectedListItems
.whereType<_FileListItem>() .whereType<PhotoListFileItem>()
.map((e) => e.file) .map((e) => e.file)
.toList(); .toList();
setState(() { setState(() {
@ -335,51 +345,29 @@ class _TrashbinBrowserState extends State<TrashbinBrowser>
} }
void _transformItems(List<File> files) { void _transformItems(List<File> files) {
_backingFiles = files.sorted((a, b) { _buildItemQueue.addJob(
if (a.trashbinDeletionTime == null && b.trashbinDeletionTime == null) { PhotoListItemBuilderArguments(
// ? widget.account,
return 0; files,
} else if (a.trashbinDeletionTime == null) { sorter: _fileSorter,
return -1; locale: language_util.getSelectedLocale() ??
} else if (b.trashbinDeletionTime == null) { PlatformDispatcher.instance.locale,
return 1; ),
} else { buildPhotoListItem,
return b.trashbinDeletionTime!.compareTo(a.trashbinDeletionTime!); (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 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();
} }
Future<void> _deleteSelected() async { Future<void> _deleteSelected() async {
final selectedFiles = selectedListItems final selectedFiles = selectedListItems
.whereType<_FileListItem>() .whereType<PhotoListFileItem>()
.map((e) => e.file) .map((e) => e.file)
.toList(); .toList();
setState(() { setState(() {
@ -404,87 +392,15 @@ class _TrashbinBrowserState extends State<TrashbinBrowser>
var _backingFiles = <File>[]; var _backingFiles = <File>[];
final _buildItemQueue =
ComputeQueue<PhotoListItemBuilderArguments, PhotoListItemBuilderResult>();
var _thumbZoomLevel = 0; var _thumbZoomLevel = 0;
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
static final _log = Logger("widget.trashbin_browser._TrashbinBrowserState"); 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 { enum _AppBarMenuOption {
empty, empty,
} }
@ -492,3 +408,16 @@ enum _AppBarMenuOption {
enum _SelectionAppBarMenuOption { enum _SelectionAppBarMenuOption {
delete, 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!);
}
}

View file

@ -18,7 +18,7 @@ void main() {
}); });
} }
void _files() { Future<void> _files() async {
final xml = XmlDocument.parse(""" final xml = XmlDocument.parse("""
<?xml version="1.0"?> <?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" <d:multistatus xmlns:d="DAV:"
@ -42,7 +42,7 @@ void _files() {
</d:response> </d:response>
</d:multistatus> </d:multistatus>
"""); """);
final results = WebdavResponseParser().parseFiles(xml); final results = await WebdavResponseParser().parseFiles(xml);
expect(results, [ expect(results, [
File( File(
path: "remote.php/dav/files/admin/Nextcloud intro.mp4", path: "remote.php/dav/files/admin/Nextcloud intro.mp4",
@ -57,7 +57,7 @@ void _files() {
]); ]);
} }
void _files404props() { Future<void> _files404props() async {
final xml = XmlDocument.parse(""" final xml = XmlDocument.parse("""
<?xml version="1.0"?> <?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" <d:multistatus xmlns:d="DAV:"
@ -88,7 +88,7 @@ void _files404props() {
</d:response> </d:response>
</d:multistatus> </d:multistatus>
"""); """);
final results = WebdavResponseParser().parseFiles(xml); final results = await WebdavResponseParser().parseFiles(xml);
expect(results, [ expect(results, [
File( File(
path: "remote.php/dav/files/admin/Nextcloud intro.mp4", path: "remote.php/dav/files/admin/Nextcloud intro.mp4",
@ -103,7 +103,7 @@ void _files404props() {
]); ]);
} }
void _filesMetadata() { Future<void> _filesMetadata() async {
final xml = XmlDocument.parse(""" final xml = XmlDocument.parse("""
<?xml version="1.0"?> <?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" <d:multistatus xmlns:d="DAV:"
@ -128,7 +128,7 @@ void _filesMetadata() {
</d:response> </d:response>
</d:multistatus> </d:multistatus>
"""); """);
final results = WebdavResponseParser().parseFiles(xml); final results = await WebdavResponseParser().parseFiles(xml);
expect(results, [ expect(results, [
File( File(
path: "remote.php/dav/files/admin/Photos/Nextcloud community.jpg", path: "remote.php/dav/files/admin/Photos/Nextcloud community.jpg",
@ -149,7 +149,7 @@ void _filesMetadata() {
]); ]);
} }
void _filesIsArchived() { Future<void> _filesIsArchived() async {
final xml = XmlDocument.parse(""" final xml = XmlDocument.parse("""
<?xml version="1.0"?> <?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" <d:multistatus xmlns:d="DAV:"
@ -173,7 +173,7 @@ void _filesIsArchived() {
</d:response> </d:response>
</d:multistatus> </d:multistatus>
"""); """);
final results = WebdavResponseParser().parseFiles(xml); final results = await WebdavResponseParser().parseFiles(xml);
expect(results, [ expect(results, [
File( File(
path: "remote.php/dav/files/admin/Photos/Nextcloud community.jpg", path: "remote.php/dav/files/admin/Photos/Nextcloud community.jpg",
@ -188,7 +188,7 @@ void _filesIsArchived() {
]); ]);
} }
void _filesOverrideDateTime() { Future<void> _filesOverrideDateTime() async {
final xml = XmlDocument.parse(""" final xml = XmlDocument.parse("""
<?xml version="1.0"?> <?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" <d:multistatus xmlns:d="DAV:"
@ -212,7 +212,7 @@ void _filesOverrideDateTime() {
</d:response> </d:response>
</d:multistatus> </d:multistatus>
"""); """);
final results = WebdavResponseParser().parseFiles(xml); final results = await WebdavResponseParser().parseFiles(xml);
expect(results, [ expect(results, [
File( File(
path: "remote.php/dav/files/admin/Photos/Nextcloud community.jpg", path: "remote.php/dav/files/admin/Photos/Nextcloud community.jpg",
@ -227,7 +227,7 @@ void _filesOverrideDateTime() {
]); ]);
} }
void _filesMultiple() { Future<void> _filesMultiple() async {
final xml = XmlDocument.parse(""" final xml = XmlDocument.parse("""
<?xml version="1.0"?> <?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" <d:multistatus xmlns:d="DAV:"
@ -267,7 +267,7 @@ void _filesMultiple() {
</d:response> </d:response>
</d:multistatus> </d:multistatus>
"""); """);
final results = WebdavResponseParser().parseFiles(xml); final results = await WebdavResponseParser().parseFiles(xml);
expect(results, [ expect(results, [
File( File(
path: "remote.php/dav/files/admin/Nextcloud intro.mp4", path: "remote.php/dav/files/admin/Nextcloud intro.mp4",
@ -298,7 +298,7 @@ void _filesMultiple() {
]); ]);
} }
void _filesDir() { Future<void> _filesDir() async {
final xml = XmlDocument.parse(""" final xml = XmlDocument.parse("""
<?xml version="1.0"?> <?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" <d:multistatus xmlns:d="DAV:"
@ -330,7 +330,7 @@ void _filesDir() {
</d:response> </d:response>
</d:multistatus> </d:multistatus>
"""); """);
final results = WebdavResponseParser().parseFiles(xml); final results = await WebdavResponseParser().parseFiles(xml);
expect(results, [ expect(results, [
File( File(
path: "remote.php/dav/files/admin/Photos", path: "remote.php/dav/files/admin/Photos",
@ -343,7 +343,7 @@ void _filesDir() {
]); ]);
} }
void _filesServerHostedInSubdir() { Future<void> _filesServerHostedInSubdir() async {
final xml = XmlDocument.parse(""" final xml = XmlDocument.parse("""
<?xml version="1.0"?> <?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" <d:multistatus xmlns:d="DAV:"
@ -367,7 +367,7 @@ void _filesServerHostedInSubdir() {
</d:response> </d:response>
</d:multistatus> </d:multistatus>
"""); """);
final results = WebdavResponseParser().parseFiles(xml); final results = await WebdavResponseParser().parseFiles(xml);
expect(results, [ expect(results, [
File( File(
path: "remote.php/dav/files/admin/Nextcloud intro.mp4", path: "remote.php/dav/files/admin/Nextcloud intro.mp4",