Merge branch 'isolate' into dev

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

View file

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

View file

@ -1,7 +1,24 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations_en.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/widget/my_app.dart';
/// Simplify localized string access
class L10n {
static AppLocalizations global() => AppLocalizations.of(MyApp.globalContext)!;
static AppLocalizations of(Locale locale) {
try {
return lookupAppLocalizations(locale);
} on FlutterError catch (_) {
// unsupported locale, use default (en)
return AppLocalizationsEn();
} catch (e, stackTrace) {
_log.shout("[of] Failed while lookupAppLocalizations", e, stackTrace);
return AppLocalizationsEn();
}
}
static final _log = Logger("app_localizations.L10n");
}

View file

@ -0,0 +1,40 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
typedef ComputeQueueCallback<U> = void Function(U result);
/// Compute the jobs in the queue one by one sequentially in isolate
class ComputeQueue<T, U> {
void addJob(T event, ComputeCallback<T, U> callback,
ComputeQueueCallback<U> onResult) {
_queue.addLast(_Job(event, callback, onResult));
if (_queue.length == 1) {
_startProcessing();
}
}
bool get isProcessing => _queue.isNotEmpty;
Future<void> _startProcessing() async {
while (_queue.isNotEmpty) {
final ev = _queue.first;
try {
final result = await compute(ev.callback, ev.message);
ev.onResult(result);
} finally {
_queue.removeFirst();
}
}
}
final _queue = Queue<_Job<T, U>>();
}
class _Job<T, U> {
const _Job(this.message, this.callback, this.onResult);
final T message;
final ComputeCallback<T, U> callback;
final ComputeQueueCallback<U> onResult;
}

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:nc_photos/list_extension.dart';
import 'package:nc_photos/override_comparator.dart';
import 'package:tuple/tuple.dart';
@ -23,56 +24,6 @@ extension IterableExtension<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));
/// Whether the collection contains an element equal to [element] using the
@ -114,4 +65,34 @@ extension IterableExtension<T> on Iterable<T> {
yield e;
}
}
Future<List<U>> computeAll<U>(ComputeCallback<T, U> callback) async {
return await compute(
_computeAllImpl<T, U>, _ComputeAllMessage(callback, asList()));
}
/// Return a list containing elements in this iterable
///
/// If this Iterable is itself a list, this will be returned directly with no
/// copying
List<T> asList() {
if (this is List) {
return this as List<T>;
} else {
return toList();
}
}
}
class _ComputeAllMessage<T, U> {
const _ComputeAllMessage(this.callback, this.data);
final ComputeCallback<T, U> callback;
final List<T> data;
}
Future<List<U>> _computeAllImpl<T, U>(_ComputeAllMessage<T, U> message) async {
final result = await Future.wait(
message.data.map((e) async => await message.callback(e)));
return result;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,183 @@
import 'package:collection/collection.dart' show compareNatural;
import 'package:flutter/widgets.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_init.dart' as app_init;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/widget/photo_list_item.dart';
import 'package:nc_photos/widget/photo_list_util.dart';
import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart';
class PhotoListItemBuilderArguments {
const PhotoListItemBuilderArguments(
this.account,
this.files, {
this.isArchived = false,
required this.sorter,
this.grouper,
this.shouldBuildSmartAlbums = false,
this.shouldShowFavoriteBadge = false,
required this.locale,
});
final Account account;
final List<File> files;
final bool isArchived;
final PhotoListItemSorter? sorter;
final PhotoListItemGrouper? grouper;
final bool shouldBuildSmartAlbums;
final bool shouldShowFavoriteBadge;
/// Locale is needed to get localized string
final Locale locale;
}
class PhotoListItemBuilderResult {
const PhotoListItemBuilderResult(
this.backingFiles,
this.listItems, {
this.smartAlbums = const [],
});
final List<File> backingFiles;
final List<SelectableItem> listItems;
final List<Album> smartAlbums;
}
typedef PhotoListItemSorter = int Function(File, File);
abstract class PhotoListItemGrouper {
const PhotoListItemGrouper();
SelectableItem? onFile(File file);
}
class PhotoListFileDateGrouper implements PhotoListItemGrouper {
PhotoListFileDateGrouper({
required this.isMonthOnly,
}) : helper = DateGroupHelper(isMonthOnly: isMonthOnly);
@override
onFile(File file) => helper
.onFile(file)
?.run((date) => PhotoListDateItem(date: date, isMonthOnly: isMonthOnly));
final bool isMonthOnly;
final DateGroupHelper helper;
}
int photoListFileDateTimeSorter(File a, File b) =>
compareFileDateTimeDescending(a, b);
int photoListFilenameSorter(File a, File b) =>
compareNatural(b.filename, a.filename);
PhotoListItemBuilderResult buildPhotoListItem(
PhotoListItemBuilderArguments arg) {
app_init.initLog();
return _PhotoListItemBuilder(
isArchived: arg.isArchived,
sorter: arg.sorter,
grouper: arg.grouper,
shouldBuildSmartAlbums: arg.shouldBuildSmartAlbums,
shouldShowFavoriteBadge: arg.shouldShowFavoriteBadge,
locale: arg.locale,
)(arg.account, arg.files);
}
class _PhotoListItemBuilder {
const _PhotoListItemBuilder({
required this.isArchived,
required this.sorter,
required this.grouper,
required this.shouldBuildSmartAlbums,
required this.shouldShowFavoriteBadge,
required this.locale,
});
PhotoListItemBuilderResult call(Account account, List<File> files) {
final s = Stopwatch()..start();
try {
return _fromSortedItems(account, _sortItems(files));
} finally {
_log.info("[call] Elapsed time: ${s.elapsedMilliseconds}ms");
}
}
List<File> _sortItems(List<File> files) {
final filtered = files.where((f) => (f.isArchived ?? false) == isArchived);
if (sorter == null) {
return filtered.toList();
} else {
return filtered.stableSorted(sorter);
}
}
PhotoListItemBuilderResult _fromSortedItems(
Account account, List<File> files) {
final today = DateTime.now();
final memoryAlbumHelper =
shouldBuildSmartAlbums ? MemoryAlbumHelper(today) : null;
final listItems = <SelectableItem>[];
for (int i = 0; i < files.length; ++i) {
final f = files[i];
grouper?.onFile(f)?.run((item) => listItems.add(item));
memoryAlbumHelper?.addFile(f);
final item = _buildListItem(i, account, f);
if (item != null) {
listItems.add(item);
}
}
final smartAlbums = memoryAlbumHelper
?.build((year) => L10n.of(locale).memoryAlbumName(today.year - year));
return PhotoListItemBuilderResult(
files,
listItems,
smartAlbums: smartAlbums ?? [],
);
}
SelectableItem? _buildListItem(int i, Account account, File file) {
final previewUrl = api_util.getFilePreviewUrl(account, file,
width: k.photoThumbSize, height: k.photoThumbSize);
if (file_util.isSupportedImageFormat(file)) {
return PhotoListImageItem(
fileIndex: i,
file: file,
account: account,
previewUrl: previewUrl,
shouldShowFavoriteBadge: shouldShowFavoriteBadge,
);
} else if (file_util.isSupportedVideoFormat(file)) {
return PhotoListVideoItem(
fileIndex: i,
file: file,
account: account,
previewUrl: previewUrl,
shouldShowFavoriteBadge: shouldShowFavoriteBadge,
);
} else {
_log.shout(
"[_buildListItem] Unsupported file format: ${file.contentType}");
return null;
}
}
final bool isArchived;
final PhotoListItemSorter? sorter;
final PhotoListItemGrouper? grouper;
final bool shouldBuildSmartAlbums;
final bool shouldShowFavoriteBadge;
final Locale locale;
static final _log =
Logger("widget.builder.photo_list_item_builder._PhotoListItemBuilder");
}

View file

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

View file

@ -1,17 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/app_init.dart' as app_init;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/scan_local_dir.dart';
import 'package:nc_photos/compute_queue.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/entity/local_file.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/mobile/android/android_info.dart';
import 'package:nc_photos/mobile/android/content_uri_image_provider.dart';
import 'package:nc_photos/mobile/android/permission_util.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/share_handler.dart';
@ -20,6 +21,7 @@ import 'package:nc_photos/theme.dart';
import 'package:nc_photos/widget/empty_list_indicator.dart';
import 'package:nc_photos/widget/handler/delete_local_selection_handler.dart';
import 'package:nc_photos/widget/local_file_viewer.dart';
import 'package:nc_photos/widget/photo_list_item.dart';
import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util;
import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart';
import 'package:nc_photos/widget/selection_app_bar.dart';
@ -91,6 +93,17 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
);
}
@override
onItemTap(SelectableItem item, int index) {
item.as<PhotoListLocalFileItem>()?.run((fileItem) {
Navigator.pushNamed(
context,
LocalFileViewer.routeName,
arguments: LocalFileViewerArguments(_backingFiles, fileItem.fileIndex),
);
});
}
void _initBloc() {
if (_bloc.state is ScanLocalDirBlocInit) {
_log.info("[_initBloc] Initialize bloc");
@ -123,6 +136,7 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
],
);
} else if (state is ScanLocalDirBlocSuccess &&
!_buildItemQueue.isProcessing &&
itemStreamListItems.isEmpty) {
return Column(
children: [
@ -159,7 +173,7 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
),
),
),
if (state is ScanLocalDirBlocLoading)
if (state is ScanLocalDirBlocLoading || _buildItemQueue.isProcessing)
const Align(
alignment: Alignment.bottomCenter,
child: LinearProgressIndicator(),
@ -237,7 +251,7 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
Future<void> _onSelectionSharePressed(BuildContext context) async {
final selected = selectedListItems
.whereType<_FileListItem>()
.whereType<PhotoListLocalFileItem>()
.map((e) => e.file)
.toList();
await ShareHandler(
@ -285,7 +299,7 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
}
final selectedFiles = selectedListItems
.whereType<_FileListItem>()
.whereType<PhotoListLocalFileItem>()
.map((e) => e.file)
.toList();
setState(() {
@ -294,29 +308,17 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
await const DeleteLocalSelectionHandler()(selectedFiles: selectedFiles);
}
void _onItemTap(int index) {
Navigator.pushNamed(context, LocalFileViewer.routeName,
arguments: LocalFileViewerArguments(_backingFiles, index));
}
void _transformItems(List<LocalFile> files) {
// we use last modified here to keep newly enhanced photo at the top
_backingFiles =
files.stableSorted((a, b) => b.lastModified.compareTo(a.lastModified));
itemStreamListItems = () sync* {
for (int i = 0; i < _backingFiles.length; ++i) {
final f = _backingFiles[i];
if (file_util.isSupportedImageMime(f.mime ?? "")) {
yield _ImageListItem(
file: f,
onTap: () => _onItemTap(i),
);
}
}
}()
.toList();
_log.info("[_transformItems] Length: ${itemStreamListItems.length}");
_buildItemQueue.addJob(
_BuilderArguments(files),
_buildPhotoListItem,
(result) {
setState(() {
_backingFiles = result.backingFiles;
itemStreamListItems = result.listItems;
});
},
);
}
void _openInitialImage(String filename) {
@ -361,6 +363,8 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
var _backingFiles = <LocalFile>[];
final _buildItemQueue = ComputeQueue<_BuilderArguments, _BuilderResult>();
var _isFirstRun = true;
var _thumbZoomLevel = 0;
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
@ -370,76 +374,67 @@ class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
Logger("widget.enhanced_photo_browser._EnhancedPhotoBrowserState");
}
abstract class _ListItem implements SelectableItem {
_ListItem({
VoidCallback? onTap,
}) : _onTap = onTap;
@override
get onTap => _onTap;
@override
get isSelectable => true;
@override
get staggeredTile => const StaggeredTile.count(1, 1);
final VoidCallback? _onTap;
}
abstract class _FileListItem extends _ListItem {
_FileListItem({
required this.file,
VoidCallback? onTap,
}) : super(onTap: onTap);
final LocalFile file;
}
class _ImageListItem extends _FileListItem {
_ImageListItem({
required LocalFile file,
VoidCallback? onTap,
}) : super(file: file, onTap: onTap);
@override
buildWidget(BuildContext context) {
final ImageProvider provider;
if (file is LocalUriFile) {
provider = ContentUriImage((file as LocalUriFile).uri);
} else {
throw ArgumentError("Invalid file");
}
return Padding(
padding: const EdgeInsets.all(2),
child: FittedBox(
clipBehavior: Clip.hardEdge,
fit: BoxFit.cover,
child: Container(
// arbitrary size here
constraints: BoxConstraints.tight(const Size(128, 128)),
color: AppTheme.getListItemBackgroundColor(context),
child: Image(
image: ResizeImage.resizeIfNeeded(k.photoThumbSize, null, provider),
filterQuality: FilterQuality.high,
fit: BoxFit.cover,
errorBuilder: (context, e, stackTrace) {
return Center(
child: Icon(
Icons.image_not_supported,
size: 64,
color: Colors.white.withOpacity(.8),
),
);
},
),
),
),
);
}
}
enum _SelectionMenuOption {
delete,
}
class _BuilderResult {
const _BuilderResult(this.backingFiles, this.listItems);
final List<LocalFile> backingFiles;
final List<SelectableItem> listItems;
}
class _BuilderArguments {
const _BuilderArguments(this.files);
final List<LocalFile> files;
}
class _Builder {
_BuilderResult call(List<LocalFile> files) {
final s = Stopwatch()..start();
try {
return _fromSortedItems(_sortItems(files));
} finally {
_log.info("[call] Elapsed time: ${s.elapsedMilliseconds}ms");
}
}
List<LocalFile> _sortItems(List<LocalFile> files) {
// we use last modified here to keep newly enhanced photo at the top
return files
.stableSorted((a, b) => b.lastModified.compareTo(a.lastModified));
}
_BuilderResult _fromSortedItems(List<LocalFile> files) {
final listItems = <SelectableItem>[];
for (int i = 0; i < files.length; ++i) {
final f = files[i];
final item = _buildListItem(i, f);
if (item != null) {
listItems.add(item);
}
}
return _BuilderResult(files, listItems);
}
SelectableItem? _buildListItem(int i, LocalFile file) {
if (file_util.isSupportedImageMime(file.mime ?? "")) {
return PhotoListLocalImageItem(
fileIndex: i,
file: file,
);
} else {
_log.shout("[_buildListItem] Unsupported file format: ${file.mime}");
return null;
}
}
static final _log = Logger("widget.enhanced_photo_browser._Builder");
}
_BuilderResult _buildPhotoListItem(_BuilderArguments arg) {
app_init.initLog();
return _Builder()(arg.files);
}

View file

@ -1,23 +1,25 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/list_favorite.dart';
import 'package:nc_photos/compute_queue.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/download_handler.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/language_util.dart' as language_util;
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/share_handler.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/widget/builder/photo_list_item_builder.dart';
import 'package:nc_photos/widget/empty_list_indicator.dart';
import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart';
import 'package:nc_photos/widget/handler/archive_selection_handler.dart';
@ -84,6 +86,18 @@ class _FavoriteBrowserState extends State<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() {
if (_bloc.state is ListFavoriteBlocInit) {
_log.info("[_initBloc] Initialize bloc");
@ -99,7 +113,9 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
}
Widget _buildContent(BuildContext context, ListFavoriteBlocState state) {
if (state is ListFavoriteBlocSuccess && itemStreamListItems.isEmpty) {
if (state is ListFavoriteBlocSuccess &&
!_buildItemQueue.isProcessing &&
itemStreamListItems.isEmpty) {
return Column(
children: [
AppBar(
@ -142,7 +158,7 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
),
),
),
if (state is ListFavoriteBlocLoading)
if (state is ListFavoriteBlocLoading || _buildItemQueue.isProcessing)
const Align(
alignment: Alignment.bottomCenter,
child: LinearProgressIndicator(),
@ -211,9 +227,7 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
minZoom: -1,
maxZoom: 2,
onZoomChanged: (value) {
setState(() {
_setThumbZoomLevel(value.round());
});
_setThumbZoomLevel(value.round());
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() {
_reqRefresh();
}
@ -265,7 +274,7 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
void _onSelectionSharePressed(BuildContext context) {
final selected = selectedListItems
.whereType<_FileListItem>()
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
ShareHandler(
@ -283,7 +292,7 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
context: context,
account: widget.account,
selectedFiles: selectedListItems
.whereType<_FileListItem>()
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList(),
clearSelection: () {
@ -298,7 +307,7 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
void _onSelectionDownloadPressed() {
final selected = selectedListItems
.whereType<_FileListItem>()
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
DownloadHandler().downloadFiles(widget.account, selected);
@ -309,7 +318,7 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
Future<void> _onSelectionArchivePressed(BuildContext context) async {
final selectedFiles = selectedListItems
.whereType<_FileListItem>()
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
setState(() {
@ -323,7 +332,7 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
Future<void> _onSelectionDeletePressed(BuildContext context) async {
final selectedFiles = selectedListItems
.whereType<_FileListItem>()
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
setState(() {
@ -336,46 +345,26 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
);
}
void _transformItems(List<File> files) {
_backingFiles = files
.where((f) => f.isArchived != true)
.sorted(compareFileDateTimeDescending);
final isMonthOnly = _thumbZoomLevel < 0;
final dateHelper = photo_list_util.DateGroupHelper(
isMonthOnly: isMonthOnly,
void _transformItems(List<File> files, {bool isSorted = false}) {
_buildItemQueue.addJob(
PhotoListItemBuilderArguments(
widget.account,
files,
sorter: isSorted ? null : photoListFileDateTimeSorter,
grouper: PhotoListFileDateGrouper(isMonthOnly: _thumbZoomLevel < 0),
locale: language_util.getSelectedLocale() ??
PlatformDispatcher.instance.locale,
),
buildPhotoListItem,
(result) {
if (mounted) {
setState(() {
_backingFiles = result.backingFiles;
itemStreamListItems = result.listItems;
});
}
},
);
itemStreamListItems = () sync* {
for (int i = 0; i < _backingFiles.length; ++i) {
final f = _backingFiles[i];
final date = dateHelper.onFile(f);
if (date != null) {
yield _DateListItem(date: date, isMonthOnly: isMonthOnly);
}
final previewUrl = api_util.getFilePreviewUrl(widget.account, f,
width: k.photoThumbSize, height: k.photoThumbSize);
if (file_util.isSupportedImageFormat(f)) {
yield _ImageListItem(
file: f,
account: widget.account,
previewUrl: previewUrl,
onTap: () => _onItemTap(i),
);
} else if (file_util.isSupportedVideoFormat(f)) {
yield _VideoListItem(
file: f,
account: widget.account,
previewUrl: previewUrl,
onTap: () => _onItemTap(i),
);
} else {
_log.shout(
"[_transformItems] Unsupported file format: ${f.contentType}");
}
}
}()
.toList();
}
void _reqQuery() {
@ -397,9 +386,13 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
void _setThumbZoomLevel(int level) {
final prevLevel = _thumbZoomLevel;
_thumbZoomLevel = level;
if ((prevLevel >= 0) != (level >= 0)) {
_transformItems(_backingFiles);
_thumbZoomLevel = level;
_transformItems(_backingFiles, isSorted: true);
} else {
setState(() {
_thumbZoomLevel = level;
});
}
}
@ -407,111 +400,15 @@ class _FavoriteBrowserState extends State<FavoriteBrowser>
var _backingFiles = <File>[];
final _buildItemQueue =
ComputeQueue<PhotoListItemBuilderArguments, PhotoListItemBuilderResult>();
var _thumbZoomLevel = 0;
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
static final _log = Logger("widget.archive_browser._FavoriteBrowserState");
}
abstract class _ListItem implements SelectableItem {
_ListItem({
VoidCallback? onTap,
}) : _onTap = onTap;
@override
get onTap => _onTap;
@override
get isSelectable => true;
@override
get staggeredTile => const StaggeredTile.count(1, 1);
final VoidCallback? _onTap;
}
class _DateListItem extends _ListItem {
_DateListItem({
required this.date,
this.isMonthOnly = false,
});
@override
get isSelectable => false;
@override
get staggeredTile => const StaggeredTile.extent(99, 32);
@override
buildWidget(BuildContext context) {
return PhotoListDate(
date: date,
isMonthOnly: isMonthOnly,
);
}
final DateTime date;
final bool isMonthOnly;
}
abstract class _FileListItem extends _ListItem {
_FileListItem({
required this.file,
VoidCallback? onTap,
}) : super(onTap: onTap);
@override
operator ==(Object other) {
return other is _FileListItem && file.path == other.file.path;
}
@override
get hashCode => file.path.hashCode;
final File file;
}
class _ImageListItem extends _FileListItem {
_ImageListItem({
required File file,
required this.account,
required this.previewUrl,
VoidCallback? onTap,
}) : super(file: file, onTap: onTap);
@override
buildWidget(BuildContext context) {
return PhotoListImage(
account: account,
previewUrl: previewUrl,
isGif: file.contentType == "image/gif",
);
}
final Account account;
final String previewUrl;
}
class _VideoListItem extends _FileListItem {
_VideoListItem({
required File file,
required this.account,
required this.previewUrl,
VoidCallback? onTap,
}) : super(file: file, onTap: onTap);
@override
buildWidget(BuildContext context) {
return PhotoListVideo(
account: account,
previewUrl: previewUrl,
);
}
final Account account;
final String previewUrl;
}
enum _SelectionMenuOption {
archive,
delete,

View file

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

View file

@ -1,11 +1,9 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:collection/collection.dart' show compareNatural;
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
@ -13,6 +11,7 @@ import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/bloc_util.dart' as bloc_util;
import 'package:nc_photos/bloc/scan_account_dir.dart';
import 'package:nc_photos/compute_queue.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/download_handler.dart';
import 'package:nc_photos/entity/album.dart';
@ -21,9 +20,10 @@ import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/language_util.dart' as language_util;
import 'package:nc_photos/metadata_task_manager.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/primitive.dart';
@ -33,6 +33,7 @@ import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/sync_favorite.dart';
import 'package:nc_photos/widget/album_browser_util.dart' as album_browser_util;
import 'package:nc_photos/widget/builder/photo_list_item_builder.dart';
import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart';
import 'package:nc_photos/widget/handler/archive_selection_handler.dart';
import 'package:nc_photos/widget/handler/remove_selection_handler.dart';
@ -92,6 +93,18 @@ class _HomePhotosState extends State<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() {
if (_bloc.state is ScanAccountDirBlocInit) {
_log.info("[_initBloc] Initialize bloc");
@ -168,7 +181,8 @@ class _HomePhotosState extends State<HomePhotos>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (state is ScanAccountDirBlocLoading)
if (state is ScanAccountDirBlocLoading ||
_buildItemQueue.isProcessing)
const LinearProgressIndicator(),
SizedBox(
width: double.infinity,
@ -254,9 +268,7 @@ class _HomePhotosState extends State<HomePhotos>
minZoom: -1,
maxZoom: 2,
onZoomChanged: (value) {
setState(() {
_setThumbZoomLevel(value.round());
});
_setThumbZoomLevel(value.round());
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() {
_hasFiredMetadataTask.value = false;
_reqRefresh();
@ -359,7 +366,7 @@ class _HomePhotosState extends State<HomePhotos>
void _onSelectionSharePressed(BuildContext context) {
final selected = selectedListItems
.whereType<_FileListItem>()
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
ShareHandler(
@ -377,7 +384,7 @@ class _HomePhotosState extends State<HomePhotos>
context: context,
account: widget.account,
selectedFiles: selectedListItems
.whereType<_FileListItem>()
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList(),
clearSelection: () {
@ -392,7 +399,7 @@ class _HomePhotosState extends State<HomePhotos>
void _onSelectionDownloadPressed() {
final selected = selectedListItems
.whereType<_FileListItem>()
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
DownloadHandler().downloadFiles(widget.account, selected);
@ -403,7 +410,7 @@ class _HomePhotosState extends State<HomePhotos>
Future<void> _onSelectionArchivePressed(BuildContext context) async {
final selectedFiles = selectedListItems
.whereType<_FileListItem>()
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
setState(() {
@ -417,7 +424,7 @@ class _HomePhotosState extends State<HomePhotos>
Future<void> _onSelectionDeletePressed(BuildContext context) async {
final selectedFiles = selectedListItems
.whereType<_FileListItem>()
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
setState(() {
@ -440,9 +447,7 @@ class _HomePhotosState extends State<HomePhotos>
} else if (ev.key == PrefKey.isPhotosTabSortByName) {
if (_bloc.state is! ScanAccountDirBlocInit) {
_log.info("[_onPrefUpdated] Update view after changing sort option");
setState(() {
_transformItems(_bloc.state.files);
});
_transformItems(_bloc.state.files);
}
}
}
@ -491,83 +496,40 @@ class _HomePhotosState extends State<HomePhotos>
}
/// Transform a File list to grid items
void _transformItems(List<File> files) {
if (!Pref().isPhotosTabSortByNameOr()) {
_transformItemsByDate(files);
void _transformItems(List<File> files, {bool isSorted = false}) {
_log.info("[_transformItems] Queue ${files.length} items");
final PhotoListItemSorter? sorter;
final PhotoListItemGrouper? grouper;
if (Pref().isPhotosTabSortByNameOr()) {
sorter = isSorted ? null : photoListFilenameSorter;
grouper = null;
} else {
_transformItemsByName(files);
sorter = isSorted ? null : photoListFileDateTimeSorter;
grouper = PhotoListFileDateGrouper(isMonthOnly: _thumbZoomLevel < 0);
}
}
void _transformItemsByName(List<File> files) {
_backingFiles = files
.where((f) => f.isArchived != true)
.sorted((a, b) => compareNatural(b.filename, a.filename));
itemStreamListItems = () sync* {
for (int i = 0; i < _backingFiles.length; ++i) {
final item = _transformItemToListItem(i, _backingFiles[i]);
if (item != null) {
yield item;
_buildItemQueue.addJob(
PhotoListItemBuilderArguments(
widget.account,
files,
sorter: sorter,
grouper: grouper,
shouldBuildSmartAlbums: true,
shouldShowFavoriteBadge: true,
locale: language_util.getSelectedLocale() ??
PlatformDispatcher.instance.locale,
),
buildPhotoListItem,
(result) {
if (mounted) {
setState(() {
_backingFiles = result.backingFiles;
itemStreamListItems = result.listItems;
_smartAlbums = result.smartAlbums;
});
}
}
}()
.toList();
}
void _transformItemsByDate(List<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() {
@ -589,9 +551,13 @@ class _HomePhotosState extends State<HomePhotos>
void _setThumbZoomLevel(int level) {
final prevLevel = _thumbZoomLevel;
_thumbZoomLevel = level;
if ((prevLevel >= 0) != (level >= 0)) {
_transformItems(_backingFiles);
_thumbZoomLevel = level;
_transformItems(_backingFiles, isSorted: true);
} else {
setState(() {
_thumbZoomLevel = level;
});
}
}
@ -664,6 +630,9 @@ class _HomePhotosState extends State<HomePhotos>
var _backingFiles = <File>[];
var _smartAlbums = <Album>[];
final _buildItemQueue =
ComputeQueue<PhotoListItemBuilderArguments, PhotoListItemBuilderResult>();
var _thumbZoomLevel = 0;
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
@ -854,107 +823,6 @@ class _Web {
static const _metadataTaskHeaderHeight = 32.0;
}
abstract class _ListItem implements SelectableItem {
_ListItem({
VoidCallback? onTap,
}) : _onTap = onTap;
@override
get onTap => _onTap;
@override
get isSelectable => true;
@override
get staggeredTile => const StaggeredTile.count(1, 1);
final VoidCallback? _onTap;
}
class _DateListItem extends _ListItem {
_DateListItem({
required this.date,
this.isMonthOnly = false,
});
@override
get isSelectable => false;
@override
get staggeredTile => const StaggeredTile.extent(99, 32);
@override
buildWidget(BuildContext context) {
return PhotoListDate(
date: date,
isMonthOnly: isMonthOnly,
);
}
final DateTime date;
final bool isMonthOnly;
}
abstract class _FileListItem extends _ListItem {
_FileListItem({
required this.file,
VoidCallback? onTap,
}) : super(onTap: onTap);
@override
operator ==(Object other) {
return other is _FileListItem && file.path == other.file.path;
}
@override
get hashCode => file.path.hashCode;
final File file;
}
class _ImageListItem extends _FileListItem {
_ImageListItem({
required File file,
required this.account,
required this.previewUrl,
VoidCallback? onTap,
}) : super(file: file, onTap: onTap);
@override
buildWidget(BuildContext context) {
return PhotoListImage(
account: account,
previewUrl: previewUrl,
isGif: file.contentType == "image/gif",
isFavorite: file.isFavorite == true,
);
}
final Account account;
final String previewUrl;
}
class _VideoListItem extends _FileListItem {
_VideoListItem({
required File file,
required this.account,
required this.previewUrl,
VoidCallback? onTap,
}) : super(file: file, onTap: onTap);
@override
buildWidget(BuildContext context) {
return PhotoListVideo(
account: account,
previewUrl: previewUrl,
isFavorite: file.isFavorite == true,
);
}
final Account account;
final String previewUrl;
}
class _MetadataTaskHeaderDelegate extends SliverPersistentHeaderDelegate {
const _MetadataTaskHeaderDelegate({
required this.extent,

View file

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

View file

@ -1,12 +1,194 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:intl/intl.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/cache_manager_util.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/local_file.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/mobile/android/content_uri_image_provider.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart';
abstract class PhotoListFileItem extends SelectableItem {
const PhotoListFileItem({
required this.fileIndex,
required this.file,
required this.shouldShowFavoriteBadge,
});
@override
get isTappable => true;
@override
get isSelectable => true;
@override
operator ==(Object other) =>
other is PhotoListFileItem && file.compareServerIdentity(other.file);
@override
get hashCode => file.path.hashCode;
@override
toString() => "$runtimeType {"
"fileIndex: $fileIndex, "
"file: ${file.path}, "
"shouldShowFavoriteBadge: $shouldShowFavoriteBadge, "
"}";
final int fileIndex;
final File file;
final bool shouldShowFavoriteBadge;
}
class PhotoListImageItem extends PhotoListFileItem {
const PhotoListImageItem({
required int fileIndex,
required File file,
required this.account,
required this.previewUrl,
required bool shouldShowFavoriteBadge,
}) : super(
fileIndex: fileIndex,
file: file,
shouldShowFavoriteBadge: shouldShowFavoriteBadge,
);
@override
buildWidget(BuildContext context) => PhotoListImage(
account: account,
previewUrl: previewUrl,
isGif: file.contentType == "image/gif",
isFavorite: shouldShowFavoriteBadge && file.isFavorite == true,
);
final Account account;
final String previewUrl;
}
class PhotoListVideoItem extends PhotoListFileItem {
const PhotoListVideoItem({
required int fileIndex,
required File file,
required this.account,
required this.previewUrl,
required bool shouldShowFavoriteBadge,
}) : super(
fileIndex: fileIndex,
file: file,
shouldShowFavoriteBadge: shouldShowFavoriteBadge,
);
@override
buildWidget(BuildContext context) => PhotoListVideo(
account: account,
previewUrl: previewUrl,
isFavorite: shouldShowFavoriteBadge && file.isFavorite == true,
);
final Account account;
final String previewUrl;
}
class PhotoListDateItem extends SelectableItem {
const PhotoListDateItem({
required this.date,
this.isMonthOnly = false,
});
@override
get isTappable => false;
@override
get isSelectable => false;
@override
get staggeredTile => const StaggeredTile.extent(99, 32);
@override
buildWidget(BuildContext context) => PhotoListDate(
date: date,
isMonthOnly: isMonthOnly,
);
final DateTime date;
final bool isMonthOnly;
}
abstract class PhotoListLocalFileItem extends SelectableItem {
const PhotoListLocalFileItem({
required this.fileIndex,
required this.file,
});
@override
get isTappable => true;
@override
get isSelectable => true;
@override
operator ==(Object other) =>
other is PhotoListLocalFileItem && file.compareIdentity(other.file);
@override
get hashCode => file.identityHashCode;
final int fileIndex;
final LocalFile file;
}
class PhotoListLocalImageItem extends PhotoListLocalFileItem {
const PhotoListLocalImageItem({
required int fileIndex,
required LocalFile file,
}) : super(
fileIndex: fileIndex,
file: file,
);
@override
buildWidget(BuildContext context) {
final ImageProvider provider;
if (file is LocalUriFile) {
provider = ContentUriImage((file as LocalUriFile).uri);
} else {
throw ArgumentError("Invalid file");
}
return Padding(
padding: const EdgeInsets.all(2),
child: FittedBox(
clipBehavior: Clip.hardEdge,
fit: BoxFit.cover,
child: Container(
// arbitrary size here
constraints: BoxConstraints.tight(const Size(128, 128)),
color: AppTheme.getListItemBackgroundColor(context),
child: Image(
image: ResizeImage.resizeIfNeeded(k.photoThumbSize, null, provider),
filterQuality: FilterQuality.high,
fit: BoxFit.cover,
errorBuilder: (context, e, stackTrace) {
return Center(
child: Icon(
Icons.image_not_supported,
size: 64,
color: Colors.white.withOpacity(.8),
),
);
},
),
),
),
);
}
}
class PhotoListImage extends StatelessWidget {
const PhotoListImage({

View file

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

View file

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

View file

@ -1,23 +1,25 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/ls_trashbin.dart';
import 'package:nc_photos/compute_queue.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/language_util.dart' as language_util;
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/restore_trashbin.dart';
import 'package:nc_photos/widget/builder/photo_list_item_builder.dart';
import 'package:nc_photos/widget/empty_list_indicator.dart';
import 'package:nc_photos/widget/handler/remove_selection_handler.dart';
import 'package:nc_photos/widget/photo_list_item.dart';
@ -82,6 +84,18 @@ class _TrashbinBrowserState extends State<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() {
_bloc = LsTrashbinBloc.of(widget.account);
if (_bloc.state is LsTrashbinBlocInit) {
@ -99,7 +113,9 @@ class _TrashbinBrowserState extends State<TrashbinBrowser>
}
Widget _buildContent(BuildContext context, LsTrashbinBlocState state) {
if (state is LsTrashbinBlocSuccess && itemStreamListItems.isEmpty) {
if (state is LsTrashbinBlocSuccess &&
!_buildItemQueue.isProcessing &&
itemStreamListItems.isEmpty) {
return Column(
children: [
AppBar(
@ -135,7 +151,7 @@ class _TrashbinBrowserState extends State<TrashbinBrowser>
),
),
),
if (state is LsTrashbinBlocLoading)
if (state is LsTrashbinBlocLoading || _buildItemQueue.isProcessing)
const Align(
alignment: Alignment.bottomCenter,
child: LinearProgressIndicator(),
@ -250,12 +266,6 @@ class _TrashbinBrowserState extends State<TrashbinBrowser>
}
}
void _onItemTap(int index) {
Navigator.pushNamed(context, TrashbinViewer.routeName,
arguments:
TrashbinViewerArguments(widget.account, _backingFiles, index));
}
void _onEmptyTrashPressed(BuildContext context) async {
showDialog(
context: context,
@ -282,7 +292,7 @@ class _TrashbinBrowserState extends State<TrashbinBrowser>
duration: k.snackBarDurationShort,
));
final selectedFiles = selectedListItems
.whereType<_FileListItem>()
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
setState(() {
@ -335,51 +345,29 @@ class _TrashbinBrowserState extends State<TrashbinBrowser>
}
void _transformItems(List<File> files) {
_backingFiles = files.sorted((a, b) {
if (a.trashbinDeletionTime == null && b.trashbinDeletionTime == null) {
// ?
return 0;
} else if (a.trashbinDeletionTime == null) {
return -1;
} else if (b.trashbinDeletionTime == null) {
return 1;
} else {
return b.trashbinDeletionTime!.compareTo(a.trashbinDeletionTime!);
}
});
itemStreamListItems = () sync* {
for (int i = 0; i < _backingFiles.length; ++i) {
final f = _backingFiles[i];
final previewUrl = api_util.getFilePreviewUrl(widget.account, f,
width: k.photoThumbSize, height: k.photoThumbSize);
if (file_util.isSupportedImageFormat(f)) {
yield _ImageListItem(
file: f,
account: widget.account,
previewUrl: previewUrl,
onTap: () => _onItemTap(i),
);
} else if (file_util.isSupportedVideoFormat(f)) {
yield _VideoListItem(
file: f,
account: widget.account,
previewUrl: previewUrl,
onTap: () => _onItemTap(i),
);
} else {
_log.shout(
"[_transformItems] Unsupported file format: ${f.contentType}");
_buildItemQueue.addJob(
PhotoListItemBuilderArguments(
widget.account,
files,
sorter: _fileSorter,
locale: language_util.getSelectedLocale() ??
PlatformDispatcher.instance.locale,
),
buildPhotoListItem,
(result) {
if (mounted) {
setState(() {
_backingFiles = result.backingFiles;
itemStreamListItems = result.listItems;
});
}
}
}()
.toList();
},
);
}
Future<void> _deleteSelected() async {
final selectedFiles = selectedListItems
.whereType<_FileListItem>()
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
setState(() {
@ -404,87 +392,15 @@ class _TrashbinBrowserState extends State<TrashbinBrowser>
var _backingFiles = <File>[];
final _buildItemQueue =
ComputeQueue<PhotoListItemBuilderArguments, PhotoListItemBuilderResult>();
var _thumbZoomLevel = 0;
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
static final _log = Logger("widget.trashbin_browser._TrashbinBrowserState");
}
abstract class _ListItem implements SelectableItem {
_ListItem({
VoidCallback? onTap,
}) : _onTap = onTap;
@override
get onTap => _onTap;
@override
get isSelectable => true;
@override
get staggeredTile => const StaggeredTile.count(1, 1);
final VoidCallback? _onTap;
}
abstract class _FileListItem extends _ListItem {
_FileListItem({
required this.file,
VoidCallback? onTap,
}) : super(onTap: onTap);
@override
operator ==(Object other) {
return other is _FileListItem && file.path == other.file.path;
}
@override
get hashCode => file.path.hashCode;
final File file;
}
class _ImageListItem extends _FileListItem {
_ImageListItem({
required File file,
required this.account,
required this.previewUrl,
VoidCallback? onTap,
}) : super(file: file, onTap: onTap);
@override
buildWidget(BuildContext context) {
return PhotoListImage(
account: account,
previewUrl: previewUrl,
isGif: file.contentType == "image/gif",
);
}
final Account account;
final String previewUrl;
}
class _VideoListItem extends _FileListItem {
_VideoListItem({
required File file,
required this.account,
required this.previewUrl,
VoidCallback? onTap,
}) : super(file: file, onTap: onTap);
@override
buildWidget(BuildContext context) {
return PhotoListVideo(
account: account,
previewUrl: previewUrl,
);
}
final Account account;
final String previewUrl;
}
enum _AppBarMenuOption {
empty,
}
@ -492,3 +408,16 @@ enum _AppBarMenuOption {
enum _SelectionAppBarMenuOption {
delete,
}
int _fileSorter(File a, File b) {
if (a.trashbinDeletionTime == null && b.trashbinDeletionTime == null) {
// ?
return 0;
} else if (a.trashbinDeletionTime == null) {
return -1;
} else if (b.trashbinDeletionTime == null) {
return 1;
} else {
return b.trashbinDeletionTime!.compareTo(a.trashbinDeletionTime!);
}
}

View file

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