mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-01-23 01:06:21 +01:00
Merge branch 'isolate' into dev
This commit is contained in:
commit
4df248e93a
26 changed files with 988 additions and 866 deletions
|
@ -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
|
||||||
|
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
40
app/lib/compute_queue.dart
Normal file
40
app/lib/compute_queue.dart
Normal 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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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 {"
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)));
|
||||||
|
}
|
||||||
|
|
|
@ -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)));
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
183
app/lib/widget/builder/photo_list_item_builder.dart
Normal file
183
app/lib/widget/builder/photo_list_item_builder.dart
Normal 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");
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Reference in a new issue