mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-01-22 16:56:19 +01:00
Merge branch 'files-controller'
This commit is contained in:
commit
99c8448bde
165 changed files with 6823 additions and 3144 deletions
|
@ -14,6 +14,8 @@ import 'package:nc_photos/entity/favorite.dart';
|
|||
import 'package:nc_photos/entity/favorite/data_source.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file/data_source.dart';
|
||||
import 'package:nc_photos/entity/file/data_source2.dart';
|
||||
import 'package:nc_photos/entity/file/repo.dart';
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
import 'package:nc_photos/entity/local_file/data_source.dart';
|
||||
import 'package:nc_photos/entity/nc_album/data_source.dart';
|
||||
|
@ -147,6 +149,10 @@ Future<void> _initDiContainer(InitIsolateType isolateType) async {
|
|||
c.fileRepo = FileRepo(FileCachedDataSource(c));
|
||||
c.fileRepoRemote = const FileRepo(FileWebdavDataSource());
|
||||
c.fileRepoLocal = FileRepo(FileSqliteDbDataSource(c));
|
||||
c.fileRepo2 =
|
||||
CachedFileRepo(const FileRemoteDataSource(), FileNpDbDataSource(c.npDb));
|
||||
c.fileRepo2Remote = const BasicFileRepo(FileRemoteDataSource());
|
||||
c.fileRepo2Local = BasicFileRepo(FileNpDbDataSource(c.npDb));
|
||||
c.shareRepo = ShareRepo(ShareRemoteDataSource());
|
||||
c.shareeRepo = ShareeRepo(ShareeRemoteDataSource());
|
||||
c.favoriteRepo = const FavoriteRepo(FavoriteRemoteDataSource());
|
||||
|
|
|
@ -219,7 +219,7 @@ class HomeSearchSuggestionBloc
|
|||
}
|
||||
try {
|
||||
final persons = await ListPerson(_c)(
|
||||
account, accountPrefController.personProvider.value)
|
||||
account, accountPrefController.personProviderValue)
|
||||
.last;
|
||||
product.addAll(persons.map((t) => _PersonSearcheable(t)));
|
||||
_log.info("[_onEventPreloadData] Loaded ${persons.length} people");
|
||||
|
|
|
@ -9,6 +9,7 @@ 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/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:nc_photos/entity/share.dart';
|
||||
import 'package:nc_photos/object_extension.dart';
|
||||
import 'package:nc_photos/use_case/list_share.dart';
|
||||
|
@ -28,12 +29,12 @@ class ListAlbumShareOutlierItem with EquatableMixin {
|
|||
String toString() => _$toString();
|
||||
|
||||
@override
|
||||
get props => [
|
||||
List<Object?> get props => [
|
||||
file,
|
||||
shareItems,
|
||||
];
|
||||
|
||||
final File file;
|
||||
final FileDescriptor file;
|
||||
@Format(r"${$?.toReadableString()}")
|
||||
final List<ListAlbumShareOutlierShareItem> shareItems;
|
||||
}
|
||||
|
@ -164,7 +165,6 @@ class ListAlbumShareOutlierBloc extends Bloc<ListAlbumShareOutlierBlocEvent,
|
|||
ListAlbumShareOutlierBlocState> {
|
||||
ListAlbumShareOutlierBloc(this._c)
|
||||
: assert(require(_c)),
|
||||
assert(ListShare.require(_c)),
|
||||
super(ListAlbumShareOutlierBlocInit()) {
|
||||
on<ListAlbumShareOutlierBlocEvent>(_onEvent);
|
||||
}
|
||||
|
@ -282,7 +282,7 @@ class ListAlbumShareOutlierBloc extends Bloc<ListAlbumShareOutlierBlocEvent,
|
|||
});
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe(
|
||||
"[_processAlbumItems] Failed while _processSingleFile: ${logFilename(fi.file.path)}",
|
||||
"[_processAlbumItems] Failed while _processSingleFile: ${logFilename(fi.file.fdPath)}",
|
||||
e,
|
||||
stackTrace);
|
||||
errors.add(e);
|
||||
|
@ -312,7 +312,7 @@ class ListAlbumShareOutlierBloc extends Bloc<ListAlbumShareOutlierBlocEvent,
|
|||
.map((s) => s.userId)
|
||||
.toSet();
|
||||
_log.info(
|
||||
"[_processSingleFileItem] Sharees: ${albumSharees.map((s) => managedAlbumSharees.contains(s) ? "(managed)$s" : s).toReadableString()} for file: ${logFilename(fileItem.file.path)}");
|
||||
"[_processSingleFileItem] Sharees: ${albumSharees.map((s) => managedAlbumSharees.contains(s) ? "(managed)$s" : s).toReadableString()} for file: ${logFilename(fileItem.file.fdPath)}");
|
||||
|
||||
// check all shares (including reshares) against sharees that are managed by
|
||||
// us
|
||||
|
@ -320,10 +320,10 @@ class ListAlbumShareOutlierBloc extends Bloc<ListAlbumShareOutlierBlocEvent,
|
|||
var missings = managedAlbumSharees
|
||||
.difference(allSharees)
|
||||
// Can't share to ourselves or the file owner
|
||||
.where((s) => s != account.userId && s != fileItem.file.ownerId)
|
||||
.where((s) => s != account.userId && s != fileItem.ownerId)
|
||||
.toList();
|
||||
_log.info(
|
||||
"[_processSingleFileItem] Missing shares: ${missings.toReadableString()} for file: ${logFilename(fileItem.file.path)}");
|
||||
"[_processSingleFileItem] Missing shares: ${missings.toReadableString()} for file: ${logFilename(fileItem.file.fdPath)}");
|
||||
for (final m in missings) {
|
||||
final as = albumShares[m]!;
|
||||
shareItems.add(
|
||||
|
@ -338,14 +338,14 @@ class ListAlbumShareOutlierBloc extends Bloc<ListAlbumShareOutlierBlocEvent,
|
|||
.toSet();
|
||||
final extras = ownedSharees.difference(albumSharees);
|
||||
_log.info(
|
||||
"[_processSingleFileItem] Extra shares: ${extras.toReadableString()} for file: ${logFilename(fileItem.file.path)}");
|
||||
"[_processSingleFileItem] Extra shares: ${extras.toReadableString()} for file: ${logFilename(fileItem.file.fdPath)}");
|
||||
for (final e in extras) {
|
||||
try {
|
||||
shareItems.add(ListAlbumShareOutlierExtraShareItem(
|
||||
shares.firstWhere((s) => s.shareWith == e)));
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe(
|
||||
"[_processSingleFileItem] Failed while processing extra share for file: ${logFilename(fileItem.file.path)}",
|
||||
"[_processSingleFileItem] Failed while processing extra share for file: ${logFilename(fileItem.file.fdPath)}",
|
||||
e,
|
||||
stackTrace);
|
||||
errors.add(e);
|
||||
|
|
|
@ -21,7 +21,7 @@ extension _$ListAlbumShareOutlierBlocNpLog on ListAlbumShareOutlierBloc {
|
|||
extension _$ListAlbumShareOutlierItemToString on ListAlbumShareOutlierItem {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "ListAlbumShareOutlierItem {file: ${file.path}, shareItems: ${shareItems.toReadableString()}}";
|
||||
return "ListAlbumShareOutlierItem {file: ${file.fdPath}, shareItems: ${shareItems.toReadableString()}}";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:to_string/to_string.dart';
|
||||
|
||||
part 'progress.g.dart';
|
||||
|
||||
abstract class ProgressBlocEvent {
|
||||
const ProgressBlocEvent();
|
||||
}
|
||||
|
||||
@toString
|
||||
class ProgressBlocUpdate extends ProgressBlocEvent {
|
||||
const ProgressBlocUpdate(this.progress, [this.text]);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final double progress;
|
||||
final String? text;
|
||||
}
|
||||
|
||||
@toString
|
||||
class ProgressBlocState with EquatableMixin {
|
||||
const ProgressBlocState(this.progress, this.text);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [progress, text];
|
||||
|
||||
final double progress;
|
||||
final String? text;
|
||||
}
|
||||
|
||||
/// A generic bloc to bubble progress update for some events
|
||||
@npLog
|
||||
class ProgressBloc extends Bloc<ProgressBlocEvent, ProgressBlocState> {
|
||||
ProgressBloc() : super(const ProgressBlocState(0, null)) {
|
||||
on<ProgressBlocEvent>(_onEvent);
|
||||
}
|
||||
|
||||
Future<void> _onEvent(
|
||||
ProgressBlocEvent ev, Emitter<ProgressBlocState> emit) async {
|
||||
_log.info("[_onEvent] $ev");
|
||||
if (ev is ProgressBlocUpdate) {
|
||||
await _onEventUpdate(ev, emit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onEventUpdate(
|
||||
ProgressBlocUpdate ev, Emitter<ProgressBlocState> emit) async {
|
||||
emit(ProgressBlocState(ev.progress, ev.text));
|
||||
}
|
||||
}
|
|
@ -1,551 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/bloc/bloc_util.dart' as bloc_util;
|
||||
import 'package:nc_photos/bloc/progress.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_descriptor.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/entity/pref.dart';
|
||||
import 'package:nc_photos/event/event.dart';
|
||||
import 'package:nc_photos/event/native_event.dart';
|
||||
import 'package:nc_photos/exception.dart';
|
||||
import 'package:nc_photos/progress_util.dart';
|
||||
import 'package:nc_photos/stream_extension.dart';
|
||||
import 'package:nc_photos/throttler.dart';
|
||||
import 'package:nc_photos/use_case/ls.dart';
|
||||
import 'package:nc_photos/use_case/scan_dir.dart';
|
||||
import 'package:nc_photos/use_case/scan_dir_offline.dart';
|
||||
import 'package:nc_photos/use_case/sync_dir.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_platform_image_processor/np_platform_image_processor.dart';
|
||||
import 'package:np_platform_util/np_platform_util.dart';
|
||||
import 'package:to_string/to_string.dart';
|
||||
|
||||
part 'scan_account_dir.g.dart';
|
||||
|
||||
abstract class ScanAccountDirBlocEvent {
|
||||
const ScanAccountDirBlocEvent();
|
||||
}
|
||||
|
||||
@toString
|
||||
abstract class ScanAccountDirBlocQueryBase extends ScanAccountDirBlocEvent {
|
||||
const ScanAccountDirBlocQueryBase({
|
||||
this.progressBloc,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
/// Get notified about the query progress
|
||||
final ProgressBloc? progressBloc;
|
||||
}
|
||||
|
||||
class ScanAccountDirBlocQuery extends ScanAccountDirBlocQueryBase {
|
||||
const ScanAccountDirBlocQuery({
|
||||
super.progressBloc,
|
||||
});
|
||||
}
|
||||
|
||||
class ScanAccountDirBlocRefresh extends ScanAccountDirBlocQueryBase {
|
||||
const ScanAccountDirBlocRefresh({
|
||||
super.progressBloc,
|
||||
});
|
||||
}
|
||||
|
||||
/// An external event has happened and may affect the state of this bloc
|
||||
@toString
|
||||
class _ScanAccountDirBlocExternalEvent extends ScanAccountDirBlocEvent {
|
||||
const _ScanAccountDirBlocExternalEvent();
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
}
|
||||
|
||||
@toString
|
||||
abstract class ScanAccountDirBlocState {
|
||||
const ScanAccountDirBlocState(this.files);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final List<FileDescriptor> files;
|
||||
}
|
||||
|
||||
class ScanAccountDirBlocInit extends ScanAccountDirBlocState {
|
||||
const ScanAccountDirBlocInit() : super(const []);
|
||||
}
|
||||
|
||||
class ScanAccountDirBlocLoading extends ScanAccountDirBlocState {
|
||||
const ScanAccountDirBlocLoading(
|
||||
List<FileDescriptor> files, {
|
||||
this.isInitialLoad = false,
|
||||
}) : super(files);
|
||||
|
||||
final bool isInitialLoad;
|
||||
}
|
||||
|
||||
class ScanAccountDirBlocSuccess extends ScanAccountDirBlocState {
|
||||
const ScanAccountDirBlocSuccess(List<FileDescriptor> files) : super(files);
|
||||
}
|
||||
|
||||
@toString
|
||||
class ScanAccountDirBlocFailure extends ScanAccountDirBlocState {
|
||||
const ScanAccountDirBlocFailure(List<FileDescriptor> files, this.exception)
|
||||
: super(files);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final dynamic exception;
|
||||
}
|
||||
|
||||
/// The state of this bloc is inconsistent. This typically means that the data
|
||||
/// may have been changed externally
|
||||
class ScanAccountDirBlocInconsistent extends ScanAccountDirBlocState {
|
||||
const ScanAccountDirBlocInconsistent(List<FileDescriptor> files)
|
||||
: super(files);
|
||||
}
|
||||
|
||||
/// A bloc that return all files under a dir recursively
|
||||
///
|
||||
/// See [ScanDir]
|
||||
@npLog
|
||||
class ScanAccountDirBloc
|
||||
extends Bloc<ScanAccountDirBlocEvent, ScanAccountDirBlocState> {
|
||||
ScanAccountDirBloc._(this.account) : super(const ScanAccountDirBlocInit()) {
|
||||
final c = KiwiContainer().resolve<DiContainer>();
|
||||
assert(require(c));
|
||||
_c = c;
|
||||
|
||||
_fileRemovedEventListener.begin();
|
||||
_filePropertyUpdatedEventListener.begin();
|
||||
_fileTrashbinRestoredEventListener.begin();
|
||||
_fileMovedEventListener.begin();
|
||||
_favoriteResyncedEventListener.begin();
|
||||
_prefUpdatedEventListener.begin();
|
||||
_accountPrefUpdatedEventListener.begin();
|
||||
|
||||
_nativeFileExifUpdatedListener?.begin();
|
||||
_imageProcessorUploadSuccessListener = _imageProcessorUploadSuccessStream
|
||||
?.listen(_onImageProcessorUploadSuccessEvent);
|
||||
|
||||
on<ScanAccountDirBlocEvent>(_onEvent, transformer: ((events, mapper) {
|
||||
return events.distinct((a, b) {
|
||||
// only handle ScanAccountDirBlocQuery
|
||||
final r = a is ScanAccountDirBlocQuery && b is ScanAccountDirBlocQuery;
|
||||
if (r) {
|
||||
_log.fine("[on] Skip identical ScanAccountDirBlocQuery event");
|
||||
}
|
||||
return r;
|
||||
}).asyncExpand(mapper);
|
||||
}));
|
||||
}
|
||||
|
||||
static bool require(DiContainer c) =>
|
||||
DiContainer.has(c, DiType.fileRepo) &&
|
||||
DiContainer.has(c, DiType.touchManager);
|
||||
|
||||
static ScanAccountDirBloc of(Account account) {
|
||||
final name =
|
||||
bloc_util.getInstNameForRootAwareAccount("ScanAccountDirBloc", account);
|
||||
try {
|
||||
_log.fine("[of] Resolving bloc for '$name'");
|
||||
return KiwiContainer().resolve<ScanAccountDirBloc>(name);
|
||||
} catch (_) {
|
||||
// no created instance for this account, make a new one
|
||||
_log.info("[of] New bloc instance for account: $account");
|
||||
final bloc = ScanAccountDirBloc._(account);
|
||||
KiwiContainer().registerInstance<ScanAccountDirBloc>(bloc, name: name);
|
||||
return bloc;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_fileRemovedEventListener.end();
|
||||
_filePropertyUpdatedEventListener.end();
|
||||
_fileTrashbinRestoredEventListener.end();
|
||||
_fileMovedEventListener.end();
|
||||
_favoriteResyncedEventListener.end();
|
||||
_prefUpdatedEventListener.end();
|
||||
_accountPrefUpdatedEventListener.end();
|
||||
|
||||
_nativeFileExifUpdatedListener?.end();
|
||||
_imageProcessorUploadSuccessListener?.cancel();
|
||||
_imageProcessorUploadSuccessListener = null;
|
||||
|
||||
_refreshThrottler.clear();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<void> _onEvent(ScanAccountDirBlocEvent event,
|
||||
Emitter<ScanAccountDirBlocState> emit) async {
|
||||
_log.info("[_onEvent] $event");
|
||||
if (event is ScanAccountDirBlocQueryBase) {
|
||||
await _onEventQuery(event, emit);
|
||||
} else if (event is _ScanAccountDirBlocExternalEvent) {
|
||||
await _onExternalEvent(event, emit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onEventQuery(ScanAccountDirBlocQueryBase ev,
|
||||
Emitter<ScanAccountDirBlocState> emit) async {
|
||||
_log.info("[_onEventQuery] $ev");
|
||||
emit(ScanAccountDirBlocLoading(state.files));
|
||||
final hasContent = state.files.isNotEmpty;
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
if (!hasContent) {
|
||||
try {
|
||||
emit(ScanAccountDirBlocLoading(await _queryOfflineMini(ev)));
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout(
|
||||
"[_onEventQuery] Failed while _queryOfflineMini", e, stackTrace);
|
||||
}
|
||||
_log.info(
|
||||
"[_onEventQuery] Elapsed time (_queryOfflineMini): ${stopwatch.elapsedMilliseconds}ms");
|
||||
stopwatch.reset();
|
||||
}
|
||||
final cacheFiles = await _queryOffline(ev);
|
||||
_log.info(
|
||||
"[_onEventQuery] Elapsed time (_queryOffline): ${stopwatch.elapsedMilliseconds}ms, ${cacheFiles.length} files");
|
||||
if (!hasContent) {
|
||||
// show something instantly on first load
|
||||
emit(ScanAccountDirBlocLoading(cacheFiles));
|
||||
}
|
||||
|
||||
if (!hasContent && cacheFiles.isEmpty) {
|
||||
emit(const ScanAccountDirBlocLoading([], isInitialLoad: true));
|
||||
}
|
||||
|
||||
stopwatch.reset();
|
||||
final bool hasUpdate;
|
||||
try {
|
||||
hasUpdate = await _syncOnline(
|
||||
ev,
|
||||
onProgressUpdate: (value) {
|
||||
if (ev.progressBloc?.isClosed == false) {
|
||||
ev.progressBloc!
|
||||
.add(ProgressBlocUpdate(value.progress, value.text));
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout("[_onEventQuery] Exception while request", e, stackTrace);
|
||||
emit(ScanAccountDirBlocFailure(cacheFiles, e));
|
||||
return;
|
||||
}
|
||||
_log.info(
|
||||
"[_onEventQuery] Elapsed time (_syncOnline): ${stopwatch.elapsedMilliseconds}ms, hasUpdate: $hasUpdate");
|
||||
if (hasUpdate) {
|
||||
// content updated, reload from db
|
||||
stopwatch.reset();
|
||||
final newFiles = await _queryOffline(ev);
|
||||
_log.info(
|
||||
"[_onEventQuery] Elapsed time (_queryOffline) 2nd pass: ${stopwatch.elapsedMilliseconds}ms, ${newFiles.length} files");
|
||||
emit(ScanAccountDirBlocSuccess(newFiles));
|
||||
} else {
|
||||
emit(ScanAccountDirBlocSuccess(cacheFiles));
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _syncOnline(
|
||||
ScanAccountDirBlocQueryBase ev, {
|
||||
ValueChanged<Progress>? onProgressUpdate,
|
||||
}) async {
|
||||
final settings = AccountPref.of(account);
|
||||
final shareDir =
|
||||
File(path: file_util.unstripPath(account, settings.getShareFolderOr()));
|
||||
bool isShareDirIncluded = false;
|
||||
|
||||
bool hasUpdate = false;
|
||||
_c.touchManager.clearTouchCache();
|
||||
final progress = IntProgress(account.roots.length);
|
||||
for (final r in account.roots) {
|
||||
final dirPath = file_util.unstripPath(account, r);
|
||||
hasUpdate |= await SyncDir(_c)(
|
||||
account,
|
||||
dirPath,
|
||||
onProgressUpdate: (value) {
|
||||
final merged = progress.progress + progress.step * value.progress;
|
||||
onProgressUpdate?.call(Progress(merged, value.text));
|
||||
},
|
||||
);
|
||||
isShareDirIncluded |=
|
||||
file_util.isOrUnderDir(shareDir, File(path: dirPath));
|
||||
progress.next();
|
||||
}
|
||||
|
||||
if (!isShareDirIncluded) {
|
||||
_log.info("[_syncOnline] Explicitly scanning share folder");
|
||||
hasUpdate |= await SyncDir(_c)(
|
||||
account,
|
||||
file_util.unstripPath(account, settings.getShareFolderOr()),
|
||||
isRecursive: false,
|
||||
);
|
||||
}
|
||||
return hasUpdate;
|
||||
}
|
||||
|
||||
Future<void> _onExternalEvent(_ScanAccountDirBlocExternalEvent ev,
|
||||
Emitter<ScanAccountDirBlocState> emit) async {
|
||||
emit(ScanAccountDirBlocInconsistent(state.files));
|
||||
}
|
||||
|
||||
void _onFileRemovedEvent(FileRemovedEvent ev) {
|
||||
if (state is ScanAccountDirBlocInit) {
|
||||
// no data in this bloc, ignore
|
||||
return;
|
||||
}
|
||||
if (_isFileOfInterest(ev.file)) {
|
||||
_log.info("[_onFileRemovedEvent] Request refresh");
|
||||
_refreshThrottler.trigger(
|
||||
maxResponceTime: const Duration(seconds: 3),
|
||||
maxPendingCount: 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onFilePropertyUpdatedEvent(FilePropertyUpdatedEvent ev) {
|
||||
if (!ev.hasAnyProperties([
|
||||
FilePropertyUpdatedEvent.propMetadata,
|
||||
FilePropertyUpdatedEvent.propIsArchived,
|
||||
FilePropertyUpdatedEvent.propOverrideDateTime,
|
||||
FilePropertyUpdatedEvent.propFavorite,
|
||||
])) {
|
||||
// not interested
|
||||
return;
|
||||
}
|
||||
if (state is ScanAccountDirBlocInit) {
|
||||
// no data in this bloc, ignore
|
||||
return;
|
||||
}
|
||||
if (!_isFileOfInterest(ev.file)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_log.info("[_onFilePropertyUpdatedEvent] Request refresh");
|
||||
if (ev.hasAnyProperties([
|
||||
FilePropertyUpdatedEvent.propIsArchived,
|
||||
FilePropertyUpdatedEvent.propOverrideDateTime,
|
||||
FilePropertyUpdatedEvent.propFavorite,
|
||||
])) {
|
||||
_refreshThrottler.trigger(
|
||||
maxResponceTime: const Duration(seconds: 3),
|
||||
maxPendingCount: 10,
|
||||
);
|
||||
} else {
|
||||
_refreshThrottler.trigger(
|
||||
maxResponceTime: const Duration(seconds: 10),
|
||||
maxPendingCount: 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onFileTrashbinRestoredEvent(FileTrashbinRestoredEvent ev) {
|
||||
if (state is ScanAccountDirBlocInit) {
|
||||
// no data in this bloc, ignore
|
||||
return;
|
||||
}
|
||||
_log.info("[_onFileTrashbinRestoredEvent] Request refresh");
|
||||
_refreshThrottler.trigger(
|
||||
maxResponceTime: const Duration(seconds: 3),
|
||||
maxPendingCount: 10,
|
||||
);
|
||||
}
|
||||
|
||||
void _onFileMovedEvent(FileMovedEvent ev) {
|
||||
if (state is ScanAccountDirBlocInit) {
|
||||
// no data in this bloc, ignore
|
||||
return;
|
||||
}
|
||||
if (_isFileOfInterest(ev.file)) {
|
||||
_log.info("[_onFileMovedEvent] Request refresh");
|
||||
_refreshThrottler.trigger(
|
||||
maxResponceTime: const Duration(seconds: 3),
|
||||
maxPendingCount: 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onFavoriteResyncedEvent(FavoriteResyncedEvent ev) {
|
||||
if (state is ScanAccountDirBlocInit) {
|
||||
// no data in this bloc, ignore
|
||||
return;
|
||||
}
|
||||
if (ev.account.compareServerIdentity(account)) {
|
||||
_log.info("[_onFavoriteResyncedEvent] Request refresh");
|
||||
_refreshThrottler.trigger(
|
||||
maxResponceTime: const Duration(seconds: 3),
|
||||
maxPendingCount: 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onPrefUpdatedEvent(PrefUpdatedEvent ev) {
|
||||
if (state is ScanAccountDirBlocInit) {
|
||||
// no data in this bloc, ignore
|
||||
return;
|
||||
}
|
||||
if (ev.key == PrefKey.accounts3) {
|
||||
_log.info("[_onPrefUpdatedEvent] Request refresh");
|
||||
_refreshThrottler.trigger(
|
||||
maxResponceTime: const Duration(seconds: 3),
|
||||
maxPendingCount: 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onAccountPrefUpdatedEvent(AccountPrefUpdatedEvent ev) {
|
||||
if (state is ScanAccountDirBlocInit) {
|
||||
// no data in this bloc, ignore
|
||||
return;
|
||||
}
|
||||
if (ev.key == AccountPrefKey.shareFolder &&
|
||||
identical(ev.pref, AccountPref.of(account))) {
|
||||
_log.info("[_onAccountPrefUpdatedEvent] Request refresh");
|
||||
_refreshThrottler.trigger(
|
||||
maxResponceTime: const Duration(seconds: 3),
|
||||
maxPendingCount: 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onNativeFileExifUpdated(FileExifUpdatedEvent ev) {
|
||||
_log.info("[_onNativeFileExifUpdated] Request refresh");
|
||||
_refreshThrottler.trigger(
|
||||
maxResponceTime: const Duration(seconds: 3),
|
||||
maxPendingCount: 10,
|
||||
);
|
||||
}
|
||||
|
||||
void _onImageProcessorUploadSuccessEvent(
|
||||
ImageProcessorUploadSuccessEvent ev) {
|
||||
_log.info("[_onImageProcessorUploadSuccessEvent] Request refresh");
|
||||
_refreshThrottler.trigger(
|
||||
maxResponceTime: const Duration(seconds: 3),
|
||||
maxPendingCount: 10,
|
||||
);
|
||||
}
|
||||
|
||||
/// Query a small amount of files to give an illusion of quick startup
|
||||
Future<List<FileDescriptor>> _queryOfflineMini(
|
||||
ScanAccountDirBlocQueryBase ev) async {
|
||||
return await ScanDirOfflineMini(_c)(
|
||||
account,
|
||||
account.roots
|
||||
.map((r) => File(path: file_util.unstripPath(account, r)))
|
||||
.toList(),
|
||||
scanMiniCount,
|
||||
isOnlySupportedFormat: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<FileDescriptor>> _queryOffline(
|
||||
ScanAccountDirBlocQueryBase ev) async {
|
||||
final settings = AccountPref.of(account);
|
||||
final shareDir =
|
||||
File(path: file_util.unstripPath(account, settings.getShareFolderOr()));
|
||||
bool isShareDirIncluded = false;
|
||||
|
||||
final files = <FileDescriptor>[];
|
||||
for (final r in account.roots) {
|
||||
try {
|
||||
final dir = File(path: file_util.unstripPath(account, r));
|
||||
files.addAll(await ScanDirOffline(_c)(account, dir,
|
||||
isOnlySupportedFormat: true));
|
||||
isShareDirIncluded |= file_util.isOrUnderDir(shareDir, dir);
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout(
|
||||
"[_queryOffline] Failed while ScanDirOffline: ${logFilename(r)}",
|
||||
e,
|
||||
stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isShareDirIncluded) {
|
||||
_log.info("[_queryOffline] Explicitly scanning share folder");
|
||||
try {
|
||||
final raw = await Ls(_c.fileRepoLocal)(account, shareDir);
|
||||
files.addAll(raw.where((f) => file_util.isSupportedFormat(f)));
|
||||
} on CacheNotFoundException catch (_) {
|
||||
// normal when there's no cache
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout(
|
||||
"[_queryOffline] Failed while ScanDirOffline: ${logFilename(shareDir.path)}",
|
||||
e,
|
||||
stackTrace);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
bool _isFileOfInterest(FileDescriptor file) {
|
||||
if (!file_util.isSupportedFormat(file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (final r in account.roots) {
|
||||
final dir = File(path: file_util.unstripPath(account, r));
|
||||
if (file_util.isUnderDir(file, dir)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
final settings = AccountPref.of(account);
|
||||
final shareDir =
|
||||
File(path: file_util.unstripPath(account, settings.getShareFolderOr()));
|
||||
if (file_util.isUnderDir(file, shareDir)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
late final DiContainer _c;
|
||||
|
||||
final Account account;
|
||||
|
||||
static const scanMiniCount = 100;
|
||||
|
||||
late final _fileRemovedEventListener =
|
||||
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
|
||||
late final _filePropertyUpdatedEventListener =
|
||||
AppEventListener<FilePropertyUpdatedEvent>(_onFilePropertyUpdatedEvent);
|
||||
late final _fileTrashbinRestoredEventListener =
|
||||
AppEventListener<FileTrashbinRestoredEvent>(_onFileTrashbinRestoredEvent);
|
||||
late final _fileMovedEventListener =
|
||||
AppEventListener<FileMovedEvent>(_onFileMovedEvent);
|
||||
late final _favoriteResyncedEventListener =
|
||||
AppEventListener<FavoriteResyncedEvent>(_onFavoriteResyncedEvent);
|
||||
late final _prefUpdatedEventListener =
|
||||
AppEventListener<PrefUpdatedEvent>(_onPrefUpdatedEvent);
|
||||
late final _accountPrefUpdatedEventListener =
|
||||
AppEventListener<AccountPrefUpdatedEvent>(_onAccountPrefUpdatedEvent);
|
||||
|
||||
late final _nativeFileExifUpdatedListener = getRawPlatform() == NpPlatform.web
|
||||
? null
|
||||
: NativeEventListener<FileExifUpdatedEvent>(_onNativeFileExifUpdated);
|
||||
|
||||
Stream<ImageProcessorUploadSuccessEvent>?
|
||||
get _imageProcessorUploadSuccessStream => getRawPlatform() ==
|
||||
NpPlatform.web
|
||||
? null
|
||||
: ImageProcessor.stream.whereType<ImageProcessorUploadSuccessEvent>();
|
||||
StreamSubscription? _imageProcessorUploadSuccessListener;
|
||||
|
||||
late final _refreshThrottler = Throttler(
|
||||
onTriggered: (_) {
|
||||
add(const _ScanAccountDirBlocExternalEvent());
|
||||
},
|
||||
logTag: "ScanAccountDirBloc.refresh",
|
||||
);
|
||||
|
||||
static final _log = _$ScanAccountDirBlocNpLog.log;
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'scan_account_dir.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$ScanAccountDirBlocNpLog on ScanAccountDirBloc {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("bloc.scan_account_dir.ScanAccountDirBloc");
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// ToStringGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$ScanAccountDirBlocQueryBaseToString on ScanAccountDirBlocQueryBase {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "${objectRuntimeType(this, "ScanAccountDirBlocQueryBase")} {progressBloc: $progressBloc}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_ScanAccountDirBlocExternalEventToString
|
||||
on _ScanAccountDirBlocExternalEvent {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_ScanAccountDirBlocExternalEvent {}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$ScanAccountDirBlocStateToString on ScanAccountDirBlocState {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "${objectRuntimeType(this, "ScanAccountDirBlocState")} {files: [length: ${files.length}]}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$ScanAccountDirBlocFailureToString on ScanAccountDirBlocFailure {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "ScanAccountDirBlocFailure {files: [length: ${files.length}], exception: $exception}";
|
||||
}
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/single_child_widget.dart';
|
||||
|
||||
mixin BlocLogger {
|
||||
String? get tag => null;
|
||||
|
@ -6,6 +8,28 @@ mixin BlocLogger {
|
|||
bool Function(dynamic currentState, dynamic nextState)? get shouldLog => null;
|
||||
}
|
||||
|
||||
class BlocListenerT<B extends StateStreamable<S>, S, T>
|
||||
extends SingleChildStatelessWidget {
|
||||
const BlocListenerT({
|
||||
super.key,
|
||||
required this.selector,
|
||||
required this.listener,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget buildWithChild(BuildContext context, Widget? child) {
|
||||
return BlocListener<B, S>(
|
||||
listenWhen: (previous, current) =>
|
||||
selector(previous) != selector(current),
|
||||
listener: (context, state) => listener(context, selector(state)),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
final BlocWidgetSelector<S, T> selector;
|
||||
final void Function(BuildContext context, T state) listener;
|
||||
}
|
||||
|
||||
/// Wrap around a string such that two strings with the same value will fail
|
||||
/// the identical check
|
||||
class StateMessage {
|
||||
|
@ -25,3 +49,11 @@ extension EmitterExtension<State> on Emitter<State> {
|
|||
onError: (_, __) {},
|
||||
);
|
||||
}
|
||||
|
||||
extension BlocExtension<E, S> on Bloc<E, S> {
|
||||
void safeAdd(E event) {
|
||||
if (!isClosed) {
|
||||
add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,17 +2,26 @@ import 'package:kiwi/kiwi.dart';
|
|||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/controller/account_pref_controller.dart';
|
||||
import 'package:nc_photos/controller/collections_controller.dart';
|
||||
import 'package:nc_photos/controller/files_controller.dart';
|
||||
import 'package:nc_photos/controller/metadata_controller.dart';
|
||||
import 'package:nc_photos/controller/persons_controller.dart';
|
||||
import 'package:nc_photos/controller/places_controller.dart';
|
||||
import 'package:nc_photos/controller/pref_controller.dart';
|
||||
import 'package:nc_photos/controller/server_controller.dart';
|
||||
import 'package:nc_photos/controller/session_controller.dart';
|
||||
import 'package:nc_photos/controller/sharings_controller.dart';
|
||||
import 'package:nc_photos/controller/sync_controller.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/event/native_event_relay.dart';
|
||||
|
||||
class AccountController {
|
||||
AccountController({
|
||||
required this.prefController,
|
||||
});
|
||||
|
||||
void setCurrentAccount(Account account) {
|
||||
_account = account;
|
||||
|
||||
_collectionsController?.dispose();
|
||||
_collectionsController = null;
|
||||
_serverController?.dispose();
|
||||
|
@ -29,6 +38,16 @@ class AccountController {
|
|||
_sharingsController = null;
|
||||
_placesController?.dispose();
|
||||
_placesController = null;
|
||||
_filesController?.dispose();
|
||||
_filesController = null;
|
||||
|
||||
_metadataController?.dispose();
|
||||
_metadataController = null;
|
||||
_nativeEventRelay?.dispose();
|
||||
_nativeEventRelay = NativeEventRelay(
|
||||
filesController: filesController,
|
||||
metadataController: metadataController,
|
||||
);
|
||||
}
|
||||
|
||||
Account get account => _account!;
|
||||
|
@ -36,6 +55,7 @@ class AccountController {
|
|||
CollectionsController get collectionsController =>
|
||||
_collectionsController ??= CollectionsController(
|
||||
KiwiContainer().resolve<DiContainer>(),
|
||||
filesController: filesController,
|
||||
account: _account!,
|
||||
serverController: serverController,
|
||||
);
|
||||
|
@ -76,7 +96,24 @@ class AccountController {
|
|||
account: _account!,
|
||||
);
|
||||
|
||||
FilesController get filesController => _filesController ??= FilesController(
|
||||
KiwiContainer().resolve<DiContainer>(),
|
||||
account: _account!,
|
||||
accountPrefController: accountPrefController,
|
||||
);
|
||||
|
||||
MetadataController get metadataController =>
|
||||
_metadataController ??= MetadataController(
|
||||
KiwiContainer().resolve(),
|
||||
account: account,
|
||||
filesController: filesController,
|
||||
prefController: prefController,
|
||||
);
|
||||
|
||||
PrefController prefController;
|
||||
|
||||
Account? _account;
|
||||
|
||||
CollectionsController? _collectionsController;
|
||||
ServerController? _serverController;
|
||||
AccountPrefController? _accountPrefController;
|
||||
|
@ -85,4 +122,8 @@ class AccountController {
|
|||
SessionController? _sessionController;
|
||||
SharingsController? _sharingsController;
|
||||
PlacesController? _placesController;
|
||||
FilesController? _filesController;
|
||||
|
||||
MetadataController? _metadataController;
|
||||
NativeEventRelay? _nativeEventRelay;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import 'package:rxdart/rxdart.dart';
|
|||
part 'account_pref_controller.g.dart';
|
||||
|
||||
@npLog
|
||||
@npSubjectAccessor
|
||||
class AccountPrefController {
|
||||
AccountPrefController({
|
||||
required this.account,
|
||||
|
@ -20,34 +21,24 @@ class AccountPrefController {
|
|||
_isEnableMemoryAlbumController.close();
|
||||
}
|
||||
|
||||
ValueStream<String> get shareFolder => _shareFolderController.stream;
|
||||
|
||||
Future<void> setShareFolder(String value) => _set<String>(
|
||||
controller: _shareFolderController,
|
||||
setter: (pref, value) => pref.setShareFolder(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<String?> get accountLabel => _accountLabelController.stream;
|
||||
|
||||
Future<void> setAccountLabel(String? value) => _set<String?>(
|
||||
controller: _accountLabelController,
|
||||
setter: (pref, value) => pref.setAccountLabel(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<PersonProvider> get personProvider =>
|
||||
_personProviderController.stream;
|
||||
|
||||
Future<void> setPersonProvider(PersonProvider value) => _set<PersonProvider>(
|
||||
controller: _personProviderController,
|
||||
setter: (pref, value) => pref.setPersonProvider(value.index),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<bool> get isEnableMemoryAlbum =>
|
||||
_isEnableMemoryAlbumController.stream;
|
||||
|
||||
Future<void> setEnableMemoryAlbum(bool value) => _set<bool>(
|
||||
controller: _isEnableMemoryAlbumController,
|
||||
setter: (pref, value) => pref.setEnableMemoryAlbum(value),
|
||||
|
@ -76,12 +67,16 @@ class AccountPrefController {
|
|||
final Account account;
|
||||
|
||||
final AccountPref _accountPref;
|
||||
@npSubjectAccessor
|
||||
late final _shareFolderController =
|
||||
BehaviorSubject.seeded(_accountPref.getShareFolderOr(""));
|
||||
@npSubjectAccessor
|
||||
late final _accountLabelController =
|
||||
BehaviorSubject.seeded(_accountPref.getAccountLabel());
|
||||
@npSubjectAccessor
|
||||
late final _personProviderController = BehaviorSubject.seeded(
|
||||
PersonProvider.fromValue(_accountPref.getPersonProviderOr()));
|
||||
@npSubjectAccessor
|
||||
late final _isEnableMemoryAlbumController =
|
||||
BehaviorSubject.seeded(_accountPref.isEnableMemoryAlbumOr(true));
|
||||
}
|
||||
|
|
|
@ -13,3 +13,34 @@ extension _$AccountPrefControllerNpLog on AccountPrefController {
|
|||
static final log =
|
||||
Logger("controller.account_pref_controller.AccountPrefController");
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// NpSubjectAccessorGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension $AccountPrefControllerNpSubjectAccessor on AccountPrefController {
|
||||
// _shareFolderController
|
||||
ValueStream<String> get shareFolder => _shareFolderController.stream;
|
||||
Stream<String> get shareFolderNew => shareFolder.skip(1);
|
||||
Stream<String> get shareFolderChange => shareFolder.distinct().skip(1);
|
||||
String get shareFolderValue => _shareFolderController.value;
|
||||
// _accountLabelController
|
||||
ValueStream<String?> get accountLabel => _accountLabelController.stream;
|
||||
Stream<String?> get accountLabelNew => accountLabel.skip(1);
|
||||
Stream<String?> get accountLabelChange => accountLabel.distinct().skip(1);
|
||||
String? get accountLabelValue => _accountLabelController.value;
|
||||
// _personProviderController
|
||||
ValueStream<PersonProvider> get personProvider =>
|
||||
_personProviderController.stream;
|
||||
Stream<PersonProvider> get personProviderNew => personProvider.skip(1);
|
||||
Stream<PersonProvider> get personProviderChange =>
|
||||
personProvider.distinct().skip(1);
|
||||
PersonProvider get personProviderValue => _personProviderController.value;
|
||||
// _isEnableMemoryAlbumController
|
||||
ValueStream<bool> get isEnableMemoryAlbum =>
|
||||
_isEnableMemoryAlbumController.stream;
|
||||
Stream<bool> get isEnableMemoryAlbumNew => isEnableMemoryAlbum.skip(1);
|
||||
Stream<bool> get isEnableMemoryAlbumChange =>
|
||||
isEnableMemoryAlbum.distinct().skip(1);
|
||||
bool get isEnableMemoryAlbumValue => _isEnableMemoryAlbumController.value;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:mutex/mutex.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/controller/files_controller.dart';
|
||||
import 'package:nc_photos/debug_util.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/collection.dart';
|
||||
|
@ -13,7 +14,6 @@ import 'package:nc_photos/entity/collection/adapter.dart';
|
|||
import 'package:nc_photos/entity/collection_item.dart';
|
||||
import 'package:nc_photos/entity/collection_item/new_item.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:nc_photos/event/event.dart';
|
||||
import 'package:nc_photos/exception_event.dart';
|
||||
import 'package:nc_photos/object_extension.dart';
|
||||
import 'package:nc_photos/rx_extension.dart';
|
||||
|
@ -46,18 +46,28 @@ class CollectionItemStreamData {
|
|||
class CollectionItemsController {
|
||||
CollectionItemsController(
|
||||
this._c, {
|
||||
required this.filesController,
|
||||
required this.account,
|
||||
required this.collection,
|
||||
required this.onCollectionUpdated,
|
||||
}) {
|
||||
_fileRemovedEventListener.begin();
|
||||
_countStreamController = BehaviorSubject.seeded(collection.count);
|
||||
_subscriptions.add(_dataStreamController.stream.listen((event) {
|
||||
if (!event.hasNext) {
|
||||
_countStreamController.add(event.items.length);
|
||||
}
|
||||
}));
|
||||
|
||||
_subscriptions.add(filesController.stream.listen(_onFilesEvent));
|
||||
}
|
||||
|
||||
/// Dispose this controller and release all internal resources
|
||||
///
|
||||
/// MUST be called
|
||||
void dispose() {
|
||||
_fileRemovedEventListener.end();
|
||||
for (final s in _subscriptions) {
|
||||
s.cancel();
|
||||
}
|
||||
_dataStreamController.close();
|
||||
}
|
||||
|
||||
|
@ -79,6 +89,8 @@ class CollectionItemsController {
|
|||
/// Peek the stream and return the current value
|
||||
CollectionItemStreamData peekStream() => _dataStreamController.stream.value;
|
||||
|
||||
ValueStream<int?> get countStream => _countStreamController.stream;
|
||||
|
||||
/// Add list of [files] to [collection]
|
||||
Future<void> addFiles(List<FileDescriptor> files) async {
|
||||
final isInited = _isDataStreamInited;
|
||||
|
@ -309,26 +321,36 @@ class CollectionItemsController {
|
|||
}
|
||||
}
|
||||
|
||||
void _onFileRemovedEvent(FileRemovedEvent ev) {
|
||||
// if (account != ev.account) {
|
||||
// return;
|
||||
// }
|
||||
// final newItems = _dataStreamController.value.items.where((e) {
|
||||
// if (e is CollectionFileItem) {
|
||||
// return !e.file.compareServerIdentity(ev.file);
|
||||
// } else {
|
||||
// return true;
|
||||
// }
|
||||
// }).toList();
|
||||
// if (newItems.length != _dataStreamController.value.items.length) {
|
||||
// // item of interest
|
||||
// _dataStreamController.addWithValue((value) => value.copyWith(
|
||||
// items: newItems,
|
||||
// ));
|
||||
// }
|
||||
Future<void> _onFilesEvent(FilesStreamEvent ev) async {
|
||||
if (!_isDataStreamInited || ev.hasNext || collection.isDynamicCollection) {
|
||||
// clean up only make sense for static albums
|
||||
return;
|
||||
}
|
||||
await _mutex.protect(() async {
|
||||
final newItems = _dataStreamController.value.items
|
||||
.map((e) {
|
||||
if (e is CollectionFileItem) {
|
||||
final file = ev.dataMap[e.file.fdId];
|
||||
if (file == null) {
|
||||
// removed
|
||||
return null;
|
||||
} else {
|
||||
return e.copyWith(file: file);
|
||||
}
|
||||
} else {
|
||||
return e;
|
||||
}
|
||||
})
|
||||
.whereNotNull()
|
||||
.toList();
|
||||
_dataStreamController.addWithValue((value) => value.copyWith(
|
||||
items: newItems,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
final DiContainer _c;
|
||||
final FilesController filesController;
|
||||
final Account account;
|
||||
Collection collection;
|
||||
ValueChanged<Collection> onCollectionUpdated;
|
||||
|
@ -340,9 +362,8 @@ class CollectionItemsController {
|
|||
hasNext: true,
|
||||
),
|
||||
);
|
||||
|
||||
late final _fileRemovedEventListener =
|
||||
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
|
||||
late final BehaviorSubject<int?> _countStreamController;
|
||||
|
||||
final _mutex = Mutex();
|
||||
final _subscriptions = <StreamSubscription>[];
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import 'package:logging/logging.dart';
|
|||
import 'package:mutex/mutex.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/controller/collection_items_controller.dart';
|
||||
import 'package:nc_photos/controller/files_controller.dart';
|
||||
import 'package:nc_photos/controller/server_controller.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/collection.dart';
|
||||
|
@ -64,6 +65,7 @@ class CollectionStreamEvent {
|
|||
class CollectionsController {
|
||||
CollectionsController(
|
||||
this._c, {
|
||||
required this.filesController,
|
||||
required this.account,
|
||||
required this.serverController,
|
||||
});
|
||||
|
@ -309,6 +311,7 @@ class CollectionsController {
|
|||
final k = _CollectionKey(c);
|
||||
_itemControllers[k] ??= CollectionItemsController(
|
||||
_c,
|
||||
filesController: filesController,
|
||||
account: account,
|
||||
collection: k.collection,
|
||||
onCollectionUpdated: _updateCollection,
|
||||
|
@ -347,6 +350,7 @@ class CollectionsController {
|
|||
}
|
||||
|
||||
final DiContainer _c;
|
||||
final FilesController filesController;
|
||||
final Account account;
|
||||
final ServerController serverController;
|
||||
|
||||
|
|
439
app/lib/controller/files_controller.dart
Normal file
439
app/lib/controller/files_controller.dart
Normal file
|
@ -0,0 +1,439 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mutex/mutex.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/controller/account_pref_controller.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_descriptor.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/progress_util.dart';
|
||||
import 'package:nc_photos/rx_extension.dart';
|
||||
import 'package:nc_photos/use_case/file/list_file.dart';
|
||||
import 'package:nc_photos/use_case/find_file_descriptor.dart';
|
||||
import 'package:nc_photos/use_case/remove.dart';
|
||||
import 'package:nc_photos/use_case/sync_dir.dart';
|
||||
import 'package:nc_photos/use_case/update_property.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_common/lazy.dart';
|
||||
import 'package:np_common/object_util.dart';
|
||||
import 'package:np_common/or_null.dart';
|
||||
import 'package:np_db/np_db.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:to_string/to_string.dart';
|
||||
|
||||
part 'files_controller.g.dart';
|
||||
|
||||
abstract class FilesStreamEvent {
|
||||
/// All files as a ordered list
|
||||
List<FileDescriptor> get data;
|
||||
|
||||
/// All files as a map with the fileId as key
|
||||
Map<int, FileDescriptor> get dataMap;
|
||||
bool get hasNext;
|
||||
}
|
||||
|
||||
@npLog
|
||||
class FilesController {
|
||||
FilesController(
|
||||
this._c, {
|
||||
required this.account,
|
||||
required this.accountPrefController,
|
||||
}) {
|
||||
_subscriptions.add(accountPrefController.shareFolderChange.listen((event) {
|
||||
// sync remote if share folder is modified
|
||||
if (_isDataStreamInited) {
|
||||
syncRemote();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final s in _subscriptions) {
|
||||
s.cancel();
|
||||
}
|
||||
_dataStreamController.close();
|
||||
}
|
||||
|
||||
/// Return a stream of files associated with [account]
|
||||
///
|
||||
/// The returned stream will emit new list of files whenever there are
|
||||
/// changes to the files (e.g., new file, removed file, etc)
|
||||
///
|
||||
/// There's no guarantee that the returned list is always sorted in some ways,
|
||||
/// callers must sort it by themselves if the ordering is important
|
||||
ValueStream<FilesStreamEvent> get stream {
|
||||
if (!_isDataStreamInited) {
|
||||
_isDataStreamInited = true;
|
||||
_load();
|
||||
}
|
||||
return _dataStreamController.stream;
|
||||
}
|
||||
|
||||
Future<void> syncRemote({
|
||||
void Function(Progress progress)? onProgressUpdate,
|
||||
}) async {
|
||||
if (_isSyncing) {
|
||||
_log.fine("[syncRemote] Skipped as another sync running");
|
||||
return;
|
||||
}
|
||||
_isSyncing = true;
|
||||
try {
|
||||
final shareDir = File(
|
||||
path: file_util.unstripPath(
|
||||
account, accountPrefController.shareFolderValue),
|
||||
);
|
||||
var isShareDirIncluded = false;
|
||||
|
||||
_c.touchManager.clearTouchCache();
|
||||
final progress = IntProgress(account.roots.length);
|
||||
var hasChange = false;
|
||||
for (final r in account.roots) {
|
||||
final dirPath = file_util.unstripPath(account, r);
|
||||
hasChange |= await SyncDir(_c)(
|
||||
account,
|
||||
dirPath,
|
||||
onProgressUpdate: (value) {
|
||||
final merged = progress.progress + progress.step * value.progress;
|
||||
onProgressUpdate?.call(Progress(merged, value.text));
|
||||
},
|
||||
);
|
||||
isShareDirIncluded |=
|
||||
file_util.isOrUnderDirPath(shareDir.path, dirPath);
|
||||
progress.next();
|
||||
}
|
||||
|
||||
if (!isShareDirIncluded) {
|
||||
_log.info("[syncRemote] Explicitly scanning share folder");
|
||||
hasChange |=
|
||||
await SyncDir(_c)(account, shareDir.path, isRecursive: false);
|
||||
}
|
||||
if (hasChange) {
|
||||
// load the synced content to stream
|
||||
unawaited(_reload());
|
||||
}
|
||||
} finally {
|
||||
_isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update files property and return number of files updated
|
||||
Future<void> updateProperty(
|
||||
List<FileDescriptor> files, {
|
||||
OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
bool? isFavorite,
|
||||
OrNull<ImageLocation>? location,
|
||||
Exception? Function(List<int> fileIds) errorBuilder =
|
||||
UpdatePropertyFailureError.new,
|
||||
}) async {
|
||||
final backups = <int, FileDescriptor>{};
|
||||
// file ids that need to be queried again to get the correct
|
||||
// FileDescriptor.fdDateTime
|
||||
final outdated = <int>[];
|
||||
await _mutex.protect(() async {
|
||||
final next = Map.of(_dataStreamController.value.files);
|
||||
for (final f in files) {
|
||||
final original = next[f.fdId];
|
||||
if (original == null) {
|
||||
_log.warning("[updateProperty] File not found: $f");
|
||||
continue;
|
||||
}
|
||||
backups[f.fdId] = original;
|
||||
if (original is File) {
|
||||
next[f.fdId] = original.copyWith(
|
||||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
isFavorite: isFavorite,
|
||||
location: location,
|
||||
);
|
||||
} else {
|
||||
next[f.fdId] = original.copyWith(
|
||||
fdIsArchived: isArchived == null ? null : (isArchived.obj ?? false),
|
||||
// in case of unsetting, we can't work out the new value here
|
||||
fdDateTime: overrideDateTime?.obj,
|
||||
fdIsFavorite: isFavorite,
|
||||
);
|
||||
if (OrNull.isSetNull(overrideDateTime)) {
|
||||
outdated.add(f.fdId);
|
||||
}
|
||||
}
|
||||
}
|
||||
_dataStreamController
|
||||
.addWithValue((value) => value.copyWith(files: next));
|
||||
});
|
||||
final failures = <int>[];
|
||||
for (final f in files) {
|
||||
try {
|
||||
await UpdateProperty(_c)(
|
||||
account,
|
||||
f,
|
||||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
favorite: isFavorite,
|
||||
location: location,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe(
|
||||
"[updateProperty] Failed while UpdateProperty: ${logFilename(f.fdPath)}",
|
||||
e,
|
||||
stackTrace);
|
||||
failures.add(f.fdId);
|
||||
outdated.remove(f.fdId);
|
||||
}
|
||||
}
|
||||
if (failures.isNotEmpty) {
|
||||
// restore
|
||||
final next = Map.of(_dataStreamController.value.files);
|
||||
for (final f in failures) {
|
||||
if (backups.containsKey(f)) {
|
||||
next[f] = backups[f]!;
|
||||
}
|
||||
}
|
||||
_dataStreamController
|
||||
.addWithValue((value) => value.copyWith(files: next));
|
||||
errorBuilder(failures)?.let(_dataStreamController.addError);
|
||||
}
|
||||
// TODO query outdated
|
||||
}
|
||||
|
||||
Future<void> remove(
|
||||
List<FileDescriptor> files, {
|
||||
Exception? Function(List<int> fileIds) errorBuilder =
|
||||
RemoveFailureError.new,
|
||||
}) async {
|
||||
final backups = <int, FileDescriptor>{};
|
||||
await _mutex.protect(() async {
|
||||
final next = Map.of(_dataStreamController.value.files);
|
||||
for (final f in files) {
|
||||
final original = next.remove(f.fdId);
|
||||
if (original == null) {
|
||||
_log.warning("[remove] File not found: $f");
|
||||
continue;
|
||||
}
|
||||
backups[f.fdId] = original;
|
||||
}
|
||||
_dataStreamController
|
||||
.addWithValue((value) => value.copyWith(files: next));
|
||||
});
|
||||
final failures = <int>[];
|
||||
try {
|
||||
await Remove(_c)(
|
||||
account,
|
||||
files,
|
||||
onError: (index, value, error, stackTrace) {
|
||||
_log.severe(
|
||||
"[remove] Failed while Remove: ${logFilename(value.fdPath)}",
|
||||
error,
|
||||
stackTrace);
|
||||
failures.add(value.fdId);
|
||||
},
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe("[remove] Failed while Remove", e, stackTrace);
|
||||
failures.addAll(files.map((e) => e.fdId));
|
||||
}
|
||||
if (failures.isNotEmpty) {
|
||||
// restore
|
||||
final next = LinkedHashMap.of(_dataStreamController.value.files);
|
||||
for (final f in failures) {
|
||||
if (backups.containsKey(f)) {
|
||||
next[f] = backups[f]!;
|
||||
}
|
||||
}
|
||||
_dataStreamController
|
||||
.addWithValue((value) => value.copyWith(files: next));
|
||||
errorBuilder(failures)?.let(_dataStreamController.addError);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> applySyncResult({
|
||||
DbSyncIdResult? favorites,
|
||||
List<int>? fileExifs,
|
||||
}) async {
|
||||
if (favorites?.isNotEmpty != true && fileExifs?.isNotEmpty != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// do async ops first
|
||||
final fileExifFiles =
|
||||
await fileExifs?.letFuture((e) async => await FindFileDescriptor(_c)(
|
||||
account,
|
||||
e,
|
||||
onFileNotFound: (id) {
|
||||
_log.warning("[applySyncResult] File id not found: $id");
|
||||
},
|
||||
));
|
||||
|
||||
final next = LinkedHashMap.of(_dataStreamController.value.files);
|
||||
if (favorites != null && favorites.isNotEmpty) {
|
||||
_applySyncFavoriteResult(next, favorites);
|
||||
}
|
||||
if (fileExifFiles != null && fileExifFiles.isNotEmpty) {
|
||||
_applySyncFileExifResult(next, fileExifFiles);
|
||||
}
|
||||
_dataStreamController.addWithValue((value) => value.copyWith(files: next));
|
||||
}
|
||||
|
||||
void _applySyncFavoriteResult(
|
||||
Map<int, FileDescriptor> next, DbSyncIdResult result) {
|
||||
for (final id in result.insert) {
|
||||
final f = next[id];
|
||||
if (f == null) {
|
||||
_log.warning("[_applySyncFavoriteResult] File id not found: $id");
|
||||
continue;
|
||||
}
|
||||
if (f is File) {
|
||||
next[id] = f.copyWith(isFavorite: true);
|
||||
} else {
|
||||
next[id] = f.copyWith(fdIsFavorite: true);
|
||||
}
|
||||
}
|
||||
for (final id in result.delete) {
|
||||
final f = next[id];
|
||||
if (f == null) {
|
||||
_log.warning("[_applySyncFavoriteResult] File id not found: $id");
|
||||
continue;
|
||||
}
|
||||
if (f is File) {
|
||||
next[id] = f.copyWith(isFavorite: false);
|
||||
} else {
|
||||
next[id] = f.copyWith(fdIsFavorite: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _applySyncFileExifResult(
|
||||
Map<int, FileDescriptor> next, List<FileDescriptor> results) {
|
||||
for (final f in results) {
|
||||
next[f.fdId] = f;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
var lastData = _FilesStreamEvent(
|
||||
files: const {},
|
||||
hasNext: false,
|
||||
);
|
||||
final completer = Completer();
|
||||
ListFile(_c)(
|
||||
account,
|
||||
file_util.unstripPath(account, accountPrefController.shareFolderValue),
|
||||
).listen(
|
||||
(ev) {
|
||||
lastData = _convertListResultsToEvent(ev, hasNext: true);
|
||||
_dataStreamController.add(lastData);
|
||||
},
|
||||
onError: _dataStreamController.addError,
|
||||
onDone: () => completer.complete(),
|
||||
);
|
||||
await completer.future;
|
||||
_dataStreamController.add(lastData.copyWith(hasNext: false));
|
||||
}
|
||||
|
||||
Future<void> _reload() async {
|
||||
var results = <FileDescriptor>[];
|
||||
final completer = Completer();
|
||||
ListFile(_c)(
|
||||
account,
|
||||
file_util.unstripPath(account, accountPrefController.shareFolderValue),
|
||||
).listen(
|
||||
(ev) {
|
||||
results = ev;
|
||||
},
|
||||
onError: _dataStreamController.addError,
|
||||
onDone: () => completer.complete(),
|
||||
);
|
||||
await completer.future;
|
||||
_dataStreamController
|
||||
.add(_convertListResultsToEvent(results, hasNext: false));
|
||||
}
|
||||
|
||||
_FilesStreamEvent _convertListResultsToEvent(
|
||||
List<FileDescriptor> results, {
|
||||
required bool hasNext,
|
||||
}) {
|
||||
return _FilesStreamEvent(
|
||||
files: {
|
||||
for (final f in results) f.fdId: f,
|
||||
},
|
||||
hasNext: hasNext,
|
||||
);
|
||||
}
|
||||
|
||||
final DiContainer _c;
|
||||
final Account account;
|
||||
final AccountPrefController accountPrefController;
|
||||
|
||||
var _isDataStreamInited = false;
|
||||
final _dataStreamController = BehaviorSubject.seeded(
|
||||
_FilesStreamEvent(
|
||||
files: const {},
|
||||
hasNext: true,
|
||||
),
|
||||
);
|
||||
|
||||
final _mutex = Mutex();
|
||||
var _isSyncing = false;
|
||||
final _subscriptions = <StreamSubscription>[];
|
||||
}
|
||||
|
||||
@toString
|
||||
class UpdatePropertyFailureError implements Exception {
|
||||
const UpdatePropertyFailureError(this.fileIds);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final List<int> fileIds;
|
||||
}
|
||||
|
||||
@toString
|
||||
class RemoveFailureError implements Exception {
|
||||
const RemoveFailureError(this.fileIds);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final List<int> fileIds;
|
||||
}
|
||||
|
||||
class _FilesStreamEvent implements FilesStreamEvent {
|
||||
_FilesStreamEvent({
|
||||
required this.files,
|
||||
Lazy<List<FileDescriptor>>? dataLazy,
|
||||
required this.hasNext,
|
||||
}) {
|
||||
this.dataLazy = dataLazy ?? (Lazy(() => files.values.toList()));
|
||||
}
|
||||
|
||||
_FilesStreamEvent copyWith({
|
||||
Map<int, FileDescriptor>? files,
|
||||
bool? hasNext,
|
||||
}) {
|
||||
return _FilesStreamEvent(
|
||||
files: files ?? this.files,
|
||||
dataLazy: (files == null) ? dataLazy : null,
|
||||
hasNext: hasNext ?? this.hasNext,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<FileDescriptor> get data => dataLazy();
|
||||
@override
|
||||
Map<int, FileDescriptor> get dataMap => files;
|
||||
|
||||
final Map<int, FileDescriptor> files;
|
||||
late final Lazy<List<FileDescriptor>> dataLazy;
|
||||
|
||||
/// If true, the results are intermediate values and may not represent the
|
||||
/// latest state
|
||||
@override
|
||||
final bool hasNext;
|
||||
}
|
|
@ -1,32 +1,32 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'progress.dart';
|
||||
part of 'files_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$ProgressBlocNpLog on ProgressBloc {
|
||||
extension _$FilesControllerNpLog on FilesController {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("bloc.progress.ProgressBloc");
|
||||
static final log = Logger("controller.files_controller.FilesController");
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// ToStringGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$ProgressBlocUpdateToString on ProgressBlocUpdate {
|
||||
extension _$UpdatePropertyFailureErrorToString on UpdatePropertyFailureError {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "ProgressBlocUpdate {progress: ${progress.toStringAsFixed(3)}, text: $text}";
|
||||
return "UpdatePropertyFailureError {fileIds: [length: ${fileIds.length}]}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$ProgressBlocStateToString on ProgressBlocState {
|
||||
extension _$RemoveFailureErrorToString on RemoveFailureError {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "ProgressBlocState {progress: ${progress.toStringAsFixed(3)}, text: $text}";
|
||||
return "RemoveFailureError {fileIds: [length: ${fileIds.length}]}";
|
||||
}
|
||||
}
|
98
app/lib/controller/metadata_controller.dart
Normal file
98
app/lib/controller/metadata_controller.dart
Normal file
|
@ -0,0 +1,98 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/controller/files_controller.dart';
|
||||
import 'package:nc_photos/controller/pref_controller.dart';
|
||||
import 'package:nc_photos/db/entity_converter.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/service.dart' as service;
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
|
||||
part 'metadata_controller.g.dart';
|
||||
|
||||
@npLog
|
||||
class MetadataController {
|
||||
MetadataController(
|
||||
this._c, {
|
||||
required this.account,
|
||||
required this.filesController,
|
||||
required this.prefController,
|
||||
}) {
|
||||
_subscriptions.add(filesController.stream.listen(_onFilesEvent));
|
||||
_subscriptions
|
||||
.add(prefController.isEnableExifChange.listen(_onSetEnableExif));
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final s in _subscriptions) {
|
||||
s.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Normally EXIF task only run once, call this function to make it run again
|
||||
/// after receiving new files
|
||||
void scheduleNext() {
|
||||
_hasStarted = false;
|
||||
}
|
||||
|
||||
Future<void> _onFilesEvent(FilesStreamEvent ev) async {
|
||||
_log.info("[_onFilesEvent]");
|
||||
if (!prefController.isEnableExifValue) {
|
||||
// disabled
|
||||
return;
|
||||
}
|
||||
if (ev.data.isNotEmpty && !ev.hasNext) {
|
||||
// finished querying
|
||||
if (!_hasStarted) {
|
||||
await _startMetadataTask(ev.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onSetEnableExif(bool value) {
|
||||
_log.info("[_onSetEnableExif]");
|
||||
if (value) {
|
||||
final filesState = filesController.stream.value;
|
||||
if (filesState.hasNext || filesState.data.isEmpty) {
|
||||
_log.info("[_onSetEnableExif] Ignored as data not ready");
|
||||
return;
|
||||
}
|
||||
_startMetadataTask(filesState.data);
|
||||
} else {
|
||||
_stopMetadataTask();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startMetadataTask(List<FileDescriptor> data) async {
|
||||
_hasStarted = true;
|
||||
try {
|
||||
final missingCount = await _c.npDb.countFilesByFileIdsMissingMetadata(
|
||||
account: account.toDb(),
|
||||
fileIds: data.map((e) => e.fdId).toList(),
|
||||
mimes: file_util.supportedImageFormatMimes,
|
||||
);
|
||||
_log.info("[_startMetadataTask] Missing count: $missingCount");
|
||||
if (missingCount > 0) {
|
||||
unawaited(service.startService());
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout(
|
||||
"[_startMetadataTask] Failed starting metadata task", e, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
void _stopMetadataTask() {
|
||||
service.stopService();
|
||||
}
|
||||
|
||||
final DiContainer _c;
|
||||
final Account account;
|
||||
final FilesController filesController;
|
||||
final PrefController prefController;
|
||||
|
||||
final _subscriptions = <StreamSubscription>[];
|
||||
var _hasStarted = false;
|
||||
}
|
15
app/lib/controller/metadata_controller.g.dart
Normal file
15
app/lib/controller/metadata_controller.g.dart
Normal file
|
@ -0,0 +1,15 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'metadata_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$MetadataControllerNpLog on MetadataController {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log =
|
||||
Logger("controller.metadata_controller.MetadataController");
|
||||
}
|
|
@ -71,7 +71,7 @@ class PersonsController {
|
|||
_personStreamContorller.add(lastData);
|
||||
final completer = Completer();
|
||||
ListPerson(_c.withLocalRepo())(
|
||||
account, accountPrefController.personProvider.value)
|
||||
account, accountPrefController.personProviderValue)
|
||||
.listen(
|
||||
(results) {
|
||||
lastData = PersonStreamEvent(
|
||||
|
|
|
@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/pref.dart';
|
||||
import 'package:nc_photos/language_util.dart' as language_util;
|
||||
import 'package:nc_photos/language_util.dart';
|
||||
import 'package:nc_photos/object_extension.dart';
|
||||
import 'package:nc_photos/size.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
|
@ -14,21 +14,21 @@ import 'package:rxdart/rxdart.dart';
|
|||
part 'pref_controller.g.dart';
|
||||
|
||||
@npLog
|
||||
@npSubjectAccessor
|
||||
class PrefController {
|
||||
PrefController(this._c);
|
||||
|
||||
ValueStream<language_util.AppLanguage> get language =>
|
||||
_languageController.stream;
|
||||
|
||||
Future<void> setAppLanguage(language_util.AppLanguage value) =>
|
||||
_set<language_util.AppLanguage>(
|
||||
Future<void> setAppLanguage(AppLanguage value) => _set<AppLanguage>(
|
||||
controller: _languageController,
|
||||
setter: (pref, value) => pref.setLanguage(value.langId),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<int> get albumBrowserZoomLevel =>
|
||||
_albumBrowserZoomLevelController.stream;
|
||||
Future<void> setHomePhotosZoomLevel(int value) => _set<int>(
|
||||
controller: _homePhotosZoomLevelController,
|
||||
setter: (pref, value) => pref.setHomePhotosZoomLevel(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
Future<void> setAlbumBrowserZoomLevel(int value) => _set<int>(
|
||||
controller: _albumBrowserZoomLevelController,
|
||||
|
@ -36,103 +36,72 @@ class PrefController {
|
|||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<int> get homeAlbumsSort => _homeAlbumsSortController.stream;
|
||||
|
||||
Future<void> setHomeAlbumsSort(int value) => _set<int>(
|
||||
controller: _homeAlbumsSortController,
|
||||
setter: (pref, value) => pref.setHomeAlbumsSort(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<bool> get isEnableExif => _isEnableExifController.stream;
|
||||
|
||||
Future<void> setEnableExif(bool value) => _set<bool>(
|
||||
controller: _isEnableExifController,
|
||||
setter: (pref, value) => pref.setEnableExif(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<bool> get shouldProcessExifWifiOnly =>
|
||||
_shouldProcessExifWifiOnlyController.stream;
|
||||
|
||||
Future<void> setProcessExifWifiOnly(bool value) => _set<bool>(
|
||||
controller: _shouldProcessExifWifiOnlyController,
|
||||
setter: (pref, value) => pref.setProcessExifWifiOnly(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<int> get memoriesRange => _memoriesRangeController.stream;
|
||||
|
||||
Future<void> setMemoriesRange(int value) => _set<int>(
|
||||
controller: _memoriesRangeController,
|
||||
setter: (pref, value) => pref.setMemoriesRange(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<bool> get isPhotosTabSortByName =>
|
||||
_isPhotosTabSortByNameController.stream;
|
||||
|
||||
Future<void> setPhotosTabSortByName(bool value) => _set<bool>(
|
||||
controller: _isPhotosTabSortByNameController,
|
||||
setter: (pref, value) => pref.setPhotosTabSortByName(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<int> get viewerScreenBrightness =>
|
||||
_viewerScreenBrightnessController.stream;
|
||||
|
||||
Future<void> setViewerScreenBrightness(int value) => _set<int>(
|
||||
controller: _viewerScreenBrightnessController,
|
||||
setter: (pref, value) => pref.setViewerScreenBrightness(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<bool> get isViewerForceRotation =>
|
||||
_isViewerForceRotationController.stream;
|
||||
|
||||
Future<void> setViewerForceRotation(bool value) => _set<bool>(
|
||||
controller: _isViewerForceRotationController,
|
||||
setter: (pref, value) => pref.setViewerForceRotation(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<GpsMapProvider> get gpsMapProvider =>
|
||||
_gpsMapProviderController.stream;
|
||||
|
||||
Future<void> setGpsMapProvider(GpsMapProvider value) => _set<GpsMapProvider>(
|
||||
controller: _gpsMapProviderController,
|
||||
setter: (pref, value) => pref.setGpsMapProvider(value.index),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<bool> get isAlbumBrowserShowDate =>
|
||||
_isAlbumBrowserShowDateController.stream;
|
||||
|
||||
Future<void> setAlbumBrowserShowDate(bool value) => _set<bool>(
|
||||
controller: _isAlbumBrowserShowDateController,
|
||||
setter: (pref, value) => pref.setAlbumBrowserShowDate(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<bool> get isDoubleTapExit => _isDoubleTapExitController.stream;
|
||||
|
||||
Future<void> setDoubleTapExit(bool value) => _set<bool>(
|
||||
controller: _isDoubleTapExitController,
|
||||
setter: (pref, value) => pref.setDoubleTapExit(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<bool> get isSaveEditResultToServer =>
|
||||
_isSaveEditResultToServerController.stream;
|
||||
|
||||
Future<void> setSaveEditResultToServer(bool value) => _set<bool>(
|
||||
controller: _isSaveEditResultToServerController,
|
||||
setter: (pref, value) => pref.setSaveEditResultToServer(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<SizeInt> get enhanceMaxSize => _enhanceMaxSizeController.stream;
|
||||
|
||||
Future<void> setEnhanceMaxSize(SizeInt value) => _set<SizeInt>(
|
||||
controller: _enhanceMaxSizeController,
|
||||
setter: (pref, value) async {
|
||||
|
@ -145,34 +114,24 @@ class PrefController {
|
|||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<bool> get isDarkTheme => _isDarkThemeController.stream;
|
||||
|
||||
Future<void> setDarkTheme(bool value) => _set<bool>(
|
||||
controller: _isDarkThemeController,
|
||||
setter: (pref, value) => pref.setDarkTheme(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<bool> get isFollowSystemTheme =>
|
||||
_isFollowSystemThemeController.stream;
|
||||
|
||||
Future<void> setFollowSystemTheme(bool value) => _set<bool>(
|
||||
controller: _isFollowSystemThemeController,
|
||||
setter: (pref, value) => pref.setFollowSystemTheme(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<bool> get isUseBlackInDarkTheme =>
|
||||
_isUseBlackInDarkThemeController.stream;
|
||||
|
||||
Future<void> setUseBlackInDarkTheme(bool value) => _set<bool>(
|
||||
controller: _isUseBlackInDarkThemeController,
|
||||
setter: (pref, value) => pref.setUseBlackInDarkTheme(value),
|
||||
value: value,
|
||||
);
|
||||
|
||||
ValueStream<Color?> get seedColor => _seedColorController.stream;
|
||||
|
||||
Future<void> setSeedColor(Color? value) => _setOrRemove<Color>(
|
||||
controller: _seedColorController,
|
||||
setter: (pref, value) => pref.setSeedColor(value.withAlpha(0xFF).value),
|
||||
|
@ -226,49 +185,70 @@ class PrefController {
|
|||
}
|
||||
}
|
||||
|
||||
static language_util.AppLanguage _langIdToAppLanguage(int langId) {
|
||||
static AppLanguage _langIdToAppLanguage(int langId) {
|
||||
try {
|
||||
return language_util.supportedLanguages[langId]!;
|
||||
return supportedLanguages[langId]!;
|
||||
} catch (_) {
|
||||
return language_util.supportedLanguages[0]!;
|
||||
return supportedLanguages[0]!;
|
||||
}
|
||||
}
|
||||
|
||||
final DiContainer _c;
|
||||
@npSubjectAccessor
|
||||
late final _languageController =
|
||||
BehaviorSubject.seeded(_langIdToAppLanguage(_c.pref.getLanguageOr(0)));
|
||||
@npSubjectAccessor
|
||||
late final _homePhotosZoomLevelController =
|
||||
BehaviorSubject.seeded(_c.pref.getHomePhotosZoomLevelOr(0));
|
||||
@npSubjectAccessor
|
||||
late final _albumBrowserZoomLevelController =
|
||||
BehaviorSubject.seeded(_c.pref.getAlbumBrowserZoomLevelOr(0));
|
||||
@npSubjectAccessor
|
||||
late final _homeAlbumsSortController =
|
||||
BehaviorSubject.seeded(_c.pref.getHomeAlbumsSortOr(0));
|
||||
@npSubjectAccessor
|
||||
late final _isEnableExifController =
|
||||
BehaviorSubject.seeded(_c.pref.isEnableExifOr(true));
|
||||
@npSubjectAccessor
|
||||
late final _shouldProcessExifWifiOnlyController =
|
||||
BehaviorSubject.seeded(_c.pref.shouldProcessExifWifiOnlyOr(true));
|
||||
@npSubjectAccessor
|
||||
late final _memoriesRangeController =
|
||||
BehaviorSubject.seeded(_c.pref.getMemoriesRangeOr(2));
|
||||
@npSubjectAccessor
|
||||
late final _isPhotosTabSortByNameController =
|
||||
BehaviorSubject.seeded(_c.pref.isPhotosTabSortByNameOr(false));
|
||||
@npSubjectAccessor
|
||||
late final _viewerScreenBrightnessController =
|
||||
BehaviorSubject.seeded(_c.pref.getViewerScreenBrightnessOr(-1));
|
||||
@npSubjectAccessor
|
||||
late final _isViewerForceRotationController =
|
||||
BehaviorSubject.seeded(_c.pref.isViewerForceRotationOr(false));
|
||||
@npSubjectAccessor
|
||||
late final _gpsMapProviderController = BehaviorSubject.seeded(
|
||||
GpsMapProvider.values[_c.pref.getGpsMapProviderOr(0)]);
|
||||
@npSubjectAccessor
|
||||
late final _isAlbumBrowserShowDateController =
|
||||
BehaviorSubject.seeded(_c.pref.isAlbumBrowserShowDateOr(false));
|
||||
@npSubjectAccessor
|
||||
late final _isDoubleTapExitController =
|
||||
BehaviorSubject.seeded(_c.pref.isDoubleTapExitOr(false));
|
||||
@npSubjectAccessor
|
||||
late final _isSaveEditResultToServerController =
|
||||
BehaviorSubject.seeded(_c.pref.isSaveEditResultToServerOr(true));
|
||||
@npSubjectAccessor
|
||||
late final _enhanceMaxSizeController = BehaviorSubject.seeded(
|
||||
SizeInt(_c.pref.getEnhanceMaxWidthOr(), _c.pref.getEnhanceMaxHeightOr()));
|
||||
@npSubjectAccessor
|
||||
late final _isDarkThemeController =
|
||||
BehaviorSubject.seeded(_c.pref.isDarkThemeOr(false));
|
||||
@npSubjectAccessor
|
||||
late final _isFollowSystemThemeController =
|
||||
BehaviorSubject.seeded(_c.pref.isFollowSystemThemeOr(false));
|
||||
@npSubjectAccessor
|
||||
late final _isUseBlackInDarkThemeController =
|
||||
BehaviorSubject.seeded(_c.pref.isUseBlackInDarkThemeOr(false));
|
||||
@NpSubjectAccessor(type: "Color?")
|
||||
late final _seedColorController =
|
||||
BehaviorSubject<Color?>.seeded(_c.pref.getSeedColor()?.run(Color.new));
|
||||
}
|
||||
|
|
|
@ -12,3 +12,133 @@ extension _$PrefControllerNpLog on PrefController {
|
|||
|
||||
static final log = Logger("controller.pref_controller.PrefController");
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// NpSubjectAccessorGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension $PrefControllerNpSubjectAccessor on PrefController {
|
||||
// _languageController
|
||||
ValueStream<AppLanguage> get language => _languageController.stream;
|
||||
Stream<AppLanguage> get languageNew => language.skip(1);
|
||||
Stream<AppLanguage> get languageChange => language.distinct().skip(1);
|
||||
AppLanguage get languageValue => _languageController.value;
|
||||
// _homePhotosZoomLevelController
|
||||
ValueStream<int> get homePhotosZoomLevel =>
|
||||
_homePhotosZoomLevelController.stream;
|
||||
Stream<int> get homePhotosZoomLevelNew => homePhotosZoomLevel.skip(1);
|
||||
Stream<int> get homePhotosZoomLevelChange =>
|
||||
homePhotosZoomLevel.distinct().skip(1);
|
||||
int get homePhotosZoomLevelValue => _homePhotosZoomLevelController.value;
|
||||
// _albumBrowserZoomLevelController
|
||||
ValueStream<int> get albumBrowserZoomLevel =>
|
||||
_albumBrowserZoomLevelController.stream;
|
||||
Stream<int> get albumBrowserZoomLevelNew => albumBrowserZoomLevel.skip(1);
|
||||
Stream<int> get albumBrowserZoomLevelChange =>
|
||||
albumBrowserZoomLevel.distinct().skip(1);
|
||||
int get albumBrowserZoomLevelValue => _albumBrowserZoomLevelController.value;
|
||||
// _homeAlbumsSortController
|
||||
ValueStream<int> get homeAlbumsSort => _homeAlbumsSortController.stream;
|
||||
Stream<int> get homeAlbumsSortNew => homeAlbumsSort.skip(1);
|
||||
Stream<int> get homeAlbumsSortChange => homeAlbumsSort.distinct().skip(1);
|
||||
int get homeAlbumsSortValue => _homeAlbumsSortController.value;
|
||||
// _isEnableExifController
|
||||
ValueStream<bool> get isEnableExif => _isEnableExifController.stream;
|
||||
Stream<bool> get isEnableExifNew => isEnableExif.skip(1);
|
||||
Stream<bool> get isEnableExifChange => isEnableExif.distinct().skip(1);
|
||||
bool get isEnableExifValue => _isEnableExifController.value;
|
||||
// _shouldProcessExifWifiOnlyController
|
||||
ValueStream<bool> get shouldProcessExifWifiOnly =>
|
||||
_shouldProcessExifWifiOnlyController.stream;
|
||||
Stream<bool> get shouldProcessExifWifiOnlyNew =>
|
||||
shouldProcessExifWifiOnly.skip(1);
|
||||
Stream<bool> get shouldProcessExifWifiOnlyChange =>
|
||||
shouldProcessExifWifiOnly.distinct().skip(1);
|
||||
bool get shouldProcessExifWifiOnlyValue =>
|
||||
_shouldProcessExifWifiOnlyController.value;
|
||||
// _memoriesRangeController
|
||||
ValueStream<int> get memoriesRange => _memoriesRangeController.stream;
|
||||
Stream<int> get memoriesRangeNew => memoriesRange.skip(1);
|
||||
Stream<int> get memoriesRangeChange => memoriesRange.distinct().skip(1);
|
||||
int get memoriesRangeValue => _memoriesRangeController.value;
|
||||
// _isPhotosTabSortByNameController
|
||||
ValueStream<bool> get isPhotosTabSortByName =>
|
||||
_isPhotosTabSortByNameController.stream;
|
||||
Stream<bool> get isPhotosTabSortByNameNew => isPhotosTabSortByName.skip(1);
|
||||
Stream<bool> get isPhotosTabSortByNameChange =>
|
||||
isPhotosTabSortByName.distinct().skip(1);
|
||||
bool get isPhotosTabSortByNameValue => _isPhotosTabSortByNameController.value;
|
||||
// _viewerScreenBrightnessController
|
||||
ValueStream<int> get viewerScreenBrightness =>
|
||||
_viewerScreenBrightnessController.stream;
|
||||
Stream<int> get viewerScreenBrightnessNew => viewerScreenBrightness.skip(1);
|
||||
Stream<int> get viewerScreenBrightnessChange =>
|
||||
viewerScreenBrightness.distinct().skip(1);
|
||||
int get viewerScreenBrightnessValue =>
|
||||
_viewerScreenBrightnessController.value;
|
||||
// _isViewerForceRotationController
|
||||
ValueStream<bool> get isViewerForceRotation =>
|
||||
_isViewerForceRotationController.stream;
|
||||
Stream<bool> get isViewerForceRotationNew => isViewerForceRotation.skip(1);
|
||||
Stream<bool> get isViewerForceRotationChange =>
|
||||
isViewerForceRotation.distinct().skip(1);
|
||||
bool get isViewerForceRotationValue => _isViewerForceRotationController.value;
|
||||
// _gpsMapProviderController
|
||||
ValueStream<GpsMapProvider> get gpsMapProvider =>
|
||||
_gpsMapProviderController.stream;
|
||||
Stream<GpsMapProvider> get gpsMapProviderNew => gpsMapProvider.skip(1);
|
||||
Stream<GpsMapProvider> get gpsMapProviderChange =>
|
||||
gpsMapProvider.distinct().skip(1);
|
||||
GpsMapProvider get gpsMapProviderValue => _gpsMapProviderController.value;
|
||||
// _isAlbumBrowserShowDateController
|
||||
ValueStream<bool> get isAlbumBrowserShowDate =>
|
||||
_isAlbumBrowserShowDateController.stream;
|
||||
Stream<bool> get isAlbumBrowserShowDateNew => isAlbumBrowserShowDate.skip(1);
|
||||
Stream<bool> get isAlbumBrowserShowDateChange =>
|
||||
isAlbumBrowserShowDate.distinct().skip(1);
|
||||
bool get isAlbumBrowserShowDateValue =>
|
||||
_isAlbumBrowserShowDateController.value;
|
||||
// _isDoubleTapExitController
|
||||
ValueStream<bool> get isDoubleTapExit => _isDoubleTapExitController.stream;
|
||||
Stream<bool> get isDoubleTapExitNew => isDoubleTapExit.skip(1);
|
||||
Stream<bool> get isDoubleTapExitChange => isDoubleTapExit.distinct().skip(1);
|
||||
bool get isDoubleTapExitValue => _isDoubleTapExitController.value;
|
||||
// _isSaveEditResultToServerController
|
||||
ValueStream<bool> get isSaveEditResultToServer =>
|
||||
_isSaveEditResultToServerController.stream;
|
||||
Stream<bool> get isSaveEditResultToServerNew =>
|
||||
isSaveEditResultToServer.skip(1);
|
||||
Stream<bool> get isSaveEditResultToServerChange =>
|
||||
isSaveEditResultToServer.distinct().skip(1);
|
||||
bool get isSaveEditResultToServerValue =>
|
||||
_isSaveEditResultToServerController.value;
|
||||
// _enhanceMaxSizeController
|
||||
ValueStream<SizeInt> get enhanceMaxSize => _enhanceMaxSizeController.stream;
|
||||
Stream<SizeInt> get enhanceMaxSizeNew => enhanceMaxSize.skip(1);
|
||||
Stream<SizeInt> get enhanceMaxSizeChange => enhanceMaxSize.distinct().skip(1);
|
||||
SizeInt get enhanceMaxSizeValue => _enhanceMaxSizeController.value;
|
||||
// _isDarkThemeController
|
||||
ValueStream<bool> get isDarkTheme => _isDarkThemeController.stream;
|
||||
Stream<bool> get isDarkThemeNew => isDarkTheme.skip(1);
|
||||
Stream<bool> get isDarkThemeChange => isDarkTheme.distinct().skip(1);
|
||||
bool get isDarkThemeValue => _isDarkThemeController.value;
|
||||
// _isFollowSystemThemeController
|
||||
ValueStream<bool> get isFollowSystemTheme =>
|
||||
_isFollowSystemThemeController.stream;
|
||||
Stream<bool> get isFollowSystemThemeNew => isFollowSystemTheme.skip(1);
|
||||
Stream<bool> get isFollowSystemThemeChange =>
|
||||
isFollowSystemTheme.distinct().skip(1);
|
||||
bool get isFollowSystemThemeValue => _isFollowSystemThemeController.value;
|
||||
// _isUseBlackInDarkThemeController
|
||||
ValueStream<bool> get isUseBlackInDarkTheme =>
|
||||
_isUseBlackInDarkThemeController.stream;
|
||||
Stream<bool> get isUseBlackInDarkThemeNew => isUseBlackInDarkTheme.skip(1);
|
||||
Stream<bool> get isUseBlackInDarkThemeChange =>
|
||||
isUseBlackInDarkTheme.distinct().skip(1);
|
||||
bool get isUseBlackInDarkThemeValue => _isUseBlackInDarkThemeController.value;
|
||||
// _seedColorController
|
||||
ValueStream<Color?> get seedColor => _seedColorController.stream;
|
||||
Stream<Color?> get seedColorNew => seedColor.skip(1);
|
||||
Stream<Color?> get seedColorChange => seedColor.distinct().skip(1);
|
||||
Color? get seedColorValue => _seedColorController.value;
|
||||
}
|
||||
|
|
|
@ -9,9 +9,11 @@ class SessionController {
|
|||
_hasFiredMetadataTaskController.close();
|
||||
}
|
||||
|
||||
@Deprecated("Use MetadataController")
|
||||
ValueStream<bool> get hasFiredMetadataTask =>
|
||||
_hasFiredMetadataTaskController.stream;
|
||||
|
||||
@Deprecated("Use MetadataController")
|
||||
void setFiredMetadataTask(bool value) {
|
||||
_hasFiredMetadataTaskController.add(value);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ import 'dart:async';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/controller/files_controller.dart';
|
||||
import 'package:nc_photos/controller/persons_controller.dart';
|
||||
import 'package:nc_photos/entity/person.dart';
|
||||
import 'package:nc_photos/use_case/startup_sync.dart';
|
||||
|
||||
|
@ -15,14 +17,19 @@ class SyncController {
|
|||
_isDisposed = true;
|
||||
}
|
||||
|
||||
Future<void> requestSync(
|
||||
Account account, PersonProvider personProvider) async {
|
||||
Future<void> requestSync({
|
||||
required Account account,
|
||||
required FilesController filesController,
|
||||
required PersonsController personsController,
|
||||
required PersonProvider personProvider,
|
||||
}) async {
|
||||
if (_isDisposed) {
|
||||
return;
|
||||
}
|
||||
if (_syncCompleter == null) {
|
||||
_syncCompleter = Completer();
|
||||
final result = await StartupSync.runInIsolate(account, personProvider);
|
||||
final result = await StartupSync.runInIsolate(
|
||||
account, filesController, personsController, personProvider);
|
||||
if (!_isDisposed && result.isSyncPersonUpdated) {
|
||||
onPeopleUpdated?.call();
|
||||
}
|
||||
|
@ -32,15 +39,23 @@ class SyncController {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> requestResync(
|
||||
Account account, PersonProvider personProvider) async {
|
||||
Future<void> requestResync({
|
||||
required Account account,
|
||||
required FilesController filesController,
|
||||
required PersonsController personsController,
|
||||
required PersonProvider personProvider,
|
||||
}) async {
|
||||
if (_syncCompleter?.isCompleted == true) {
|
||||
_syncCompleter = null;
|
||||
return requestSync(account, personProvider);
|
||||
} else {
|
||||
// already syncing
|
||||
return requestSync(account, personProvider);
|
||||
}
|
||||
return requestSync(
|
||||
account: account,
|
||||
filesController: filesController,
|
||||
personsController: personsController,
|
||||
personProvider: personProvider,
|
||||
);
|
||||
}
|
||||
|
||||
final Account account;
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'package:nc_photos/entity/album/repo2.dart';
|
|||
import 'package:nc_photos/entity/face_recognition_person/repo.dart';
|
||||
import 'package:nc_photos/entity/favorite.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file/repo.dart';
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
import 'package:nc_photos/entity/nc_album/repo.dart';
|
||||
import 'package:nc_photos/entity/pref.dart';
|
||||
|
@ -26,6 +27,9 @@ enum DiType {
|
|||
fileRepo,
|
||||
fileRepoRemote,
|
||||
fileRepoLocal,
|
||||
fileRepo2,
|
||||
fileRepo2Remote,
|
||||
fileRepo2Local,
|
||||
shareRepo,
|
||||
shareeRepo,
|
||||
favoriteRepo,
|
||||
|
@ -60,6 +64,9 @@ class DiContainer {
|
|||
FileRepo? fileRepo,
|
||||
FileRepo? fileRepoRemote,
|
||||
FileRepo? fileRepoLocal,
|
||||
FileRepo2? fileRepo2,
|
||||
FileRepo2? fileRepo2Remote,
|
||||
FileRepo2? fileRepo2Local,
|
||||
ShareRepo? shareRepo,
|
||||
ShareeRepo? shareeRepo,
|
||||
FavoriteRepo? favoriteRepo,
|
||||
|
@ -90,6 +97,9 @@ class DiContainer {
|
|||
_fileRepo = fileRepo,
|
||||
_fileRepoRemote = fileRepoRemote,
|
||||
_fileRepoLocal = fileRepoLocal,
|
||||
_fileRepo2 = fileRepo2,
|
||||
_fileRepo2Remote = fileRepo2Remote,
|
||||
_fileRepo2Local = fileRepo2Local,
|
||||
_shareRepo = shareRepo,
|
||||
_shareeRepo = shareeRepo,
|
||||
_favoriteRepo = favoriteRepo,
|
||||
|
@ -134,6 +144,12 @@ class DiContainer {
|
|||
return contianer._fileRepoRemote != null;
|
||||
case DiType.fileRepoLocal:
|
||||
return contianer._fileRepoLocal != null;
|
||||
case DiType.fileRepo2:
|
||||
return contianer._fileRepo2 != null;
|
||||
case DiType.fileRepo2Remote:
|
||||
return contianer._fileRepo2Remote != null;
|
||||
case DiType.fileRepo2Local:
|
||||
return contianer._fileRepo2Local != null;
|
||||
case DiType.shareRepo:
|
||||
return contianer._shareRepo != null;
|
||||
case DiType.shareeRepo:
|
||||
|
@ -183,6 +199,7 @@ class DiContainer {
|
|||
OrNull<AlbumRepo>? albumRepo,
|
||||
OrNull<AlbumRepo2>? albumRepo2,
|
||||
OrNull<FileRepo>? fileRepo,
|
||||
OrNull<FileRepo2>? fileRepo2,
|
||||
OrNull<ShareRepo>? shareRepo,
|
||||
OrNull<ShareeRepo>? shareeRepo,
|
||||
OrNull<FavoriteRepo>? favoriteRepo,
|
||||
|
@ -201,6 +218,7 @@ class DiContainer {
|
|||
albumRepo: albumRepo == null ? _albumRepo : albumRepo.obj,
|
||||
albumRepo2: albumRepo2 == null ? _albumRepo2 : albumRepo2.obj,
|
||||
fileRepo: fileRepo == null ? _fileRepo : fileRepo.obj,
|
||||
fileRepo2: fileRepo2 == null ? _fileRepo2 : fileRepo2.obj,
|
||||
shareRepo: shareRepo == null ? _shareRepo : shareRepo.obj,
|
||||
shareeRepo: shareeRepo == null ? _shareeRepo : shareeRepo.obj,
|
||||
favoriteRepo: favoriteRepo == null ? _favoriteRepo : favoriteRepo.obj,
|
||||
|
@ -231,6 +249,9 @@ class DiContainer {
|
|||
FileRepo get fileRepo => _fileRepo!;
|
||||
FileRepo get fileRepoRemote => _fileRepoRemote!;
|
||||
FileRepo get fileRepoLocal => _fileRepoLocal!;
|
||||
FileRepo2 get fileRepo2 => _fileRepo2!;
|
||||
FileRepo2 get fileRepo2Remote => _fileRepo2Remote!;
|
||||
FileRepo2 get fileRepo2Local => _fileRepo2Local!;
|
||||
ShareRepo get shareRepo => _shareRepo!;
|
||||
ShareeRepo get shareeRepo => _shareeRepo!;
|
||||
FavoriteRepo get favoriteRepo => _favoriteRepo!;
|
||||
|
@ -302,6 +323,21 @@ class DiContainer {
|
|||
_fileRepoLocal = v;
|
||||
}
|
||||
|
||||
set fileRepo2(FileRepo2 v) {
|
||||
assert(_fileRepo2 == null);
|
||||
_fileRepo2 = v;
|
||||
}
|
||||
|
||||
set fileRepo2Remote(FileRepo2 v) {
|
||||
assert(_fileRepo2Remote == null);
|
||||
_fileRepo2Remote = v;
|
||||
}
|
||||
|
||||
set fileRepo2Local(FileRepo2 v) {
|
||||
assert(_fileRepo2Local == null);
|
||||
_fileRepo2Local = v;
|
||||
}
|
||||
|
||||
set shareRepo(ShareRepo v) {
|
||||
assert(_shareRepo == null);
|
||||
_shareRepo = v;
|
||||
|
@ -419,6 +455,9 @@ class DiContainer {
|
|||
FileRepo? _fileRepoRemote;
|
||||
// Explicitly request a FileRepo backed by local source
|
||||
FileRepo? _fileRepoLocal;
|
||||
FileRepo2? _fileRepo2;
|
||||
FileRepo2? _fileRepo2Remote;
|
||||
FileRepo2? _fileRepo2Local;
|
||||
ShareRepo? _shareRepo;
|
||||
ShareeRepo? _shareeRepo;
|
||||
FavoriteRepo? _favoriteRepo;
|
||||
|
|
|
@ -101,6 +101,13 @@ class Album with EquatableMixin {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
if (jsonVersion < 10) {
|
||||
result = upgraderFactory?.buildV9()?.doJson(result);
|
||||
if (result == null) {
|
||||
_log.info("[fromJson] Version $jsonVersion not compatible");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (jsonVersion > version) {
|
||||
_log.warning(
|
||||
"[fromJson] Reading album with newer version: $jsonVersion > $version");
|
||||
|
@ -224,7 +231,7 @@ class Album with EquatableMixin {
|
|||
final int savedVersion;
|
||||
|
||||
/// versioning of this class, use to upgrade old persisted album
|
||||
static const version = 9;
|
||||
static const version = 10;
|
||||
|
||||
static final _log = _$AlbumNpLog.log;
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ import 'package:logging/logging.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/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
|
@ -78,11 +77,8 @@ class AlbumAutoCoverProvider extends AlbumCoverProvider {
|
|||
return items
|
||||
.whereType<AlbumFileItem>()
|
||||
.map((e) => e.file)
|
||||
.where((element) =>
|
||||
file_util.isSupportedFormat(element) &&
|
||||
(element.hasPreview ?? false) &&
|
||||
element.fileId != null)
|
||||
.sorted(compareFileDateTimeDescending)
|
||||
.where(file_util.isSupportedFormat)
|
||||
.sorted(compareFileDescriptorDateTimeDescending)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
|
|
|
@ -147,6 +147,11 @@ class AlbumSqliteDbDataSource2 implements AlbumDataSource2 {
|
|||
if (dbAlbum.version < 9) {
|
||||
dbAlbum = AlbumUpgraderV8(logFilePath: file.path).doDb(dbAlbum)!;
|
||||
}
|
||||
if (dbAlbum.version < 10) {
|
||||
dbAlbum =
|
||||
AlbumUpgraderV9(account: account, logFilePath: file.path)
|
||||
.doDb(dbAlbum)!;
|
||||
}
|
||||
return DbAlbumConverter.fromDb(file, dbAlbum);
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe(
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import 'package:copy_with/copy_with.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_common/or_null.dart';
|
||||
import 'package:np_common/type.dart';
|
||||
import 'package:np_string/np_string.dart';
|
||||
import 'package:to_string/to_string.dart';
|
||||
|
@ -57,11 +57,13 @@ abstract class AlbumItem with EquatableMixin {
|
|||
|
||||
JsonObj toContentJson();
|
||||
|
||||
bool compareServerIdentity(AlbumItem other);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
@override
|
||||
get props => [
|
||||
List<Object?> get props => [
|
||||
addedBy,
|
||||
addedAt,
|
||||
];
|
||||
|
@ -72,32 +74,23 @@ abstract class AlbumItem with EquatableMixin {
|
|||
static final _log = _$AlbumItemNpLog.log;
|
||||
}
|
||||
|
||||
@genCopyWith
|
||||
@toString
|
||||
class AlbumFileItem extends AlbumItem {
|
||||
AlbumFileItem({
|
||||
required CiString addedBy,
|
||||
required DateTime addedAt,
|
||||
required super.addedBy,
|
||||
required super.addedAt,
|
||||
required this.file,
|
||||
}) : super(addedBy: addedBy, addedAt: addedAt);
|
||||
|
||||
@override
|
||||
// ignore: hash_and_equals
|
||||
bool operator ==(Object? other) => equals(other, isDeep: true);
|
||||
|
||||
bool equals(Object? other, {bool isDeep = false}) {
|
||||
if (other is AlbumFileItem) {
|
||||
return super == other && (file.equals(other.file, isDeep: isDeep));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
required this.ownerId,
|
||||
});
|
||||
|
||||
factory AlbumFileItem.fromJson(
|
||||
JsonObj json, CiString addedBy, DateTime addedAt) {
|
||||
return AlbumFileItem(
|
||||
addedBy: addedBy,
|
||||
addedAt: addedAt,
|
||||
file: File.fromJson(json["file"].cast<String, dynamic>()),
|
||||
file: FileDescriptor.fromJson(json["file"].cast<String, dynamic>()),
|
||||
ownerId: (json["ownerId"] as String).toCi(),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -105,37 +98,29 @@ class AlbumFileItem extends AlbumItem {
|
|||
String toString() => _$toString();
|
||||
|
||||
@override
|
||||
toContentJson() {
|
||||
JsonObj toContentJson() {
|
||||
return {
|
||||
"file": file.toJson(),
|
||||
"file": file.toFdJson(),
|
||||
"ownerId": ownerId.raw,
|
||||
};
|
||||
}
|
||||
|
||||
AlbumFileItem copyWith({
|
||||
CiString? addedBy,
|
||||
DateTime? addedAt,
|
||||
File? file,
|
||||
}) {
|
||||
return AlbumFileItem(
|
||||
addedBy: addedBy ?? this.addedBy,
|
||||
addedAt: addedAt ?? this.addedAt,
|
||||
file: file ?? this.file,
|
||||
);
|
||||
}
|
||||
|
||||
AlbumFileItem minimize() => AlbumFileItem(
|
||||
addedBy: addedBy,
|
||||
addedAt: addedAt,
|
||||
file: file.copyWith(metadata: const OrNull(null)),
|
||||
);
|
||||
@override
|
||||
bool compareServerIdentity(AlbumItem other) =>
|
||||
other is AlbumFileItem &&
|
||||
file.compareServerIdentity(other.file) &&
|
||||
addedBy == other.addedBy &&
|
||||
addedAt == other.addedAt;
|
||||
|
||||
@override
|
||||
get props => [
|
||||
List<Object?> get props => [
|
||||
...super.props,
|
||||
// file is handled separately, see [equals]
|
||||
file,
|
||||
ownerId,
|
||||
];
|
||||
|
||||
final File file;
|
||||
final FileDescriptor file;
|
||||
final CiString ownerId;
|
||||
|
||||
static const _type = "file";
|
||||
}
|
||||
|
@ -161,12 +146,19 @@ class AlbumLabelItem extends AlbumItem {
|
|||
String toString() => _$toString();
|
||||
|
||||
@override
|
||||
toContentJson() {
|
||||
JsonObj toContentJson() {
|
||||
return {
|
||||
"text": text,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool compareServerIdentity(AlbumItem other) =>
|
||||
other is AlbumLabelItem &&
|
||||
text == other.text &&
|
||||
addedBy == other.addedBy &&
|
||||
addedAt == other.addedAt;
|
||||
|
||||
AlbumLabelItem copyWith({
|
||||
CiString? addedBy,
|
||||
DateTime? addedAt,
|
||||
|
@ -180,7 +172,7 @@ class AlbumLabelItem extends AlbumItem {
|
|||
}
|
||||
|
||||
@override
|
||||
get props => [
|
||||
List<Object?> get props => [
|
||||
...super.props,
|
||||
text,
|
||||
];
|
||||
|
|
|
@ -2,6 +2,47 @@
|
|||
|
||||
part of 'item.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithLintRuleGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: library_private_types_in_public_api, duplicate_ignore
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithGenerator
|
||||
// **************************************************************************
|
||||
|
||||
abstract class $AlbumFileItemCopyWithWorker {
|
||||
AlbumFileItem call(
|
||||
{CiString? addedBy,
|
||||
DateTime? addedAt,
|
||||
FileDescriptor? file,
|
||||
CiString? ownerId});
|
||||
}
|
||||
|
||||
class _$AlbumFileItemCopyWithWorkerImpl
|
||||
implements $AlbumFileItemCopyWithWorker {
|
||||
_$AlbumFileItemCopyWithWorkerImpl(this.that);
|
||||
|
||||
@override
|
||||
AlbumFileItem call(
|
||||
{dynamic addedBy, dynamic addedAt, dynamic file, dynamic ownerId}) {
|
||||
return AlbumFileItem(
|
||||
addedBy: addedBy as CiString? ?? that.addedBy,
|
||||
addedAt: addedAt as DateTime? ?? that.addedAt,
|
||||
file: file as FileDescriptor? ?? that.file,
|
||||
ownerId: ownerId as CiString? ?? that.ownerId);
|
||||
}
|
||||
|
||||
final AlbumFileItem that;
|
||||
}
|
||||
|
||||
extension $AlbumFileItemCopyWith on AlbumFileItem {
|
||||
$AlbumFileItemCopyWithWorker get copyWith => _$copyWith;
|
||||
$AlbumFileItemCopyWithWorker get _$copyWith =>
|
||||
_$AlbumFileItemCopyWithWorkerImpl(this);
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
@ -27,7 +68,7 @@ extension _$AlbumItemToString on AlbumItem {
|
|||
extension _$AlbumFileItemToString on AlbumFileItem {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "AlbumFileItem {addedBy: $addedBy, addedAt: $addedAt, file: ${file.path}}";
|
||||
return "AlbumFileItem {addedBy: $addedBy, addedAt: $addedAt, file: ${file.fdPath}, ownerId: $ownerId}";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -350,6 +350,64 @@ class AlbumUpgraderV8 implements AlbumUpgrader {
|
|||
final String? logFilePath;
|
||||
}
|
||||
|
||||
/// Upgrade v9 Album to v10
|
||||
///
|
||||
/// In v10, file items are now stored as FileDescriptor instead of File
|
||||
@npLog
|
||||
class AlbumUpgraderV9 implements AlbumUpgrader {
|
||||
const AlbumUpgraderV9({
|
||||
required this.account,
|
||||
this.logFilePath,
|
||||
});
|
||||
|
||||
@override
|
||||
JsonObj? doJson(JsonObj json) {
|
||||
_log.fine("[doJson] Upgrade v9 Album for file: $logFilePath");
|
||||
final result = JsonObj.from(json);
|
||||
if (result["provider"]["type"] != "static") {
|
||||
return result;
|
||||
}
|
||||
for (final item in (result["provider"]["content"]["items"] as List)) {
|
||||
if (item["type"] != "file") {
|
||||
continue;
|
||||
}
|
||||
final originalFile =
|
||||
(item["content"]["file"] as Map).cast<String, dynamic>();
|
||||
item["content"]["file"] =
|
||||
AlbumUpgraderV8._fileJsonToFileDescriptorJson(originalFile);
|
||||
item["content"]["ownerId"] =
|
||||
originalFile["ownerId"] ?? account.userId.raw;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
DbAlbum? doDb(DbAlbum dbObj) {
|
||||
_log.fine("[doDb] Upgrade v9 Album for file: $logFilePath");
|
||||
if (dbObj.providerType != "static") {
|
||||
return dbObj;
|
||||
}
|
||||
final content = Map.of(dbObj.providerContent);
|
||||
for (final item in content["items"] as List) {
|
||||
if (item["type"] != "file") {
|
||||
continue;
|
||||
}
|
||||
final originalFile =
|
||||
(item["content"]["file"] as Map).cast<String, dynamic>();
|
||||
item["content"]["file"] =
|
||||
AlbumUpgraderV8._fileJsonToFileDescriptorJson(originalFile);
|
||||
item["content"]["ownerId"] =
|
||||
originalFile["ownerId"] ?? account.userId.raw;
|
||||
}
|
||||
return dbObj.copyWith(providerContent: content);
|
||||
}
|
||||
|
||||
final Account account;
|
||||
|
||||
/// File path for logging only
|
||||
final String? logFilePath;
|
||||
}
|
||||
|
||||
abstract class AlbumUpgraderFactory {
|
||||
const AlbumUpgraderFactory();
|
||||
|
||||
|
@ -361,6 +419,7 @@ abstract class AlbumUpgraderFactory {
|
|||
AlbumUpgraderV6? buildV6();
|
||||
AlbumUpgraderV7? buildV7();
|
||||
AlbumUpgraderV8? buildV8();
|
||||
AlbumUpgraderV9? buildV9();
|
||||
}
|
||||
|
||||
class DefaultAlbumUpgraderFactory extends AlbumUpgraderFactory {
|
||||
|
@ -371,32 +430,38 @@ class DefaultAlbumUpgraderFactory extends AlbumUpgraderFactory {
|
|||
});
|
||||
|
||||
@override
|
||||
buildV1() => AlbumUpgraderV1(logFilePath: logFilePath);
|
||||
AlbumUpgraderV1 buildV1() => AlbumUpgraderV1(logFilePath: logFilePath);
|
||||
|
||||
@override
|
||||
buildV2() => AlbumUpgraderV2(logFilePath: logFilePath);
|
||||
AlbumUpgraderV2 buildV2() => AlbumUpgraderV2(logFilePath: logFilePath);
|
||||
|
||||
@override
|
||||
buildV3() => AlbumUpgraderV3(logFilePath: logFilePath);
|
||||
AlbumUpgraderV3 buildV3() => AlbumUpgraderV3(logFilePath: logFilePath);
|
||||
|
||||
@override
|
||||
buildV4() => AlbumUpgraderV4(logFilePath: logFilePath);
|
||||
AlbumUpgraderV4 buildV4() => AlbumUpgraderV4(logFilePath: logFilePath);
|
||||
|
||||
@override
|
||||
buildV5() => AlbumUpgraderV5(
|
||||
AlbumUpgraderV5 buildV5() => AlbumUpgraderV5(
|
||||
account,
|
||||
albumFile: albumFile,
|
||||
logFilePath: logFilePath,
|
||||
);
|
||||
|
||||
@override
|
||||
buildV6() => AlbumUpgraderV6(logFilePath: logFilePath);
|
||||
AlbumUpgraderV6 buildV6() => AlbumUpgraderV6(logFilePath: logFilePath);
|
||||
|
||||
@override
|
||||
buildV7() => AlbumUpgraderV7(logFilePath: logFilePath);
|
||||
AlbumUpgraderV7 buildV7() => AlbumUpgraderV7(logFilePath: logFilePath);
|
||||
|
||||
@override
|
||||
AlbumUpgraderV8? buildV8() => AlbumUpgraderV8(logFilePath: logFilePath);
|
||||
AlbumUpgraderV8 buildV8() => AlbumUpgraderV8(logFilePath: logFilePath);
|
||||
|
||||
@override
|
||||
AlbumUpgraderV9 buildV9() => AlbumUpgraderV9(
|
||||
account: account,
|
||||
logFilePath: logFilePath,
|
||||
);
|
||||
|
||||
final Account account;
|
||||
final File? albumFile;
|
||||
|
|
|
@ -61,3 +61,10 @@ extension _$AlbumUpgraderV8NpLog on AlbumUpgraderV8 {
|
|||
|
||||
static final log = Logger("entity.album.upgrader.AlbumUpgraderV8");
|
||||
}
|
||||
|
||||
extension _$AlbumUpgraderV9NpLog on AlbumUpgraderV9 {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("entity.album.upgrader.AlbumUpgraderV9");
|
||||
}
|
||||
|
|
|
@ -192,7 +192,7 @@ class CollectionAlbumAdapter implements CollectionAdapter {
|
|||
_provider.album,
|
||||
sharee,
|
||||
onShareFileFailed: (f, e, stackTrace) {
|
||||
_log.severe("[share] Failed to share file: ${logFilename(f.path)}", e,
|
||||
_log.severe("[share] Failed to share file: ${logFilename(f.fdPath)}", e,
|
||||
stackTrace);
|
||||
fileFailed = true;
|
||||
},
|
||||
|
|
|
@ -50,6 +50,7 @@ class CollectionExporter {
|
|||
addedBy: account.userId,
|
||||
addedAt: clock.now().toUtc(),
|
||||
file: f,
|
||||
ownerId: f.ownerId ?? account.userId,
|
||||
);
|
||||
}
|
||||
} else if (e is CollectionLabelItem) {
|
||||
|
|
|
@ -8,6 +8,10 @@ abstract class CollectionItem {
|
|||
abstract class CollectionFileItem implements CollectionItem {
|
||||
const CollectionFileItem();
|
||||
|
||||
CollectionFileItem copyWith({
|
||||
FileDescriptor? file,
|
||||
});
|
||||
|
||||
FileDescriptor get file;
|
||||
}
|
||||
|
||||
|
|
|
@ -24,6 +24,15 @@ class CollectionFileItemAlbumAdapter extends CollectionFileItem
|
|||
with AlbumAdaptedCollectionItem {
|
||||
const CollectionFileItemAlbumAdapter(this.item);
|
||||
|
||||
@override
|
||||
CollectionFileItemAlbumAdapter copyWith({
|
||||
FileDescriptor? file,
|
||||
}) {
|
||||
return CollectionFileItemAlbumAdapter(item.copyWith(
|
||||
file: file,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
|
|
|
@ -9,6 +9,13 @@ part 'basic_item.g.dart';
|
|||
class BasicCollectionFileItem implements CollectionFileItem {
|
||||
const BasicCollectionFileItem(this.file);
|
||||
|
||||
@override
|
||||
BasicCollectionFileItem copyWith({
|
||||
FileDescriptor? file,
|
||||
}) {
|
||||
return BasicCollectionFileItem(file ?? this.file);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
|
|
|
@ -9,6 +9,13 @@ part 'nc_album_item_adapter.g.dart';
|
|||
class CollectionFileItemNcAlbumItemAdapter extends CollectionFileItem {
|
||||
const CollectionFileItemNcAlbumItemAdapter(this.item, [this.localFile]);
|
||||
|
||||
@override
|
||||
CollectionFileItemNcAlbumItemAdapter copyWith({
|
||||
FileDescriptor? file,
|
||||
}) {
|
||||
return CollectionFileItemNcAlbumItemAdapter(item, file ?? this.file);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
|
|
|
@ -14,6 +14,13 @@ abstract class NewCollectionItem implements CollectionItem {}
|
|||
class NewCollectionFileItem implements CollectionFileItem, NewCollectionItem {
|
||||
const NewCollectionFileItem(this.file);
|
||||
|
||||
@override
|
||||
NewCollectionFileItem copyWith({
|
||||
FileDescriptor? file,
|
||||
}) {
|
||||
return NewCollectionFileItem(file ?? this.file);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
|
|
|
@ -420,7 +420,7 @@ class FileSqliteDbDataSource implements FileDataSource {
|
|||
@override
|
||||
remove(Account account, FileDescriptor f) {
|
||||
_log.info("[remove] ${f.fdPath}");
|
||||
return FileSqliteCacheRemover(_c)(account, f);
|
||||
return _c.fileRepo2.remove(account, f);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
230
app/lib/entity/file/data_source2.dart
Normal file
230
app/lib/entity/file/data_source2.dart
Normal file
|
@ -0,0 +1,230 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/db/entity_converter.dart';
|
||||
import 'package:nc_photos/debug_util.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file/repo.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/exception.dart';
|
||||
import 'package:nc_photos/np_api_util.dart';
|
||||
import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util;
|
||||
import 'package:np_async/np_async.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_common/object_util.dart';
|
||||
import 'package:np_common/or_null.dart';
|
||||
import 'package:np_db/np_db.dart';
|
||||
|
||||
part 'data_source2.g.dart';
|
||||
|
||||
@npLog
|
||||
class FileRemoteDataSource implements FileDataSource2 {
|
||||
const FileRemoteDataSource();
|
||||
|
||||
@override
|
||||
Stream<List<FileDescriptor>> getFileDescriptors(
|
||||
Account account, String shareDirPath) {
|
||||
throw UnsupportedError("getFileDescriptors not supported");
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateProperty(
|
||||
Account account,
|
||||
FileDescriptor f, {
|
||||
OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
bool? favorite,
|
||||
OrNull<ImageLocation>? location,
|
||||
}) async {
|
||||
_log.info("[updateProperty] ${f.fdPath}");
|
||||
if (f is File &&
|
||||
metadata?.obj != null &&
|
||||
metadata!.obj!.fileEtag != f.etag) {
|
||||
_log.warning(
|
||||
"[updateProperty] Metadata etag mismatch (metadata: ${metadata.obj!.fileEtag}, file: ${f.etag})");
|
||||
}
|
||||
final setProps = {
|
||||
if (metadata?.obj != null)
|
||||
"app:metadata": jsonEncode(metadata!.obj!.toJson()),
|
||||
if (isArchived?.obj != null) "app:is-archived": isArchived!.obj,
|
||||
if (overrideDateTime?.obj != null)
|
||||
"app:override-date-time":
|
||||
overrideDateTime!.obj!.toUtc().toIso8601String(),
|
||||
if (favorite != null) "oc:favorite": favorite ? 1 : 0,
|
||||
if (location?.obj != null)
|
||||
"app:location": jsonEncode(location!.obj!.toJson()),
|
||||
};
|
||||
final removeProps = [
|
||||
if (OrNull.isSetNull(metadata)) "app:metadata",
|
||||
if (OrNull.isSetNull(isArchived)) "app:is-archived",
|
||||
if (OrNull.isSetNull(overrideDateTime)) "app:override-date-time",
|
||||
if (OrNull.isSetNull(location)) "app:location",
|
||||
];
|
||||
final response = await ApiUtil.fromAccount(account).files().proppatch(
|
||||
path: f.fdPath,
|
||||
namespaces: {
|
||||
"com.nkming.nc_photos": "app",
|
||||
"http://owncloud.org/ns": "oc",
|
||||
},
|
||||
set: setProps.isNotEmpty ? setProps : null,
|
||||
remove: removeProps.isNotEmpty ? removeProps : null,
|
||||
);
|
||||
if (!response.isGood) {
|
||||
_log.severe("[updateProperty] Failed requesting server: $response");
|
||||
throw ApiException(
|
||||
response: response,
|
||||
message: "Server responed with an error: HTTP ${response.statusCode}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(Account account, FileDescriptor f) async {
|
||||
_log.info("[remove] ${f.fdPath}");
|
||||
final response =
|
||||
await ApiUtil.fromAccount(account).files().delete(path: f.fdPath);
|
||||
if (!response.isGood) {
|
||||
_log.severe("[remove] Failed requesting server: $response");
|
||||
throw ApiException(
|
||||
response: response,
|
||||
message: "Server responed with an error: HTTP ${response.statusCode}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@npLog
|
||||
class FileNpDbDataSource implements FileDataSource2 {
|
||||
const FileNpDbDataSource(this.db);
|
||||
|
||||
@override
|
||||
Stream<List<FileDescriptor>> getFileDescriptors(
|
||||
Account account, String shareDirPath) async* {
|
||||
_log.info("[getFileDescriptors] $account");
|
||||
final stopwatch = Stopwatch()..start();
|
||||
yield await _getPartialFileDescriptors(account);
|
||||
yield await _getCompleteFileDescriptors(account, shareDirPath);
|
||||
_log.info(
|
||||
"[getFileDescriptors] Elapsed time: ${stopwatch.elapsedMilliseconds}ms");
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateProperty(
|
||||
Account account,
|
||||
FileDescriptor f, {
|
||||
OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
bool? favorite,
|
||||
OrNull<ImageLocation>? location,
|
||||
}) async {
|
||||
_log.info("[updateProperty] ${f.fdPath}");
|
||||
if (overrideDateTime != null || metadata != null) {
|
||||
f = DbFileConverter.fromDb(
|
||||
account.userId.toCaseInsensitiveString(),
|
||||
await db.getFilesByFileIds(
|
||||
account: account.toDb(),
|
||||
fileIds: [f.fdId],
|
||||
).first,
|
||||
);
|
||||
}
|
||||
await db.updateFileByFileId(
|
||||
account: account.toDb(),
|
||||
fileId: f.fdId,
|
||||
isFavorite: favorite?.let(OrNull.new),
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
bestDateTime: overrideDateTime == null && metadata == null
|
||||
? null
|
||||
: file_util.getBestDateTime(
|
||||
overrideDateTime: overrideDateTime == null
|
||||
? (f as File).overrideDateTime
|
||||
: overrideDateTime.obj,
|
||||
dateTimeOriginal: metadata == null
|
||||
? (f as File).metadata?.exif?.dateTimeOriginal
|
||||
: metadata.obj?.exif?.dateTimeOriginal,
|
||||
lastModified: (f as File).lastModified,
|
||||
),
|
||||
imageData: metadata?.let((e) => OrNull(e.obj?.toDb())),
|
||||
location: location?.let((e) => OrNull(e.obj?.toDb())),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(Account account, FileDescriptor f) async {
|
||||
_log.info("[remove] ${f.fdPath}");
|
||||
await db.deleteFile(
|
||||
account: account.toDb(),
|
||||
file: f.toDbKey(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<FileDescriptor>> _getPartialFileDescriptors(
|
||||
Account account) async {
|
||||
_log.info("[_getPartialFileDescriptors] $account");
|
||||
final results = await db.getFileDescriptors(
|
||||
account: account.toDb(),
|
||||
// need this because this arg expect empty string for root instead of "."
|
||||
includeRelativeRoots: account.roots
|
||||
.map((e) => File(path: file_util.unstripPath(account, e))
|
||||
.strippedPathWithEmpty)
|
||||
.toList(),
|
||||
excludeRelativeRoots: [remote_storage_util.remoteStorageDirRelativePath],
|
||||
mimes: file_util.supportedFormatMimes,
|
||||
limit: _partialCount,
|
||||
);
|
||||
return results
|
||||
.map((e) =>
|
||||
DbFileDescriptorConverter.fromDb(account.userId.toString(), e))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<List<FileDescriptor>> _getCompleteFileDescriptors(
|
||||
Account account, String shareDirPath) async {
|
||||
_log.info("[_getCompleteFileDescriptors] $account");
|
||||
final dbResults = await db.getFileDescriptors(
|
||||
account: account.toDb(),
|
||||
includeRelativeRoots: account.roots
|
||||
.map((e) => File(path: file_util.unstripPath(account, e))
|
||||
.strippedPathWithEmpty)
|
||||
.toList(),
|
||||
excludeRelativeRoots: [remote_storage_util.remoteStorageDirRelativePath],
|
||||
mimes: file_util.supportedFormatMimes,
|
||||
);
|
||||
final results = dbResults
|
||||
.map((e) => DbFileDescriptorConverter.fromDb(
|
||||
account.userId.toCaseInsensitiveString(), e))
|
||||
.toList();
|
||||
final isShareDirIncluded = account.roots.any((e) => file_util
|
||||
.isOrUnderDirPath(shareDirPath, file_util.unstripPath(account, e)));
|
||||
if (!isShareDirIncluded) {
|
||||
_log.info(
|
||||
"[_getCompleteFileDescriptors] Explicitly getting share folder");
|
||||
try {
|
||||
final shareDirResults = await db.getFilesByDirKey(
|
||||
account: account.toDb(),
|
||||
dir: File(path: shareDirPath).toDbKey(),
|
||||
);
|
||||
results.addAll(shareDirResults.where((f) => f.isCollection != true).map(
|
||||
(e) => DbFileConverter.fromDb(
|
||||
account.userId.toCaseInsensitiveString(), e)
|
||||
.toDescriptor()));
|
||||
} on DbNotFoundException catch (_) {
|
||||
// normal when there's no cache
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout(
|
||||
"[_getCompleteFileDescriptors] Failed while getFilesByDirKey: ${logFilename(shareDirPath)}",
|
||||
e,
|
||||
stackTrace);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
final NpDb db;
|
||||
|
||||
static const _partialCount = 100;
|
||||
}
|
21
app/lib/entity/file/data_source2.g.dart
Normal file
21
app/lib/entity/file/data_source2.g.dart
Normal file
|
@ -0,0 +1,21 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'data_source2.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$FileRemoteDataSourceNpLog on FileRemoteDataSource {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("entity.file.data_source2.FileRemoteDataSource");
|
||||
}
|
||||
|
||||
extension _$FileNpDbDataSourceNpLog on FileNpDbDataSource {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("entity.file.data_source2.FileNpDbDataSource");
|
||||
}
|
|
@ -110,20 +110,6 @@ class FileSqliteCacheUpdater {
|
|||
final DiContainer _c;
|
||||
}
|
||||
|
||||
class FileSqliteCacheRemover {
|
||||
const FileSqliteCacheRemover(this._c);
|
||||
|
||||
/// Remove a file/dir from cache
|
||||
Future<void> call(Account account, FileDescriptor f) async {
|
||||
await _c.npDb.deleteFile(
|
||||
account: account.toDb(),
|
||||
file: f.toDbKey(),
|
||||
);
|
||||
}
|
||||
|
||||
final DiContainer _c;
|
||||
}
|
||||
|
||||
class FileSqliteCacheEmptier {
|
||||
const FileSqliteCacheEmptier(this._c);
|
||||
|
||||
|
|
148
app/lib/entity/file/repo.dart
Normal file
148
app/lib/entity/file/repo.dart
Normal file
|
@ -0,0 +1,148 @@
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_common/or_null.dart';
|
||||
|
||||
part 'repo.g.dart';
|
||||
|
||||
abstract class FileRepo2 {
|
||||
/// Query all files belonging to [account]
|
||||
///
|
||||
/// Returned files are sorted by time in descending order
|
||||
///
|
||||
/// Normally the stream should complete with only a single event, but some
|
||||
/// implementation might want to return multiple set of values, say one set of
|
||||
/// cached value and later another set of updated value from a remote source.
|
||||
/// In any case, each event is guaranteed to be one complete set of data
|
||||
Stream<List<FileDescriptor>> getFileDescriptors(
|
||||
Account account, String shareDirPath);
|
||||
|
||||
Future<void> updateProperty(
|
||||
Account account,
|
||||
FileDescriptor f, {
|
||||
OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
bool? favorite,
|
||||
OrNull<ImageLocation>? location,
|
||||
});
|
||||
|
||||
Future<void> remove(Account account, FileDescriptor f);
|
||||
}
|
||||
|
||||
/// A repo that simply relay the call to the backed [FileDataSource]
|
||||
@npLog
|
||||
class BasicFileRepo implements FileRepo2 {
|
||||
const BasicFileRepo(this.dataSrc);
|
||||
|
||||
@override
|
||||
Stream<List<FileDescriptor>> getFileDescriptors(
|
||||
Account account, String shareDirPath) =>
|
||||
dataSrc.getFileDescriptors(account, shareDirPath);
|
||||
|
||||
@override
|
||||
Future<void> updateProperty(
|
||||
Account account,
|
||||
FileDescriptor f, {
|
||||
OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
bool? favorite,
|
||||
OrNull<ImageLocation>? location,
|
||||
}) =>
|
||||
dataSrc.updateProperty(
|
||||
account,
|
||||
f,
|
||||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
favorite: favorite,
|
||||
location: location,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> remove(Account account, FileDescriptor f) =>
|
||||
dataSrc.remove(account, f);
|
||||
|
||||
final FileDataSource2 dataSrc;
|
||||
}
|
||||
|
||||
/// A repo that manage a remote data source and a cache data source
|
||||
@npLog
|
||||
class CachedFileRepo implements FileRepo2 {
|
||||
const CachedFileRepo(this.remoteDataSrc, this.cacheDataSrc);
|
||||
|
||||
@override
|
||||
Stream<List<FileDescriptor>> getFileDescriptors(
|
||||
Account account, String shareDirPath) =>
|
||||
cacheDataSrc.getFileDescriptors(account, shareDirPath);
|
||||
|
||||
@override
|
||||
Future<void> updateProperty(
|
||||
Account account,
|
||||
FileDescriptor f, {
|
||||
OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
bool? favorite,
|
||||
OrNull<ImageLocation>? location,
|
||||
}) async {
|
||||
await remoteDataSrc.updateProperty(
|
||||
account,
|
||||
f,
|
||||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
favorite: favorite,
|
||||
location: location,
|
||||
);
|
||||
try {
|
||||
await cacheDataSrc.updateProperty(
|
||||
account,
|
||||
f,
|
||||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
favorite: favorite,
|
||||
location: location,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
_log.warning("[updateProperty] Failed to update cache", e, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(Account account, FileDescriptor f) async {
|
||||
await remoteDataSrc.remove(account, f);
|
||||
try {
|
||||
await cacheDataSrc.remove(account, f);
|
||||
} catch (e, stackTrace) {
|
||||
_log.warning("[remove] Failed to update cache", e, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
final FileDataSource2 remoteDataSrc;
|
||||
final FileDataSource2 cacheDataSrc;
|
||||
}
|
||||
|
||||
abstract class FileDataSource2 {
|
||||
/// Query all files belonging to [account]
|
||||
///
|
||||
/// Returned files are sorted by time in descending order
|
||||
Stream<List<FileDescriptor>> getFileDescriptors(
|
||||
Account account, String shareDirPath);
|
||||
|
||||
Future<void> updateProperty(
|
||||
Account account,
|
||||
FileDescriptor f, {
|
||||
OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
bool? favorite,
|
||||
OrNull<ImageLocation>? location,
|
||||
});
|
||||
|
||||
Future<void> remove(Account account, FileDescriptor f);
|
||||
}
|
21
app/lib/entity/file/repo.g.dart
Normal file
21
app/lib/entity/file/repo.g.dart
Normal file
|
@ -0,0 +1,21 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'repo.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$BasicFileRepoNpLog on BasicFileRepo {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("entity.file.repo.BasicFileRepo");
|
||||
}
|
||||
|
||||
extension _$CachedFileRepoNpLog on CachedFileRepo {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("entity.file.repo.CachedFileRepo");
|
||||
}
|
|
@ -55,7 +55,7 @@ class FileDescriptor with EquatableMixin {
|
|||
JsonObj toFdJson() => toJson(this);
|
||||
|
||||
@override
|
||||
get props => [
|
||||
List<Object?> get props => [
|
||||
fdPath,
|
||||
fdId,
|
||||
fdMime,
|
||||
|
@ -136,3 +136,23 @@ extension FileDescriptorExtension on FileDescriptor {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FileDescriptorServerIdentityComparator {
|
||||
const FileDescriptorServerIdentityComparator(this.file);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is FileDescriptorServerIdentityComparator) {
|
||||
return file.compareServerIdentity(other.file);
|
||||
} else if (other is FileDescriptor) {
|
||||
return file.compareServerIdentity(other);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => file.fdId.hashCode;
|
||||
|
||||
final FileDescriptor file;
|
||||
}
|
||||
|
|
|
@ -49,12 +49,18 @@ bool isNcAlbumFile(Account account, FileDescriptor file) =>
|
|||
bool isUnderDir(FileDescriptor file, FileDescriptor dir) =>
|
||||
file.fdPath.startsWith("${dir.fdPath}/");
|
||||
|
||||
bool isUnderDirPath(String filePath, String dirPath) =>
|
||||
filePath.startsWith("$dirPath/");
|
||||
|
||||
/// Return if [file] is [dir] or located under [dir]
|
||||
///
|
||||
/// See [isUnderDir]
|
||||
bool isOrUnderDir(FileDescriptor file, FileDescriptor dir) =>
|
||||
file.fdPath == dir.fdPath || isUnderDir(file, dir);
|
||||
|
||||
bool isOrUnderDirPath(String filePath, String dirPath) =>
|
||||
filePath == dirPath || isUnderDirPath(filePath, dirPath);
|
||||
|
||||
/// Convert a stripped path to a full path
|
||||
///
|
||||
/// See [File.strippedPath]
|
||||
|
@ -64,15 +70,18 @@ String unstripPath(Account account, String strippedPath) {
|
|||
}
|
||||
|
||||
/// For a path "remote.php/dav/files/foo/bar.jpg", return foo
|
||||
CiString getUserDirName(File file) {
|
||||
if (file.path.startsWith("remote.php/dav/files/")) {
|
||||
CiString getUserDirName(File file) => getUserDirNamePath(file.path);
|
||||
|
||||
/// For a path "remote.php/dav/files/foo/bar.jpg", return foo
|
||||
CiString getUserDirNamePath(String filePath) {
|
||||
if (filePath.startsWith("remote.php/dav/files/")) {
|
||||
const beg = "remote.php/dav/files/".length;
|
||||
final end = file.path.indexOf("/", beg);
|
||||
final end = filePath.indexOf("/", beg);
|
||||
if (end != -1) {
|
||||
return file.path.substring(beg, end).toCi();
|
||||
return filePath.substring(beg, end).toCi();
|
||||
}
|
||||
}
|
||||
throw ArgumentError("Invalid path: ${file.path}");
|
||||
throw ArgumentError("Invalid path: $filePath");
|
||||
}
|
||||
|
||||
String renameConflict(String filename, int conflictCount) {
|
||||
|
|
|
@ -2,12 +2,9 @@ import 'dart:async';
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:event_bus/event_bus.dart';
|
||||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/entity/pref/provider/memory.dart';
|
||||
import 'package:nc_photos/event/event.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_common/type.dart';
|
||||
|
||||
|
@ -30,14 +27,8 @@ class Pref {
|
|||
}
|
||||
|
||||
Future<bool> _set<T>(PrefKey key, T value,
|
||||
Future<bool> Function(PrefKey key, T value) setFn) async {
|
||||
if (await setFn(key, value)) {
|
||||
KiwiContainer().resolve<EventBus>().fire(PrefUpdatedEvent(key, value));
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Future<bool> Function(PrefKey key, T value) setFn) =>
|
||||
setFn(key, value);
|
||||
|
||||
Future<bool> _remove(PrefKey key) => provider.remove(key);
|
||||
|
||||
|
@ -69,16 +60,8 @@ class AccountPref {
|
|||
Future<JsonObj> toJson() => provider.toJson();
|
||||
|
||||
Future<bool> _set<T>(AccountPrefKey key, T value,
|
||||
Future<bool> Function(AccountPrefKey key, T value) setFn) async {
|
||||
if (await setFn(key, value)) {
|
||||
KiwiContainer()
|
||||
.resolve<EventBus>()
|
||||
.fire(AccountPrefUpdatedEvent(this, key, value));
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Future<bool> Function(AccountPrefKey key, T value) setFn) =>
|
||||
setFn(key, value);
|
||||
|
||||
Future<bool> _remove(AccountPrefKey key) => provider.remove(key);
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:np_string/np_string.dart';
|
||||
import 'package:path/path.dart' as path_lib;
|
||||
import 'package:to_string/to_string.dart';
|
||||
|
@ -150,7 +151,7 @@ class ShareRepo {
|
|||
/// See [ShareDataSource.list]
|
||||
Future<List<Share>> list(
|
||||
Account account,
|
||||
File file, {
|
||||
FileDescriptor file, {
|
||||
bool? isIncludeReshare,
|
||||
}) =>
|
||||
dataSrc.list(account, file, isIncludeReshare: isIncludeReshare);
|
||||
|
@ -171,7 +172,8 @@ class ShareRepo {
|
|||
dataSrc.reverseListAll(account);
|
||||
|
||||
/// See [ShareDataSource.create]
|
||||
Future<Share> create(Account account, File file, String shareWith) =>
|
||||
Future<Share> create(
|
||||
Account account, FileDescriptor file, String shareWith) =>
|
||||
dataSrc.create(account, file, shareWith);
|
||||
|
||||
/// See [ShareDataSource.createLink]
|
||||
|
@ -193,7 +195,7 @@ abstract class ShareDataSource {
|
|||
/// List all shares from a given file
|
||||
Future<List<Share>> list(
|
||||
Account account,
|
||||
File file, {
|
||||
FileDescriptor file, {
|
||||
bool? isIncludeReshare,
|
||||
});
|
||||
|
||||
|
@ -210,7 +212,7 @@ abstract class ShareDataSource {
|
|||
Future<List<Share>> reverseListAll(Account account);
|
||||
|
||||
/// Share a file/folder with a user
|
||||
Future<Share> create(Account account, File file, String shareWith);
|
||||
Future<Share> create(Account account, FileDescriptor file, String shareWith);
|
||||
|
||||
/// Share a file/folder with a share link
|
||||
///
|
||||
|
|
|
@ -18,12 +18,12 @@ part 'data_source.g.dart';
|
|||
@npLog
|
||||
class ShareRemoteDataSource implements ShareDataSource {
|
||||
@override
|
||||
list(
|
||||
Future<List<Share>> list(
|
||||
Account account,
|
||||
File file, {
|
||||
FileDescriptor file, {
|
||||
bool? isIncludeReshare,
|
||||
}) async {
|
||||
_log.info("[list] ${file.path}");
|
||||
_log.info("[list] ${file.fdPath}");
|
||||
final response =
|
||||
await ApiUtil.fromAccount(account).ocs().filesSharing().shares().get(
|
||||
path: file.strippedPath,
|
||||
|
@ -73,8 +73,9 @@ class ShareRemoteDataSource implements ShareDataSource {
|
|||
}
|
||||
|
||||
@override
|
||||
create(Account account, File file, String shareWith) async {
|
||||
_log.info("[create] Share '${file.path}' with '$shareWith'");
|
||||
Future<Share> create(
|
||||
Account account, FileDescriptor file, String shareWith) async {
|
||||
_log.info("[create] Share '${file.fdPath}' with '$shareWith'");
|
||||
final response =
|
||||
await ApiUtil.fromAccount(account).ocs().filesSharing().shares().post(
|
||||
path: file.strippedPath,
|
||||
|
|
|
@ -37,6 +37,7 @@ class AppEventListener<T> {
|
|||
final _log = Logger("event.event.AppEventListener<${T.runtimeType}>");
|
||||
}
|
||||
|
||||
@Deprecated("not fired anymore, to be removed")
|
||||
class AccountPrefUpdatedEvent {
|
||||
const AccountPrefUpdatedEvent(this.pref, this.key, this.value);
|
||||
|
||||
|
@ -49,7 +50,7 @@ class FilePropertyUpdatedEvent {
|
|||
FilePropertyUpdatedEvent(this.account, this.file, this.properties);
|
||||
|
||||
final Account account;
|
||||
final File file;
|
||||
final FileDescriptor file;
|
||||
final int properties;
|
||||
|
||||
// Bit masks for properties field
|
||||
|
@ -89,6 +90,7 @@ class ShareRemovedEvent {
|
|||
final Share share;
|
||||
}
|
||||
|
||||
@Deprecated("not fired anymore, to be removed")
|
||||
class FavoriteResyncedEvent {
|
||||
const FavoriteResyncedEvent(this.account);
|
||||
|
||||
|
@ -115,6 +117,7 @@ class MetadataTaskStateChangedEvent {
|
|||
final MetadataTaskState state;
|
||||
}
|
||||
|
||||
@Deprecated("not fired anymore, to be removed")
|
||||
class PrefUpdatedEvent {
|
||||
PrefUpdatedEvent(this.key, this.value);
|
||||
|
||||
|
|
|
@ -4,7 +4,11 @@ import 'dart:convert';
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/stream_extension.dart';
|
||||
import 'package:np_platform_message_relay/np_platform_message_relay.dart';
|
||||
import 'package:to_string/to_string.dart';
|
||||
|
||||
part 'native_event.g.dart';
|
||||
|
||||
@Deprecated("See AccountController.NativeEventRelay")
|
||||
class NativeEventListener<T> {
|
||||
NativeEventListener(this.listener);
|
||||
|
||||
|
@ -28,7 +32,7 @@ class NativeEventListener<T> {
|
|||
static final _mappedStream =
|
||||
MessageRelay.stream.whereType<Message>().map((ev) {
|
||||
switch (ev.event) {
|
||||
case FileExifUpdatedEvent._id:
|
||||
case FileExifUpdatedEvent.id:
|
||||
return FileExifUpdatedEvent.fromEvent(ev);
|
||||
|
||||
default:
|
||||
|
@ -43,24 +47,28 @@ class NativeEventListener<T> {
|
|||
Logger("event.native_event.NativeEventListener<${T.runtimeType}>");
|
||||
}
|
||||
|
||||
@toString
|
||||
class FileExifUpdatedEvent {
|
||||
const FileExifUpdatedEvent(this.fileIds);
|
||||
|
||||
factory FileExifUpdatedEvent.fromEvent(Message ev) {
|
||||
assert(ev.event == _id);
|
||||
assert(ev.event == id);
|
||||
assert(ev.data != null);
|
||||
final dataJson = jsonDecode(ev.data!) as Map;
|
||||
return FileExifUpdatedEvent((dataJson["fileIds"] as List).cast<int>());
|
||||
}
|
||||
|
||||
Message toEvent() => Message(
|
||||
_id,
|
||||
id,
|
||||
jsonEncode({
|
||||
"fileIds": fileIds,
|
||||
}),
|
||||
);
|
||||
|
||||
static const _id = "FileExifUpdatedEvent";
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
static const id = "FileExifUpdatedEvent";
|
||||
|
||||
final List<int> fileIds;
|
||||
}
|
||||
|
|
14
app/lib/event/native_event.g.dart
Normal file
14
app/lib/event/native_event.g.dart
Normal file
|
@ -0,0 +1,14 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'native_event.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// ToStringGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$FileExifUpdatedEventToString on FileExifUpdatedEvent {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "FileExifUpdatedEvent {fileIds: [length: ${fileIds.length}]}";
|
||||
}
|
||||
}
|
62
app/lib/event/native_event_relay.dart
Normal file
62
app/lib/event/native_event_relay.dart
Normal file
|
@ -0,0 +1,62 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/controller/files_controller.dart';
|
||||
import 'package:nc_photos/controller/metadata_controller.dart';
|
||||
import 'package:nc_photos/event/native_event.dart';
|
||||
import 'package:nc_photos/platform/features.dart' as features;
|
||||
import 'package:nc_photos/stream_extension.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_platform_image_processor/np_platform_image_processor.dart';
|
||||
import 'package:np_platform_message_relay/np_platform_message_relay.dart';
|
||||
|
||||
part 'native_event_relay.g.dart';
|
||||
|
||||
/// Convert native events into actions on the corresponding controllers
|
||||
@npLog
|
||||
class NativeEventRelay {
|
||||
NativeEventRelay({
|
||||
required this.filesController,
|
||||
required this.metadataController,
|
||||
}) {
|
||||
_subscriptions.add(MessageRelay.stream.whereType<Message>().listen((event) {
|
||||
switch (event.event) {
|
||||
case FileExifUpdatedEvent.id:
|
||||
_onFileExifUpdatedEvent(FileExifUpdatedEvent.fromEvent(event));
|
||||
break;
|
||||
|
||||
default:
|
||||
_log.severe('Unknown event: ${event.event}');
|
||||
break;
|
||||
}
|
||||
}));
|
||||
|
||||
if (features.isSupportEnhancement) {
|
||||
_subscriptions.add(ImageProcessor.stream
|
||||
.whereType<ImageProcessorUploadSuccessEvent>()
|
||||
.listen(_onImageProcessorUploadSuccessEvent));
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final s in _subscriptions) {
|
||||
s.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
void _onFileExifUpdatedEvent(FileExifUpdatedEvent ev) {
|
||||
_log.info(ev);
|
||||
filesController.applySyncResult(fileExifs: ev.fileIds);
|
||||
}
|
||||
|
||||
void _onImageProcessorUploadSuccessEvent(
|
||||
ImageProcessorUploadSuccessEvent ev) {
|
||||
_log.info(ev);
|
||||
filesController.syncRemote();
|
||||
metadataController.scheduleNext();
|
||||
}
|
||||
|
||||
final FilesController filesController;
|
||||
final MetadataController metadataController;
|
||||
final _subscriptions = <StreamSubscription>[];
|
||||
}
|
|
@ -1,14 +1,14 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'home_photos.dart';
|
||||
part of 'native_event_relay.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$_HomePhotosStateNpLog on _HomePhotosState {
|
||||
extension _$NativeEventRelayNpLog on NativeEventRelay {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("widget.home_photos._HomePhotosState");
|
||||
static final log = Logger("event.native_event_relay.NativeEventRelay");
|
||||
}
|
|
@ -37,8 +37,8 @@ class MetadataTask {
|
|||
for (final r in account.roots) {
|
||||
final dir = File(path: file_util.unstripPath(account, r));
|
||||
hasScanShareFolder |= file_util.isOrUnderDir(shareFolder, dir);
|
||||
final op = UpdateMissingMetadata(_c.fileRepo,
|
||||
const _UpdateMissingMetadataConfigProvider(), geocoder);
|
||||
final op = UpdateMissingMetadata(
|
||||
_c, const _UpdateMissingMetadataConfigProvider(), geocoder);
|
||||
await for (final _ in op(account, dir)) {
|
||||
if (!Pref().isEnableExifOr()) {
|
||||
_log.info("[call] EXIF disabled, task ending immaturely");
|
||||
|
@ -48,8 +48,8 @@ class MetadataTask {
|
|||
}
|
||||
}
|
||||
if (!hasScanShareFolder) {
|
||||
final op = UpdateMissingMetadata(_c.fileRepo,
|
||||
const _UpdateMissingMetadataConfigProvider(), geocoder);
|
||||
final op = UpdateMissingMetadata(
|
||||
_c, const _UpdateMissingMetadataConfigProvider(), geocoder);
|
||||
await for (final _ in op(
|
||||
account,
|
||||
shareFolder,
|
||||
|
|
|
@ -260,7 +260,7 @@ class _MetadataTask {
|
|||
final dir = File(path: file_util.unstripPath(account, r));
|
||||
hasScanShareFolder |= file_util.isOrUnderDir(shareFolder, dir);
|
||||
final updater = UpdateMissingMetadata(
|
||||
c.fileRepo, const _UpdateMissingMetadataConfigProvider(), geocoder);
|
||||
c, const _UpdateMissingMetadataConfigProvider(), geocoder);
|
||||
void onServiceStop() {
|
||||
_log.info("[_updateMetadata] Stopping task: user canceled");
|
||||
updater.stop();
|
||||
|
@ -283,7 +283,7 @@ class _MetadataTask {
|
|||
}
|
||||
if (!hasScanShareFolder) {
|
||||
final shareUpdater = UpdateMissingMetadata(
|
||||
c.fileRepo, const _UpdateMissingMetadataConfigProvider(), geocoder);
|
||||
c, const _UpdateMissingMetadataConfigProvider(), geocoder);
|
||||
void onServiceStop() {
|
||||
_log.info("[_updateMetadata] Stopping task: user canceled");
|
||||
shareUpdater.stop();
|
||||
|
|
|
@ -112,7 +112,7 @@ ThemeData buildDarkTheme(BuildContext context, [ColorScheme? dynamicScheme]) {
|
|||
}
|
||||
|
||||
Color? getSeedColor(BuildContext context) {
|
||||
return context.read<PrefController>().seedColor.value;
|
||||
return context.read<PrefController>().seedColorValue;
|
||||
}
|
||||
|
||||
ColorScheme _getColorScheme(
|
||||
|
|
|
@ -27,7 +27,6 @@ class AddFileToAlbum {
|
|||
static bool require(DiContainer c) =>
|
||||
DiContainer.has(c, DiType.albumRepo) &&
|
||||
DiContainer.has(c, DiType.shareRepo) &&
|
||||
ListShare.require(c) &&
|
||||
PreProcessAlbum.require(c);
|
||||
|
||||
/// Add list of files to [album]
|
||||
|
@ -47,7 +46,8 @@ class AddFileToAlbum {
|
|||
.map((f) => AlbumFileItem(
|
||||
addedBy: account.userId,
|
||||
addedAt: clock.now(),
|
||||
file: f,
|
||||
file: f.toDescriptor(),
|
||||
ownerId: f.ownerId ?? account.userId,
|
||||
))
|
||||
.where((i) => itemSet.add(OverrideComparator<AlbumItem>(
|
||||
i, _isItemFileEqual, _getItemHashCode)))
|
||||
|
@ -63,18 +63,14 @@ class AddFileToAlbum {
|
|||
);
|
||||
// UpdateAlbumWithActualItems only persists when there are changes to
|
||||
// several properties, so we can't rely on it
|
||||
newAlbum = await UpdateAlbumWithActualItems(null)(
|
||||
account,
|
||||
newAlbum,
|
||||
newItems,
|
||||
);
|
||||
newAlbum =
|
||||
await UpdateAlbumWithActualItems(null)(account, newAlbum, newItems);
|
||||
await UpdateAlbum(_c.albumRepo)(account, newAlbum);
|
||||
|
||||
if (album.shares?.isNotEmpty == true) {
|
||||
final newFiles =
|
||||
addItems.whereType<AlbumFileItem>().map((e) => e.file).toList();
|
||||
if (newFiles.isNotEmpty) {
|
||||
await _shareFiles(account, newAlbum, newFiles);
|
||||
final newFileItems = addItems.whereType<AlbumFileItem>().toList();
|
||||
if (newFileItems.isNotEmpty) {
|
||||
await _shareFiles(account, newAlbum, newFileItems);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -82,7 +78,7 @@ class AddFileToAlbum {
|
|||
}
|
||||
|
||||
Future<void> _shareFiles(
|
||||
Account account, Album album, List<File> files) async {
|
||||
Account account, Album album, List<AlbumFileItem> fileItems) async {
|
||||
final albumShares = (album.shares!.map((e) => e.userId).toList()
|
||||
..add(album.albumFile!.ownerId ?? account.userId))
|
||||
.where((element) => element != account.userId)
|
||||
|
@ -90,30 +86,30 @@ class AddFileToAlbum {
|
|||
if (albumShares.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final f in files) {
|
||||
for (final i in fileItems) {
|
||||
try {
|
||||
final fileShares = (await ListShare(_c)(account, f))
|
||||
final fileShares = (await ListShare(_c)(account, i.file))
|
||||
.where((element) => element.shareType == ShareType.user)
|
||||
.map((e) => e.shareWith!)
|
||||
.toSet();
|
||||
final diffShares = albumShares.difference(fileShares);
|
||||
for (final s in diffShares) {
|
||||
if (s == f.ownerId) {
|
||||
if (s == i.ownerId) {
|
||||
// skip files already owned by the target user
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await CreateUserShare(_c.shareRepo)(account, f, s.raw);
|
||||
await CreateUserShare(_c.shareRepo)(account, i.file, s.raw);
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout(
|
||||
"[_shareFiles] Failed while CreateUserShare: ${logFilename(f.path)}",
|
||||
"[_shareFiles] Failed while CreateUserShare: ${logFilename(i.file.fdPath)}",
|
||||
e,
|
||||
stackTrace);
|
||||
}
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout(
|
||||
"[_shareFiles] Failed while listing shares: ${logFilename(f.path)}",
|
||||
"[_shareFiles] Failed while listing shares: ${logFilename(i.file.fdPath)}",
|
||||
e,
|
||||
stackTrace);
|
||||
}
|
||||
|
@ -133,7 +129,7 @@ bool _isItemFileEqual(AlbumItem a, AlbumItem b) {
|
|||
|
||||
int _getItemHashCode(AlbumItem a) {
|
||||
if (a is AlbumFileItem) {
|
||||
return a.file.fileId?.hashCode ?? a.file.path.hashCode;
|
||||
return a.file.fdId.hashCode;
|
||||
} else {
|
||||
return a.hashCode;
|
||||
}
|
||||
|
|
|
@ -19,8 +19,6 @@ part 'remove_album.g.dart';
|
|||
class RemoveAlbum {
|
||||
RemoveAlbum(this._c)
|
||||
: assert(require(_c)),
|
||||
assert(ListShare.require(_c)),
|
||||
assert(Remove.require(_c)),
|
||||
assert(UnshareFileFromAlbum.require(_c));
|
||||
|
||||
static bool require(DiContainer c) =>
|
||||
|
|
|
@ -30,9 +30,6 @@ class RemoveFromAlbum {
|
|||
|
||||
/// Remove a list of AlbumItems from [album]
|
||||
///
|
||||
/// The items are compared with [identical], so it must come from [album] for
|
||||
/// it to work
|
||||
///
|
||||
/// If [shouldUnshare] is false, files will not be unshared after removing
|
||||
/// from the album
|
||||
Future<Album> call(
|
||||
|
@ -65,7 +62,8 @@ class RemoveFromAlbum {
|
|||
.toList();
|
||||
final provider = album.provider as AlbumStaticProvider;
|
||||
final newItems = provider.items
|
||||
.where((element) => !filtered.containsIdentical(element))
|
||||
.where((e) =>
|
||||
!filtered.containsIf(e, (a, b) => a.compareServerIdentity(b)))
|
||||
.toList();
|
||||
var newAlbum = album.copyWith(
|
||||
provider: AlbumStaticProvider.of(album).copyWith(
|
||||
|
@ -108,7 +106,7 @@ class RemoveFromAlbum {
|
|||
isNeedUpdate = true;
|
||||
break;
|
||||
}
|
||||
if (fileItem.file.bestDateTime == newAlbum.provider.latestItemTime) {
|
||||
if (fileItem.file.fdDateTime == newAlbum.provider.latestItemTime) {
|
||||
isNeedUpdate = true;
|
||||
break;
|
||||
}
|
||||
|
@ -130,7 +128,7 @@ class RemoveFromAlbum {
|
|||
}
|
||||
|
||||
Future<void> _unshareFiles(
|
||||
Account account, Album album, List<File> files) async {
|
||||
Account account, Album album, List<FileDescriptor> files) async {
|
||||
final albumShares = (album.shares!.map((e) => e.userId).toList()
|
||||
..add(album.albumFile!.ownerId ?? account.userId))
|
||||
.where((element) => element != account.userId)
|
||||
|
|
|
@ -4,7 +4,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/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:nc_photos/entity/share.dart';
|
||||
import 'package:nc_photos/entity/sharee.dart';
|
||||
import 'package:nc_photos/use_case/create_share.dart';
|
||||
|
@ -25,7 +25,7 @@ class ShareAlbumWithUser {
|
|||
Account account,
|
||||
Album album,
|
||||
Sharee sharee, {
|
||||
ErrorWithValueHandler<File>? onShareFileFailed,
|
||||
ErrorWithValueHandler<FileDescriptor>? onShareFileFailed,
|
||||
}) async {
|
||||
assert(album.provider is AlbumStaticProvider);
|
||||
final newShares = (album.shares ?? [])
|
||||
|
@ -56,12 +56,12 @@ class ShareAlbumWithUser {
|
|||
Account account,
|
||||
Album album,
|
||||
CiString shareWith, {
|
||||
ErrorWithValueHandler<File>? onShareFileFailed,
|
||||
ErrorWithValueHandler<FileDescriptor>? onShareFileFailed,
|
||||
}) async {
|
||||
final files = AlbumStaticProvider.of(album)
|
||||
.items
|
||||
.whereType<AlbumFileItem>()
|
||||
.where((item) => item.file.ownerId != shareWith)
|
||||
.where((item) => item.ownerId != shareWith)
|
||||
.map((e) => e.file);
|
||||
try {
|
||||
await CreateUserShare(shareRepo)(
|
||||
|
@ -74,12 +74,12 @@ class ShareAlbumWithUser {
|
|||
onShareFileFailed?.call(album.albumFile!, e, stackTrace);
|
||||
}
|
||||
for (final f in files) {
|
||||
_log.info("[_createFileShares] Sharing '${f.path}' with '$shareWith'");
|
||||
_log.info("[_createFileShares] Sharing '${f.fdPath}' with '$shareWith'");
|
||||
try {
|
||||
await CreateUserShare(shareRepo)(account, f, shareWith.raw);
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe(
|
||||
"[_createFileShares] Failed sharing file '${logFilename(f.path)}' with '$shareWith'",
|
||||
"[_createFileShares] Failed sharing file '${logFilename(f.fdPath)}' with '$shareWith'",
|
||||
e,
|
||||
stackTrace);
|
||||
onShareFileFailed?.call(f, e, stackTrace);
|
||||
|
|
|
@ -21,7 +21,6 @@ part 'unshare_album_with_user.g.dart';
|
|||
class UnshareAlbumWithUser {
|
||||
UnshareAlbumWithUser(this._c)
|
||||
: assert(require(_c)),
|
||||
assert(ListShare.require(_c)),
|
||||
assert(UnshareFileFromAlbum.require(_c));
|
||||
|
||||
static bool require(DiContainer c) =>
|
||||
|
|
|
@ -5,7 +5,6 @@ import 'package:nc_photos/di_container.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/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:nc_photos/entity/share.dart';
|
||||
import 'package:nc_photos/stream_extension.dart';
|
||||
|
@ -22,8 +21,7 @@ part 'unshare_file_from_album.g.dart';
|
|||
class UnshareFileFromAlbum {
|
||||
UnshareFileFromAlbum(this._c)
|
||||
: assert(require(_c)),
|
||||
assert(ListAlbum.require(_c)),
|
||||
assert(ListShare.require(_c));
|
||||
assert(ListAlbum.require(_c));
|
||||
|
||||
static bool require(DiContainer c) => DiContainer.has(c, DiType.shareRepo);
|
||||
|
||||
|
@ -34,7 +32,7 @@ class UnshareFileFromAlbum {
|
|||
Future<void> call(
|
||||
Account account,
|
||||
Album album,
|
||||
List<File> files,
|
||||
List<FileDescriptor> files,
|
||||
List<CiString> unshareWith, {
|
||||
ErrorWithValueHandler<Share>? onUnshareFileFailed,
|
||||
}) async {
|
||||
|
@ -57,7 +55,7 @@ class UnshareFileFromAlbum {
|
|||
exclusiveShares.addAll(
|
||||
shares.where((element) => unshareWith.contains(element.shareWith)));
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe("[call] Failed while ListShare: '${logFilename(f.path)}'",
|
||||
_log.severe("[call] Failed while ListShare: '${logFilename(f.fdPath)}'",
|
||||
e, stackTrace);
|
||||
}
|
||||
}
|
||||
|
@ -77,7 +75,7 @@ class UnshareFileFromAlbum {
|
|||
// remove files shared as part of this other shared album
|
||||
exclusiveShares.removeWhere((s) =>
|
||||
sharesOfInterest.any((i) => i.userId == s.shareWith) &&
|
||||
albumFiles.any((f) => f.fileId == s.itemSource));
|
||||
albumFiles.any((f) => f.fdId == s.itemSource));
|
||||
}
|
||||
_log.fine("[call] Post-filter shares: $exclusiveShares");
|
||||
|
||||
|
|
|
@ -54,7 +54,7 @@ class _SetArchiveFile {
|
|||
var count = 0;
|
||||
for (final f in files) {
|
||||
try {
|
||||
await UpdateProperty(_c.fileRepo).updateIsArchived(account, f, flag);
|
||||
await UpdateProperty(_c).updateIsArchived(account, f, flag);
|
||||
++count;
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe(
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import 'package:event_bus/event_bus.dart';
|
||||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/db/entity_converter.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/event/event.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_db/np_db.dart';
|
||||
|
||||
part 'cache_favorite.g.dart';
|
||||
|
||||
|
@ -15,18 +13,14 @@ class CacheFavorite {
|
|||
|
||||
/// Cache favorites using results from remote
|
||||
///
|
||||
/// Return number of files updated
|
||||
Future<int> call(Account account, Iterable<int> remoteFileIds) async {
|
||||
/// Return the fileIds of the affected files
|
||||
Future<DbSyncIdResult> call(
|
||||
Account account, Iterable<int> remoteFileIds) async {
|
||||
_log.info("[call] Cache favorites");
|
||||
final result = await _c.npDb.syncFavoriteFiles(
|
||||
return _c.npDb.syncFavoriteFiles(
|
||||
account: account.toDb(),
|
||||
favoriteFileIds: remoteFileIds.toList(),
|
||||
);
|
||||
final count = result.insert + result.delete + result.update;
|
||||
if (count > 0) {
|
||||
KiwiContainer().resolve<EventBus>().fire(FavoriteResyncedEvent(account));
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
final DiContainer _c;
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:nc_photos/entity/share.dart';
|
||||
|
||||
class CreateUserShare {
|
||||
const CreateUserShare(this.shareRepo);
|
||||
|
||||
Future<Share> call(Account account, File file, String shareWith) =>
|
||||
Future<Share> call(Account account, FileDescriptor file, String shareWith) =>
|
||||
shareRepo.create(account, file, shareWith);
|
||||
|
||||
final ShareRepo shareRepo;
|
||||
|
|
12
app/lib/use_case/file/list_file.dart
Normal file
12
app/lib/use_case/file/list_file.dart
Normal file
|
@ -0,0 +1,12 @@
|
|||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
|
||||
class ListFile {
|
||||
const ListFile(this._c);
|
||||
|
||||
Stream<List<FileDescriptor>> call(Account account, String shareDirPath) =>
|
||||
_c.fileRepo2.getFileDescriptors(account, shareDirPath);
|
||||
|
||||
final DiContainer _c;
|
||||
}
|
|
@ -2,7 +2,7 @@ import 'package:logging/logging.dart';
|
|||
import 'package:nc_photos/account.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_descriptor.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/entity/share.dart';
|
||||
import 'package:nc_photos/use_case/find_file.dart';
|
||||
|
@ -13,22 +13,20 @@ part 'list_share.g.dart';
|
|||
/// List all shares from a given file
|
||||
@npLog
|
||||
class ListShare {
|
||||
ListShare(this._c) : assert(require(_c));
|
||||
|
||||
static bool require(DiContainer c) => DiContainer.has(c, DiType.shareRepo);
|
||||
const ListShare(this._c);
|
||||
|
||||
Future<List<Share>> call(
|
||||
Account account,
|
||||
File file, {
|
||||
FileDescriptor file, {
|
||||
bool? isIncludeReshare,
|
||||
}) async {
|
||||
try {
|
||||
if (file_util.getUserDirName(file) != account.userId) {
|
||||
file = (await FindFile(_c)(account, [file.fileId!])).first;
|
||||
if (file_util.getUserDirNamePath(file.fdPath) != account.userId) {
|
||||
file = (await FindFile(_c)(account, [file.fdId])).first;
|
||||
}
|
||||
} catch (_) {
|
||||
// file not found
|
||||
_log.warning("[call] File not found in db: ${logFilename(file.path)}");
|
||||
_log.warning("[call] File not found in db: ${logFilename(file.fdPath)}");
|
||||
}
|
||||
return _c.shareRepo.list(
|
||||
account,
|
||||
|
|
|
@ -45,7 +45,7 @@ class RemoveFromNcAlbum {
|
|||
var count = fileItems.length;
|
||||
await Remove(_c)(
|
||||
account,
|
||||
fileItems.map((e) => e.file.toFile()).toList(),
|
||||
fileItems.map((e) => e.file).toList(),
|
||||
onError: (i, f, e, stackTrace) {
|
||||
--count;
|
||||
try {
|
||||
|
|
|
@ -55,6 +55,7 @@ class PopulateAlbum {
|
|||
addedBy: account.userId,
|
||||
addedAt: clock.now(),
|
||||
file: f,
|
||||
ownerId: f.ownerId ?? account.userId,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
@ -72,6 +73,7 @@ class PopulateAlbum {
|
|||
addedBy: account.userId,
|
||||
addedAt: clock.now(),
|
||||
file: f,
|
||||
ownerId: f.ownerId ?? account.userId,
|
||||
)));
|
||||
return products;
|
||||
}
|
||||
|
|
|
@ -14,8 +14,7 @@ import 'package:nc_photos/use_case/resync_album.dart';
|
|||
class PreProcessAlbum {
|
||||
PreProcessAlbum(this._c)
|
||||
: assert(require(_c)),
|
||||
assert(PopulateAlbum.require(_c)),
|
||||
assert(ResyncAlbum.require(_c));
|
||||
assert(PopulateAlbum.require(_c));
|
||||
|
||||
static bool require(DiContainer c) => true;
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@ import 'package:nc_photos/di_container.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/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:nc_photos/event/event.dart';
|
||||
import 'package:nc_photos/stream_extension.dart';
|
||||
|
@ -24,15 +23,7 @@ part 'remove.g.dart';
|
|||
|
||||
@npLog
|
||||
class Remove {
|
||||
Remove(this._c)
|
||||
: assert(require(_c)),
|
||||
assert(ListAlbum.require(_c)),
|
||||
assert(ListShare.require(_c)),
|
||||
assert(RemoveFromAlbum.require(_c));
|
||||
|
||||
static bool require(DiContainer c) =>
|
||||
DiContainer.has(c, DiType.fileRepo) &&
|
||||
DiContainer.has(c, DiType.shareRepo);
|
||||
const Remove(this._c);
|
||||
|
||||
/// Remove list of [files] and return the removed count
|
||||
Future<int> call(
|
||||
|
@ -52,7 +43,7 @@ class Remove {
|
|||
final i = pair.item1;
|
||||
final f = pair.item2;
|
||||
try {
|
||||
await _c.fileRepo.remove(account, f);
|
||||
await _c.fileRepo2.remove(account, f);
|
||||
++count;
|
||||
KiwiContainer().resolve<EventBus>().fire(FileRemovedEvent(account, f));
|
||||
} catch (e, stackTrace) {
|
||||
|
@ -64,11 +55,12 @@ class Remove {
|
|||
return count;
|
||||
}
|
||||
|
||||
// TODO: move to CollectionsController
|
||||
Future<void> _cleanUpAlbums(
|
||||
Account account, List<FileDescriptor> removes) async {
|
||||
final albums = await ListAlbum(_c)(account).whereType<Album>().toList();
|
||||
// figure out which files need to be unshared with whom
|
||||
final unshares = <FileServerIdentityComparator, Set<CiString>>{};
|
||||
final unshares = <FileDescriptorServerIdentityComparator, Set<CiString>>{};
|
||||
// clean up only make sense for static albums
|
||||
for (final a in albums.where((a) => a.provider is AlbumStaticProvider)) {
|
||||
try {
|
||||
|
@ -76,22 +68,21 @@ class Remove {
|
|||
final itemsToRemove = provider.items
|
||||
.whereType<AlbumFileItem>()
|
||||
.where((i) =>
|
||||
(i.file.isOwned(account.userId) ||
|
||||
i.addedBy == account.userId) &&
|
||||
(i.ownerId == account.userId || i.addedBy == account.userId) &&
|
||||
removes.any((r) => r.compareServerIdentity(i.file)))
|
||||
.toList();
|
||||
if (itemsToRemove.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
for (final i in itemsToRemove) {
|
||||
final key = FileServerIdentityComparator(i.file);
|
||||
final key = FileDescriptorServerIdentityComparator(i.file);
|
||||
final value = (a.shares?.map((s) => s.userId).toList() ?? [])
|
||||
..add(a.albumFile!.ownerId!)
|
||||
..remove(account.userId);
|
||||
(unshares[key] ??= <CiString>{}).addAll(value);
|
||||
}
|
||||
_log.fine(
|
||||
"[_cleanUpAlbums] Removing from album '${a.name}': ${itemsToRemove.map((e) => e.file.path).toReadableString()}");
|
||||
"[_cleanUpAlbums] Removing from album '${a.name}': ${itemsToRemove.map((e) => e.file.fdPath).toReadableString()}");
|
||||
// skip unsharing as we'll handle it ourselves
|
||||
await RemoveFromAlbum(_c)(account, a, itemsToRemove,
|
||||
shouldUnshare: false);
|
||||
|
|
|
@ -6,7 +6,7 @@ import 'package:nc_photos/di_container.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/use_case/find_file.dart';
|
||||
import 'package:nc_photos/use_case/find_file_descriptor.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
|
||||
part 'resync_album.g.dart';
|
||||
|
@ -14,9 +14,7 @@ part 'resync_album.g.dart';
|
|||
/// Resync files inside an album with the file db
|
||||
@npLog
|
||||
class ResyncAlbum {
|
||||
ResyncAlbum(this._c) : assert(require(_c));
|
||||
|
||||
static bool require(DiContainer c) => true;
|
||||
const ResyncAlbum(this._c);
|
||||
|
||||
Future<List<AlbumItem>> call(Account account, Album album) async {
|
||||
_log.info("[call] Resync album: ${album.name}");
|
||||
|
@ -26,11 +24,11 @@ class ResyncAlbum {
|
|||
}
|
||||
final items = AlbumStaticProvider.of(album).items;
|
||||
|
||||
final files = await FindFile(_c)(
|
||||
final files = await FindFileDescriptor(_c)(
|
||||
account,
|
||||
items
|
||||
.whereType<AlbumFileItem>()
|
||||
.map((i) => i.file.fileId)
|
||||
.map((i) => i.file.fdId)
|
||||
.whereNotNull()
|
||||
.toList(),
|
||||
onFileNotFound: (_) {},
|
||||
|
@ -40,19 +38,18 @@ class ResyncAlbum {
|
|||
return items.map((i) {
|
||||
if (i is AlbumFileItem) {
|
||||
try {
|
||||
if (i.file.fileId! == nextFile?.fileId) {
|
||||
final newItem = i.copyWith(
|
||||
file: nextFile,
|
||||
);
|
||||
if (i.file.fdId == nextFile?.fdId) {
|
||||
final newItem = i.copyWith(file: nextFile);
|
||||
nextFile = fileIt.moveNext() ? fileIt.current : null;
|
||||
return newItem;
|
||||
} else {
|
||||
_log.warning("[call] File not found: ${logFilename(i.file.path)}");
|
||||
_log.warning(
|
||||
"[call] File not found: ${logFilename(i.file.fdPath)}");
|
||||
return i;
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout(
|
||||
"[call] Failed syncing file in album: ${logFilename(i.file.path)}",
|
||||
"[call] Failed syncing file in album: ${logFilename(i.file.fdPath)}",
|
||||
e,
|
||||
stackTrace);
|
||||
return i;
|
||||
|
|
|
@ -1,21 +1,24 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:event_bus/event_bus.dart';
|
||||
import 'package:flutter_isolate/flutter_isolate.dart';
|
||||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mutex/mutex.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/app_init.dart' as app_init;
|
||||
import 'package:nc_photos/controller/files_controller.dart';
|
||||
import 'package:nc_photos/controller/persons_controller.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/person.dart';
|
||||
import 'package:nc_photos/event/event.dart';
|
||||
import 'package:nc_photos/use_case/person/sync_person.dart';
|
||||
import 'package:nc_photos/use_case/sync_favorite.dart';
|
||||
import 'package:nc_photos/use_case/sync_tag.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_common/object_util.dart';
|
||||
import 'package:np_common/type.dart';
|
||||
import 'package:np_db/np_db.dart';
|
||||
import 'package:np_platform_util/np_platform_util.dart';
|
||||
import 'package:to_string/to_string.dart';
|
||||
|
||||
part 'startup_sync.g.dart';
|
||||
|
||||
|
@ -28,7 +31,11 @@ class StartupSync {
|
|||
|
||||
/// Sync in a background isolate
|
||||
static Future<SyncResult> runInIsolate(
|
||||
Account account, PersonProvider personProvider) async {
|
||||
Account account,
|
||||
FilesController filesController,
|
||||
PersonsController personsController,
|
||||
PersonProvider personProvider,
|
||||
) async {
|
||||
return _mutex.protect(() async {
|
||||
if (getRawPlatform() == NpPlatform.web) {
|
||||
// not supported on web
|
||||
|
@ -42,7 +49,7 @@ class StartupSync {
|
|||
final result = SyncResult.fromJson(resultJson);
|
||||
// events fired in background isolate won't be noticed by the main isolate,
|
||||
// so we fire them again here
|
||||
_broadcastResult(account, result);
|
||||
_broadcastResult(account, filesController, personsController, result);
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
@ -52,16 +59,16 @@ class StartupSync {
|
|||
Account account, PersonProvider personProvider) async {
|
||||
_log.info("[_run] Begin sync");
|
||||
final stopwatch = Stopwatch()..start();
|
||||
late final int syncFavoriteCount;
|
||||
late final bool isSyncPersonUpdated;
|
||||
DbSyncIdResult? syncFavoriteResult;
|
||||
DbSyncIdResult? syncTagResult;
|
||||
var isSyncPersonUpdated = false;
|
||||
try {
|
||||
syncFavoriteCount = await SyncFavorite(_c)(account);
|
||||
syncFavoriteResult = await SyncFavorite(_c)(account);
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout("[_run] Failed while SyncFavorite", e, stackTrace);
|
||||
syncFavoriteCount = -1;
|
||||
}
|
||||
try {
|
||||
await SyncTag(_c)(account);
|
||||
syncTagResult = await SyncTag(_c)(account);
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout("[_run] Failed while SyncTag", e, stackTrace);
|
||||
}
|
||||
|
@ -72,15 +79,24 @@ class StartupSync {
|
|||
}
|
||||
_log.info("[_run] Elapsed time: ${stopwatch.elapsedMilliseconds}ms");
|
||||
return SyncResult(
|
||||
syncFavoriteCount: syncFavoriteCount,
|
||||
syncFavoriteResult: syncFavoriteResult,
|
||||
syncTagResult: syncTagResult,
|
||||
isSyncPersonUpdated: isSyncPersonUpdated,
|
||||
);
|
||||
}
|
||||
|
||||
static void _broadcastResult(Account account, SyncResult result) {
|
||||
final eventBus = KiwiContainer().resolve<EventBus>();
|
||||
if (result.syncFavoriteCount > 0) {
|
||||
eventBus.fire(FavoriteResyncedEvent(account));
|
||||
static void _broadcastResult(
|
||||
Account account,
|
||||
FilesController filesController,
|
||||
PersonsController personsController,
|
||||
SyncResult result,
|
||||
) {
|
||||
_$StartupSyncNpLog.log.info('[_broadcastResult] $result');
|
||||
if (result.syncFavoriteResult != null) {
|
||||
filesController.applySyncResult(favorites: result.syncFavoriteResult!);
|
||||
}
|
||||
if (result.isSyncPersonUpdated) {
|
||||
personsController.reload();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -89,23 +105,35 @@ class StartupSync {
|
|||
static final _mutex = Mutex();
|
||||
}
|
||||
|
||||
@toString
|
||||
class SyncResult {
|
||||
const SyncResult({
|
||||
required this.syncFavoriteCount,
|
||||
required this.syncFavoriteResult,
|
||||
required this.syncTagResult,
|
||||
required this.isSyncPersonUpdated,
|
||||
});
|
||||
|
||||
factory SyncResult.fromJson(JsonObj json) => SyncResult(
|
||||
syncFavoriteCount: json["syncFavoriteCount"],
|
||||
syncFavoriteResult: (json["syncFavoriteResult"] as Map?)
|
||||
?.cast<String, dynamic>()
|
||||
.let(DbSyncIdResult.fromJson),
|
||||
syncTagResult: (json["syncTagResult"] as Map?)
|
||||
?.cast<String, dynamic>()
|
||||
.let(DbSyncIdResult.fromJson),
|
||||
isSyncPersonUpdated: json["isSyncPersonUpdated"],
|
||||
);
|
||||
|
||||
JsonObj toJson() => {
|
||||
"syncFavoriteCount": syncFavoriteCount,
|
||||
"syncFavoriteResult": syncFavoriteResult?.toJson(),
|
||||
"syncTagResult": syncTagResult?.toJson(),
|
||||
"isSyncPersonUpdated": isSyncPersonUpdated,
|
||||
};
|
||||
|
||||
final int syncFavoriteCount;
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final DbSyncIdResult? syncFavoriteResult;
|
||||
final DbSyncIdResult? syncTagResult;
|
||||
final bool isSyncPersonUpdated;
|
||||
}
|
||||
|
||||
|
|
|
@ -12,3 +12,14 @@ extension _$StartupSyncNpLog on StartupSync {
|
|||
|
||||
static final log = Logger("use_case.startup_sync.StartupSync");
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// ToStringGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$SyncResultToString on SyncResult {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "SyncResult {syncFavoriteResult: $syncFavoriteResult, syncTagResult: $syncTagResult, isSyncPersonUpdated: $isSyncPersonUpdated}";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import 'package:nc_photos/entity/file_util.dart' as file_util;
|
|||
import 'package:nc_photos/entity/pref.dart';
|
||||
import 'package:nc_photos/use_case/cache_favorite.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_db/np_db.dart';
|
||||
|
||||
part 'sync_favorite.g.dart';
|
||||
|
||||
|
@ -18,7 +19,7 @@ class SyncFavorite {
|
|||
/// Sync favorites in cache db with remote server
|
||||
///
|
||||
/// Return number of files updated
|
||||
Future<int> call(Account account) async {
|
||||
Future<DbSyncIdResult> call(Account account) async {
|
||||
_log.info("[call] Sync favorites with remote");
|
||||
final remote = await _getRemoteFavoriteFileIds(account);
|
||||
return await CacheFavorite(_c)(account, remote);
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'package:nc_photos/account.dart';
|
|||
import 'package:nc_photos/db/entity_converter.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_db/np_db.dart';
|
||||
|
||||
part 'sync_tag.g.dart';
|
||||
|
||||
|
@ -11,13 +12,16 @@ class SyncTag {
|
|||
const SyncTag(this._c);
|
||||
|
||||
/// Sync tags in cache db with remote server
|
||||
Future<void> call(Account account) async {
|
||||
///
|
||||
/// Return tagIds of the affected tags
|
||||
Future<DbSyncIdResult> call(Account account) async {
|
||||
_log.info("[call] Sync tags with remote");
|
||||
final remote = await _c.tagRepoRemote.list(account);
|
||||
await _c.npDb.syncTags(
|
||||
final result = await _c.npDb.syncTags(
|
||||
account: account.toDb(),
|
||||
tags: remote.map(DbTagConverter.toDb).toList(),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
final DiContainer _c;
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import 'package:nc_photos/account.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/exception.dart';
|
||||
|
||||
class UpdateAlbum {
|
||||
UpdateAlbum(this.albumRepo);
|
||||
const UpdateAlbum(this.albumRepo);
|
||||
|
||||
Future<void> call(Account account, Album album) async {
|
||||
if (album.savedVersion > Album.version) {
|
||||
|
@ -13,23 +11,7 @@ class UpdateAlbum {
|
|||
throw AlbumDowngradeException(
|
||||
"Not allowed to downgrade album '${album.name}'");
|
||||
}
|
||||
final provider = album.provider;
|
||||
if (provider is AlbumStaticProvider) {
|
||||
await albumRepo.update(
|
||||
account,
|
||||
album.copyWith(
|
||||
provider: provider.copyWith(
|
||||
items: _minimizeItems(provider.items),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await albumRepo.update(account, album);
|
||||
}
|
||||
}
|
||||
|
||||
List<AlbumItem> _minimizeItems(List<AlbumItem> items) {
|
||||
return items.map((e) => e is AlbumFileItem ? e.minimize() : e).toList();
|
||||
await albumRepo.update(account, album);
|
||||
}
|
||||
|
||||
final AlbumRepo albumRepo;
|
||||
|
|
|
@ -2,7 +2,6 @@ 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/entity/album/sort_provider.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:np_common/or_null.dart';
|
||||
|
||||
|
@ -49,7 +48,7 @@ class UpdateAlbumTime {
|
|||
.map((e) => e.file)
|
||||
.where((element) => file_util.isSupportedFormat(element))
|
||||
.first;
|
||||
latestItemTime = latestFile.bestDateTime;
|
||||
latestItemTime = latestFile.fdDateTime;
|
||||
} catch (_) {
|
||||
latestItemTime = null;
|
||||
}
|
||||
|
|
|
@ -45,8 +45,8 @@ class UpdateAutoAlbumCover {
|
|||
final coverFile = sortedItems
|
||||
.whereType<AlbumFileItem>()
|
||||
.map((e) => e.file)
|
||||
.where((element) => file_util.isSupportedFormat(element))
|
||||
.firstWhere((element) => element.hasPreview ?? false);
|
||||
.where(file_util.isSupportedFormat)
|
||||
.first;
|
||||
// cache the result for later use
|
||||
if ((album.coverProvider as AlbumAutoCoverProvider)
|
||||
.coverFile
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:kiwi/kiwi.dart';
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/connectivity_util.dart' as connectivity_util;
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/exif_extension.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/event/event.dart';
|
||||
|
@ -26,7 +27,7 @@ abstract class UpdateMissingMetadataConfigProvider {
|
|||
|
||||
@npLog
|
||||
class UpdateMissingMetadata {
|
||||
UpdateMissingMetadata(this.fileRepo, this.configProvider, this.geocoder);
|
||||
UpdateMissingMetadata(this._c, this.configProvider, this.geocoder);
|
||||
|
||||
/// Update metadata for all files that support one under a dir
|
||||
///
|
||||
|
@ -44,7 +45,7 @@ class UpdateMissingMetadata {
|
|||
bool isRecursive = true,
|
||||
bool Function(File file)? filter,
|
||||
}) async* {
|
||||
final dataStream = ScanMissingMetadata(fileRepo)(
|
||||
final dataStream = ScanMissingMetadata(_c.fileRepo)(
|
||||
account,
|
||||
root,
|
||||
isRecursive: isRecursive,
|
||||
|
@ -78,7 +79,7 @@ class UpdateMissingMetadata {
|
|||
return;
|
||||
}
|
||||
_log.fine("[call] Updating metadata for ${file.path}");
|
||||
final binary = await GetFileBinary(fileRepo)(account, file);
|
||||
final binary = await GetFileBinary(_c.fileRepo)(account, file);
|
||||
final metadata =
|
||||
(await LoadMetadata().loadRemote(account, file, binary)).copyWith(
|
||||
fileEtag: file.etag,
|
||||
|
@ -111,7 +112,7 @@ class UpdateMissingMetadata {
|
|||
}
|
||||
|
||||
if (metadataUpdate != null || locationUpdate != null) {
|
||||
await UpdateProperty(fileRepo)(
|
||||
await UpdateProperty(_c)(
|
||||
account,
|
||||
file,
|
||||
metadata: metadataUpdate,
|
||||
|
@ -164,7 +165,7 @@ class UpdateMissingMetadata {
|
|||
}
|
||||
}
|
||||
|
||||
final FileRepo fileRepo;
|
||||
final DiContainer _c;
|
||||
final UpdateMissingMetadataConfigProvider configProvider;
|
||||
final ReverseGeocoder geocoder;
|
||||
|
||||
|
|
|
@ -2,7 +2,9 @@ import 'package:event_bus/event_bus.dart';
|
|||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:nc_photos/event/event.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_common/or_null.dart';
|
||||
|
@ -11,11 +13,11 @@ part 'update_property.g.dart';
|
|||
|
||||
@npLog
|
||||
class UpdateProperty {
|
||||
UpdateProperty(this.fileRepo);
|
||||
const UpdateProperty(this._c);
|
||||
|
||||
Future<void> call(
|
||||
Account account,
|
||||
File file, {
|
||||
FileDescriptor file, {
|
||||
OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
|
@ -32,11 +34,7 @@ class UpdateProperty {
|
|||
return;
|
||||
}
|
||||
|
||||
if (metadata?.obj != null && metadata!.obj!.fileEtag != file.etag) {
|
||||
_log.warning(
|
||||
"[call] Metadata fileEtag mismatch with actual file's (metadata: ${metadata.obj!.fileEtag}, file: ${file.etag})");
|
||||
}
|
||||
await fileRepo.updateProperty(
|
||||
await _c.fileRepo2.updateProperty(
|
||||
account,
|
||||
file,
|
||||
metadata: metadata,
|
||||
|
@ -46,6 +44,27 @@ class UpdateProperty {
|
|||
location: location,
|
||||
);
|
||||
|
||||
_notify(
|
||||
account,
|
||||
file,
|
||||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
favorite: favorite,
|
||||
location: location,
|
||||
);
|
||||
}
|
||||
|
||||
@Deprecated("legacy")
|
||||
void _notify(
|
||||
Account account,
|
||||
FileDescriptor file, {
|
||||
OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
bool? favorite,
|
||||
OrNull<ImageLocation>? location,
|
||||
}) {
|
||||
int properties = 0;
|
||||
if (metadata != null) {
|
||||
properties |= FilePropertyUpdatedEvent.propMetadata;
|
||||
|
@ -68,7 +87,7 @@ class UpdateProperty {
|
|||
.fire(FilePropertyUpdatedEvent(account, file, properties));
|
||||
}
|
||||
|
||||
final FileRepo fileRepo;
|
||||
final DiContainer _c;
|
||||
}
|
||||
|
||||
extension UpdatePropertyExtension on UpdateProperty {
|
||||
|
|
|
@ -7,7 +7,6 @@ import 'package:nc_photos/app_localizations.dart';
|
|||
import 'package:nc_photos/bloc/list_album_share_outlier.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/album.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/entity/share.dart';
|
||||
|
@ -190,7 +189,7 @@ class _AlbumShareOutlierBrowserState extends State<AlbumShareOutlierBrowser> {
|
|||
Widget _buildMissingShareeItem(
|
||||
BuildContext context, _MissingShareeItem item) {
|
||||
final Widget trailing;
|
||||
switch (_getItemStatus(item.file.path, item.shareWith)) {
|
||||
switch (_getItemStatus(item.file.fdPath, item.shareWith)) {
|
||||
case null:
|
||||
trailing = _buildFixButton(
|
||||
onPressed: () {
|
||||
|
@ -223,7 +222,7 @@ class _AlbumShareOutlierBrowserState extends State<AlbumShareOutlierBrowser> {
|
|||
|
||||
Widget _buildExtraShareItem(BuildContext context, _ExtraShareItem item) {
|
||||
final Widget trailing;
|
||||
switch (_getItemStatus(item.file.path, item.share.shareWith!)) {
|
||||
switch (_getItemStatus(item.file.fdPath, item.share.shareWith!)) {
|
||||
case null:
|
||||
trailing = _buildFixButton(
|
||||
onPressed: () {
|
||||
|
@ -254,7 +253,7 @@ class _AlbumShareOutlierBrowserState extends State<AlbumShareOutlierBrowser> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildFileThumbnail(File file) {
|
||||
Widget _buildFileThumbnail(FileDescriptor file) {
|
||||
if (file_util.isAlbumFile(widget.account, file)) {
|
||||
return const SizedBox.square(
|
||||
dimension: 56,
|
||||
|
@ -270,8 +269,8 @@ class _AlbumShareOutlierBrowserState extends State<AlbumShareOutlierBrowser> {
|
|||
}
|
||||
}
|
||||
|
||||
String _buildFilename(File file) {
|
||||
if (widget.album.albumFile?.path.equalsIgnoreCase(file.path) == true) {
|
||||
String _buildFilename(FileDescriptor file) {
|
||||
if (widget.album.albumFile?.path.equalsIgnoreCase(file.fdPath) == true) {
|
||||
return widget.album.name;
|
||||
} else {
|
||||
return file.filename;
|
||||
|
@ -326,9 +325,9 @@ class _AlbumShareOutlierBrowserState extends State<AlbumShareOutlierBrowser> {
|
|||
// select only items that are not fixed/being fixed
|
||||
final items = _items.where((i) {
|
||||
if (i is _MissingShareeItem) {
|
||||
return _getItemStatus(i.file.path, i.shareWith) == null;
|
||||
return _getItemStatus(i.file.fdPath, i.shareWith) == null;
|
||||
} else if (i is _ExtraShareItem) {
|
||||
return _getItemStatus(i.file.path, i.share.shareWith!) == null;
|
||||
return _getItemStatus(i.file.fdPath, i.share.shareWith!) == null;
|
||||
} else {
|
||||
// ?
|
||||
return false;
|
||||
|
@ -337,10 +336,10 @@ class _AlbumShareOutlierBrowserState extends State<AlbumShareOutlierBrowser> {
|
|||
setState(() {
|
||||
for (final i in items) {
|
||||
if (i is _MissingShareeItem) {
|
||||
_setItemStatus(i.file.path, i.shareWith, _ItemStatus.processing);
|
||||
_setItemStatus(i.file.fdPath, i.shareWith, _ItemStatus.processing);
|
||||
} else if (i is _ExtraShareItem) {
|
||||
_setItemStatus(
|
||||
i.file.path, i.share.shareWith!, _ItemStatus.processing);
|
||||
i.file.fdPath, i.share.shareWith!, _ItemStatus.processing);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -372,14 +371,14 @@ class _AlbumShareOutlierBrowserState extends State<AlbumShareOutlierBrowser> {
|
|||
Future<void> _fixMissingSharee(_MissingShareeItem item) async {
|
||||
final shareRepo = ShareRepo(ShareRemoteDataSource());
|
||||
setState(() {
|
||||
_setItemStatus(item.file.path, item.shareWith, _ItemStatus.processing);
|
||||
_setItemStatus(item.file.fdPath, item.shareWith, _ItemStatus.processing);
|
||||
});
|
||||
try {
|
||||
await CreateUserShare(shareRepo)(
|
||||
widget.account, item.file, item.shareWith.raw);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_setItemStatus(item.file.path, item.shareWith, _ItemStatus.fixed);
|
||||
_setItemStatus(item.file.fdPath, item.shareWith, _ItemStatus.fixed);
|
||||
});
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
|
@ -391,7 +390,7 @@ class _AlbumShareOutlierBrowserState extends State<AlbumShareOutlierBrowser> {
|
|||
));
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_removeItemStatus(item.file.path, item.shareWith);
|
||||
_removeItemStatus(item.file.fdPath, item.shareWith);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -401,14 +400,14 @@ class _AlbumShareOutlierBrowserState extends State<AlbumShareOutlierBrowser> {
|
|||
final shareRepo = ShareRepo(ShareRemoteDataSource());
|
||||
setState(() {
|
||||
_setItemStatus(
|
||||
item.file.path, item.share.shareWith!, _ItemStatus.processing);
|
||||
item.file.fdPath, item.share.shareWith!, _ItemStatus.processing);
|
||||
});
|
||||
try {
|
||||
await RemoveShare(shareRepo)(widget.account, item.share);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_setItemStatus(
|
||||
item.file.path, item.share.shareWith!, _ItemStatus.fixed);
|
||||
item.file.fdPath, item.share.shareWith!, _ItemStatus.fixed);
|
||||
});
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
|
@ -419,7 +418,7 @@ class _AlbumShareOutlierBrowserState extends State<AlbumShareOutlierBrowser> {
|
|||
));
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_removeItemStatus(item.file.path, item.share.shareWith!);
|
||||
_removeItemStatus(item.file.fdPath, item.share.shareWith!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -466,7 +465,7 @@ abstract class _ListItem {
|
|||
class _ExtraShareItem extends _ListItem {
|
||||
const _ExtraShareItem(this.file, this.share);
|
||||
|
||||
final File file;
|
||||
final FileDescriptor file;
|
||||
final Share share;
|
||||
}
|
||||
|
||||
|
@ -474,7 +473,7 @@ class _MissingShareeItem extends _ListItem {
|
|||
const _MissingShareeItem(
|
||||
this.file, this.shareWith, this.shareWithDisplayName);
|
||||
|
||||
final File file;
|
||||
final FileDescriptor file;
|
||||
final CiString shareWith;
|
||||
final String? shareWithDisplayName;
|
||||
}
|
||||
|
|
|
@ -1,304 +1,197 @@
|
|||
import 'dart:ui';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:copy_with/copy_with.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:kiwi/kiwi.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/app_localizations.dart';
|
||||
import 'package:nc_photos/bloc/scan_account_dir.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/bloc_util.dart';
|
||||
import 'package:nc_photos/controller/account_controller.dart';
|
||||
import 'package:nc_photos/controller/files_controller.dart';
|
||||
import 'package:nc_photos/controller/pref_controller.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:nc_photos/entity/pref.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/exception_event.dart';
|
||||
import 'package:nc_photos/exception_util.dart' as exception_util;
|
||||
import 'package:nc_photos/flutter_util.dart' as flutter_util;
|
||||
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/snack_bar_manager.dart';
|
||||
import 'package:nc_photos/use_case/inflate_file_descriptor.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/finger_listener.dart';
|
||||
import 'package:nc_photos/widget/network_thumbnail.dart';
|
||||
import 'package:nc_photos/widget/page_visibility_mixin.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/selectable_item_list.dart';
|
||||
import 'package:nc_photos/widget/selection_app_bar.dart';
|
||||
import 'package:nc_photos/widget/sliver_visualized_scale.dart';
|
||||
import 'package:nc_photos/widget/viewer.dart';
|
||||
import 'package:nc_photos/widget/zoom_menu_button.dart';
|
||||
import 'package:np_async/np_async.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_common/object_util.dart';
|
||||
import 'package:np_common/or_null.dart';
|
||||
import 'package:to_string/to_string.dart';
|
||||
|
||||
part 'archive_browser.g.dart';
|
||||
part 'archive_browser/app_bar.dart';
|
||||
part 'archive_browser/bloc.dart';
|
||||
part 'archive_browser/state_event.dart';
|
||||
part 'archive_browser/type.dart';
|
||||
part 'archive_browser/view.dart';
|
||||
|
||||
class ArchiveBrowserArguments {
|
||||
ArchiveBrowserArguments(this.account);
|
||||
|
||||
final Account account;
|
||||
}
|
||||
|
||||
class ArchiveBrowser extends StatefulWidget {
|
||||
class ArchiveBrowser extends StatelessWidget {
|
||||
static const routeName = "/archive-browser";
|
||||
|
||||
static Route buildRoute(ArchiveBrowserArguments args) => MaterialPageRoute(
|
||||
builder: (context) => ArchiveBrowser.fromArgs(args),
|
||||
static Route buildRoute() => MaterialPageRoute(
|
||||
builder: (_) => const ArchiveBrowser(),
|
||||
);
|
||||
|
||||
const ArchiveBrowser({
|
||||
Key? key,
|
||||
required this.account,
|
||||
}) : super(key: key);
|
||||
|
||||
ArchiveBrowser.fromArgs(ArchiveBrowserArguments args, {Key? key})
|
||||
: this(
|
||||
key: key,
|
||||
account: args.account,
|
||||
);
|
||||
const ArchiveBrowser({super.key});
|
||||
|
||||
@override
|
||||
createState() => _ArchiveBrowserState();
|
||||
Widget build(BuildContext context) {
|
||||
final accountController = context.read<AccountController>();
|
||||
return BlocProvider(
|
||||
create: (_) => _Bloc(
|
||||
account: accountController.account,
|
||||
controller: accountController.filesController,
|
||||
prefController: context.read(),
|
||||
),
|
||||
child: const _WrappedArchiveBrowser(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final Account account;
|
||||
class _WrappedArchiveBrowser extends StatefulWidget {
|
||||
const _WrappedArchiveBrowser();
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _WrappedArchiveBrowserState();
|
||||
}
|
||||
|
||||
@npLog
|
||||
class _ArchiveBrowserState extends State<ArchiveBrowser>
|
||||
with SelectableItemStreamListMixin<ArchiveBrowser> {
|
||||
class _WrappedArchiveBrowserState extends State<_WrappedArchiveBrowser>
|
||||
with RouteAware, PageVisibilityMixin {
|
||||
@override
|
||||
initState() {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initBloc();
|
||||
_thumbZoomLevel = Pref().getAlbumBrowserZoomLevelOr(0);
|
||||
_bloc.add(const _LoadItems());
|
||||
}
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: BlocListener<ScanAccountDirBloc, ScanAccountDirBlocState>(
|
||||
bloc: _bloc,
|
||||
listener: (context, state) => _onStateChange(context, state),
|
||||
child: BlocBuilder<ScanAccountDirBloc, ScanAccountDirBlocState>(
|
||||
bloc: _bloc,
|
||||
builder: (context, state) => _buildContent(context, state),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@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");
|
||||
_reqQuery();
|
||||
} else {
|
||||
// process the current state
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_onStateChange(context, _bloc.state);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, ScanAccountDirBlocState state) {
|
||||
if (state is ScanAccountDirBlocSuccess &&
|
||||
!_buildItemQueue.isProcessing &&
|
||||
itemStreamListItems.isEmpty) {
|
||||
return Column(
|
||||
children: [
|
||||
AppBar(
|
||||
title: Text(L10n.global().albumArchiveLabel),
|
||||
elevation: 0,
|
||||
body: MultiBlocListener(
|
||||
listeners: [
|
||||
_BlocListenerT<List<FileDescriptor>>(
|
||||
selector: (state) => state.files,
|
||||
listener: (context, files) {
|
||||
_bloc.add(_TransformItems(files));
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: EmptyListIndicator(
|
||||
icon: Icons.archive_outlined,
|
||||
text: L10n.global().listEmptyText,
|
||||
),
|
||||
_BlocListenerT<ExceptionEvent?>(
|
||||
selector: (state) => state.error,
|
||||
listener: (context, error) {
|
||||
if (error != null && isPageVisible()) {
|
||||
if (error.error is _UnarchiveFailedError) {
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(L10n.global()
|
||||
.unarchiveSelectedFailureNotification(
|
||||
(error.error as _UnarchiveFailedError).count)),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
} else {
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(exception_util.toUserString(error.error)),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Stack(
|
||||
children: [
|
||||
buildItemStreamListOuter(
|
||||
context,
|
||||
child: FingerListener(
|
||||
onFingerChanged: (finger) {
|
||||
setState(() {
|
||||
_finger = finger;
|
||||
});
|
||||
},
|
||||
child: GestureDetector(
|
||||
onScaleStart: (_) {
|
||||
_bloc.add(const _StartScaling());
|
||||
},
|
||||
onScaleUpdate: (details) {
|
||||
_bloc.add(_SetScale(details.scale));
|
||||
},
|
||||
onScaleEnd: (_) {
|
||||
_bloc.add(const _EndScaling());
|
||||
},
|
||||
child: CustomScrollView(
|
||||
physics:
|
||||
_finger >= 2 ? const NeverScrollableScrollPhysics() : null,
|
||||
slivers: [
|
||||
_buildAppBar(context),
|
||||
buildItemStreamList(
|
||||
maxCrossAxisExtent: _thumbSize.toDouble(),
|
||||
_BlocSelector<bool>(
|
||||
selector: (state) => state.selectedItems.isEmpty,
|
||||
builder: (context, isEmpty) =>
|
||||
isEmpty ? const _AppBar() : const _SelectionAppBar(),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: _BlocSelector<bool>(
|
||||
selector: (state) => state.isLoading,
|
||||
builder: (context, isLoading) => isLoading
|
||||
? const LinearProgressIndicator()
|
||||
: const SizedBox(height: 4),
|
||||
),
|
||||
),
|
||||
_BlocBuilder(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.transformedItems.isEmpty !=
|
||||
current.transformedItems.isEmpty ||
|
||||
previous.isLoading != current.isLoading,
|
||||
builder: (context, state) => state.transformedItems.isEmpty &&
|
||||
!state.isLoading
|
||||
? SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: EmptyListIndicator(
|
||||
icon: Icons.archive_outlined,
|
||||
text: L10n.global().listEmptyText,
|
||||
),
|
||||
)
|
||||
: _BlocSelector<double?>(
|
||||
selector: (state) => state.scale,
|
||||
builder: (context, scale) => SliverTransitionedScale(
|
||||
scale: scale,
|
||||
baseSliver: const _ContentList(),
|
||||
overlaySliver: const _ScalingList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (state is ScanAccountDirBlocLoading ||
|
||||
_buildItemQueue.isProcessing)
|
||||
const Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: LinearProgressIndicator(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context) {
|
||||
if (isSelectionMode) {
|
||||
return _buildSelectionAppBar(context);
|
||||
} else {
|
||||
return _buildNormalAppBar(context);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSelectionAppBar(BuildContext context) {
|
||||
return SelectionAppBar(
|
||||
count: selectedListItems.length,
|
||||
onClosePressed: () {
|
||||
setState(() {
|
||||
clearSelectedItems();
|
||||
});
|
||||
},
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.unarchive),
|
||||
tooltip: L10n.global().unarchiveTooltip,
|
||||
onPressed: () {
|
||||
_onSelectionAppBarUnarchivePressed();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNormalAppBar(BuildContext context) {
|
||||
return SliverAppBar(
|
||||
title: Text(L10n.global().albumArchiveLabel),
|
||||
floating: true,
|
||||
actions: [
|
||||
ZoomMenuButton(
|
||||
initialZoom: _thumbZoomLevel,
|
||||
minZoom: 0,
|
||||
maxZoom: 2,
|
||||
onZoomChanged: (value) {
|
||||
setState(() {
|
||||
_thumbZoomLevel = value.round();
|
||||
});
|
||||
Pref().setAlbumBrowserZoomLevel(_thumbZoomLevel);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onStateChange(BuildContext context, ScanAccountDirBlocState state) {
|
||||
if (state is ScanAccountDirBlocInit) {
|
||||
itemStreamListItems = [];
|
||||
} else if (state is ScanAccountDirBlocSuccess ||
|
||||
state is ScanAccountDirBlocLoading) {
|
||||
_transformItems(state.files);
|
||||
} else if (state is ScanAccountDirBlocFailure) {
|
||||
_transformItems(state.files);
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(exception_util.toUserString(state.exception)),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
} else if (state is ScanAccountDirBlocInconsistent) {
|
||||
_reqQuery();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSelectionAppBarUnarchivePressed() async {
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(L10n.global()
|
||||
.unarchiveSelectedProcessingNotification(selectedListItems.length)),
|
||||
duration: k.snackBarDurationShort,
|
||||
));
|
||||
final selection = selectedListItems
|
||||
.whereType<PhotoListFileItem>()
|
||||
.map((e) => e.file)
|
||||
.toList();
|
||||
setState(() {
|
||||
clearSelectedItems();
|
||||
});
|
||||
final c = KiwiContainer().resolve<DiContainer>();
|
||||
final selectedFiles =
|
||||
await InflateFileDescriptor(c)(widget.account, selection);
|
||||
final failures = <File>[];
|
||||
for (final f in selectedFiles) {
|
||||
try {
|
||||
await UpdateProperty(c.fileRepo)
|
||||
.updateIsArchived(widget.account, f, false);
|
||||
} catch (e, stacktrace) {
|
||||
_log.shout(
|
||||
"[_onSelectionAppBarUnarchivePressed] Failed while unarchiving file: ${logFilename(f.path)}",
|
||||
e,
|
||||
stacktrace);
|
||||
failures.add(f);
|
||||
}
|
||||
}
|
||||
if (failures.isEmpty) {
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(L10n.global().unarchiveSelectedSuccessNotification),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
} else {
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(L10n.global()
|
||||
.unarchiveSelectedFailureNotification(failures.length)),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _transformItems(List<FileDescriptor> files) {
|
||||
_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;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _reqQuery() {
|
||||
_bloc.add(const ScanAccountDirBlocQuery());
|
||||
}
|
||||
late final _bloc = context.bloc;
|
||||
|
||||
late final _bloc = ScanAccountDirBloc.of(widget.account);
|
||||
|
||||
var _backingFiles = <FileDescriptor>[];
|
||||
|
||||
final _buildItemQueue =
|
||||
ComputeQueue<PhotoListItemBuilderArguments, PhotoListItemBuilderResult>();
|
||||
|
||||
var _thumbZoomLevel = 0;
|
||||
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
|
||||
var _finger = 0;
|
||||
}
|
||||
|
||||
typedef _BlocBuilder = BlocBuilder<_Bloc, _State>;
|
||||
// typedef _BlocListener = BlocListener<_Bloc, _State>;
|
||||
typedef _BlocListenerT<T> = BlocListenerT<_Bloc, _State, T>;
|
||||
typedef _BlocSelector<T> = BlocSelector<_Bloc, _State, T>;
|
||||
|
||||
extension on BuildContext {
|
||||
_Bloc get bloc => read<_Bloc>();
|
||||
// _State get state => bloc.state;
|
||||
void addEvent(_Event event) => bloc.add(event);
|
||||
}
|
||||
|
||||
@npLog
|
||||
// ignore: camel_case_types
|
||||
class __ {}
|
||||
|
|
|
@ -2,13 +2,185 @@
|
|||
|
||||
part of 'archive_browser.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithLintRuleGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: library_private_types_in_public_api, duplicate_ignore
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithGenerator
|
||||
// **************************************************************************
|
||||
|
||||
abstract class $_StateCopyWithWorker {
|
||||
_State call(
|
||||
{List<FileDescriptor>? files,
|
||||
bool? isLoading,
|
||||
List<_Item>? transformedItems,
|
||||
Set<_Item>? selectedItems,
|
||||
Set<_VisibleItem>? visibleItems,
|
||||
int? zoom,
|
||||
double? scale,
|
||||
ExceptionEvent? error});
|
||||
}
|
||||
|
||||
class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker {
|
||||
_$_StateCopyWithWorkerImpl(this.that);
|
||||
|
||||
@override
|
||||
_State call(
|
||||
{dynamic files,
|
||||
dynamic isLoading,
|
||||
dynamic transformedItems,
|
||||
dynamic selectedItems,
|
||||
dynamic visibleItems,
|
||||
dynamic zoom,
|
||||
dynamic scale = copyWithNull,
|
||||
dynamic error = copyWithNull}) {
|
||||
return _State(
|
||||
files: files as List<FileDescriptor>? ?? that.files,
|
||||
isLoading: isLoading as bool? ?? that.isLoading,
|
||||
transformedItems:
|
||||
transformedItems as List<_Item>? ?? that.transformedItems,
|
||||
selectedItems: selectedItems as Set<_Item>? ?? that.selectedItems,
|
||||
visibleItems: visibleItems as Set<_VisibleItem>? ?? that.visibleItems,
|
||||
zoom: zoom as int? ?? that.zoom,
|
||||
scale: scale == copyWithNull ? that.scale : scale as double?,
|
||||
error: error == copyWithNull ? that.error : error as ExceptionEvent?);
|
||||
}
|
||||
|
||||
final _State that;
|
||||
}
|
||||
|
||||
extension $_StateCopyWith on _State {
|
||||
$_StateCopyWithWorker get copyWith => _$copyWith;
|
||||
$_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this);
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$_ArchiveBrowserStateNpLog on _ArchiveBrowserState {
|
||||
extension _$_WrappedArchiveBrowserStateNpLog on _WrappedArchiveBrowserState {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("widget.archive_browser._ArchiveBrowserState");
|
||||
static final log =
|
||||
Logger("widget.archive_browser._WrappedArchiveBrowserState");
|
||||
}
|
||||
|
||||
extension _$__NpLog on __ {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("widget.archive_browser.__");
|
||||
}
|
||||
|
||||
extension _$_SelectionAppBarNpLog on _SelectionAppBar {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("widget.archive_browser._SelectionAppBar");
|
||||
}
|
||||
|
||||
extension _$_BlocNpLog on _Bloc {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("widget.archive_browser._Bloc");
|
||||
}
|
||||
|
||||
extension _$_ContentListBodyNpLog on _ContentListBody {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("widget.archive_browser._ContentListBody");
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// ToStringGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$_UnarchiveFailedErrorToString on _UnarchiveFailedError {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_UnarchiveFailedError {count: $count}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_StateToString on _State {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_State {files: [length: ${files.length}], isLoading: $isLoading, transformedItems: [length: ${transformedItems.length}], selectedItems: {length: ${selectedItems.length}}, visibleItems: {length: ${visibleItems.length}}, zoom: $zoom, scale: ${scale == null ? null : "${scale!.toStringAsFixed(3)}"}, error: $error}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_LoadItemsToString on _LoadItems {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_LoadItems {}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_ReloadToString on _Reload {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_Reload {}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_TransformItemsToString on _TransformItems {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_TransformItems {items: [length: ${items.length}]}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_OnItemTransformedToString on _OnItemTransformed {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_OnItemTransformed {items: [length: ${items.length}]}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_SetSelectedItemsToString on _SetSelectedItems {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_SetSelectedItems {items: {length: ${items.length}}}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_UnarchiveSelectedItemsToString on _UnarchiveSelectedItems {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_UnarchiveSelectedItems {}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_StartScalingToString on _StartScaling {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_StartScaling {}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_EndScalingToString on _EndScaling {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_EndScaling {}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_SetScaleToString on _SetScale {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_SetScale {scale: ${scale.toStringAsFixed(3)}}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_SetErrorToString on _SetError {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_SetError {error: $error, stackTrace: $stackTrace}";
|
||||
}
|
||||
}
|
||||
|
|
41
app/lib/widget/archive_browser/app_bar.dart
Normal file
41
app/lib/widget/archive_browser/app_bar.dart
Normal file
|
@ -0,0 +1,41 @@
|
|||
part of '../archive_browser.dart';
|
||||
|
||||
class _AppBar extends StatelessWidget {
|
||||
const _AppBar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverAppBar(
|
||||
title: Text(L10n.global().albumArchiveLabel),
|
||||
floating: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@npLog
|
||||
class _SelectionAppBar extends StatelessWidget {
|
||||
const _SelectionAppBar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _BlocBuilder(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.selectedItems != current.selectedItems,
|
||||
builder: (context, state) => SelectionAppBar(
|
||||
count: state.selectedItems.length,
|
||||
onClosePressed: () {
|
||||
context.addEvent(const _SetSelectedItems(items: {}));
|
||||
},
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.unarchive_outlined),
|
||||
tooltip: L10n.global().unarchiveTooltip,
|
||||
onPressed: () {
|
||||
context.addEvent(const _UnarchiveSelectedItems());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
217
app/lib/widget/archive_browser/bloc.dart
Normal file
217
app/lib/widget/archive_browser/bloc.dart
Normal file
|
@ -0,0 +1,217 @@
|
|||
part of '../archive_browser.dart';
|
||||
|
||||
@npLog
|
||||
class _Bloc extends Bloc<_Event, _State> with BlocLogger {
|
||||
_Bloc({
|
||||
required this.account,
|
||||
required this.controller,
|
||||
required this.prefController,
|
||||
}) : super(_State.init(
|
||||
zoom: prefController.albumBrowserZoomLevelValue,
|
||||
)) {
|
||||
on<_LoadItems>(_onLoad);
|
||||
on<_TransformItems>(_onTransformItems);
|
||||
on<_OnItemTransformed>(_onOnItemTransformed);
|
||||
|
||||
on<_SetSelectedItems>(_onSetSelectedItems);
|
||||
on<_UnarchiveSelectedItems>(_onUnarchiveSelectedItems);
|
||||
|
||||
on<_StartScaling>(_onStartScaling);
|
||||
on<_EndScaling>(_onEndScaling);
|
||||
on<_SetScale>(_onSetScale);
|
||||
|
||||
on<_SetError>(_onSetError);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
for (final s in _subscriptions) {
|
||||
s.cancel();
|
||||
}
|
||||
return super.close();
|
||||
}
|
||||
|
||||
@override
|
||||
String get tag => _log.fullName;
|
||||
|
||||
@override
|
||||
bool Function(dynamic, dynamic)? get shouldLog => (currentState, nextState) {
|
||||
currentState = currentState as _State;
|
||||
nextState = nextState as _State;
|
||||
return currentState.scale == nextState.scale &&
|
||||
currentState.visibleItems == nextState.visibleItems;
|
||||
};
|
||||
|
||||
@override
|
||||
void onError(Object error, StackTrace stackTrace) {
|
||||
// we need this to prevent onError being triggered recursively
|
||||
if (!isClosed && !_isHandlingError) {
|
||||
_isHandlingError = true;
|
||||
try {
|
||||
add(_SetError(error, stackTrace));
|
||||
} catch (_) {}
|
||||
_isHandlingError = false;
|
||||
}
|
||||
super.onError(error, stackTrace);
|
||||
}
|
||||
|
||||
Future<void> _onLoad(_LoadItems ev, Emitter<_State> emit) {
|
||||
_log.info(ev);
|
||||
return emit.forEach<FilesStreamEvent>(
|
||||
controller.stream,
|
||||
onData: (data) => state.copyWith(
|
||||
files: data.data,
|
||||
isLoading: data.hasNext || _itemTransformerQueue.isProcessing,
|
||||
),
|
||||
onError: (e, stackTrace) {
|
||||
_log.severe("[_onLoad] Uncaught exception", e, stackTrace);
|
||||
return state.copyWith(
|
||||
isLoading: _itemTransformerQueue.isProcessing,
|
||||
error: ExceptionEvent(e, stackTrace),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onTransformItems(_TransformItems ev, Emitter<_State> emit) {
|
||||
_log.info(ev);
|
||||
_transformItems(ev.items);
|
||||
emit(state.copyWith(isLoading: true));
|
||||
}
|
||||
|
||||
void _onOnItemTransformed(_OnItemTransformed ev, Emitter<_State> emit) {
|
||||
_log.info(ev);
|
||||
emit(state.copyWith(
|
||||
transformedItems: ev.items,
|
||||
isLoading: _itemTransformerQueue.isProcessing,
|
||||
));
|
||||
}
|
||||
|
||||
void _onSetSelectedItems(_SetSelectedItems ev, Emitter<_State> emit) {
|
||||
_log.info(ev);
|
||||
emit(state.copyWith(selectedItems: ev.items));
|
||||
}
|
||||
|
||||
void _onUnarchiveSelectedItems(
|
||||
_UnarchiveSelectedItems ev, Emitter<_State> emit) {
|
||||
_log.info(ev);
|
||||
final selected = state.selectedItems;
|
||||
_clearSelection(emit);
|
||||
final selectedFiles =
|
||||
selected.whereType<_FileItem>().map((e) => e.file).toList();
|
||||
if (selectedFiles.isNotEmpty) {
|
||||
controller.updateProperty(
|
||||
selectedFiles,
|
||||
isArchived: const OrNull(false),
|
||||
errorBuilder: (fileIds) => _UnarchiveFailedError(fileIds.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onStartScaling(_StartScaling ev, Emitter<_State> emit) {
|
||||
_log.info(ev);
|
||||
}
|
||||
|
||||
void _onEndScaling(_EndScaling ev, Emitter<_State> emit) {
|
||||
_log.info(ev);
|
||||
if (state.scale == null) {
|
||||
return;
|
||||
}
|
||||
final int newZoom;
|
||||
if (state.scale! >= 1.25) {
|
||||
// scale up
|
||||
newZoom = (state.zoom + 1).clamp(-1, 2);
|
||||
} else if (state.scale! <= 0.75) {
|
||||
newZoom = (state.zoom - 1).clamp(-1, 2);
|
||||
} else {
|
||||
newZoom = state.zoom;
|
||||
}
|
||||
emit(state.copyWith(
|
||||
zoom: newZoom,
|
||||
scale: null,
|
||||
));
|
||||
unawaited(prefController.setAlbumBrowserZoomLevel(newZoom));
|
||||
}
|
||||
|
||||
void _onSetScale(_SetScale ev, Emitter<_State> emit) {
|
||||
// _log.info(ev);
|
||||
emit(state.copyWith(scale: ev.scale));
|
||||
}
|
||||
|
||||
void _onSetError(_SetError ev, Emitter<_State> emit) {
|
||||
_log.info(ev);
|
||||
emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace)));
|
||||
}
|
||||
|
||||
Future _transformItems(List<FileDescriptor> files) async {
|
||||
_log.info("[_transformItems] Queue ${files.length} items");
|
||||
_itemTransformerQueue.addJob(
|
||||
_ItemTransformerArgument(
|
||||
account: account,
|
||||
files: files,
|
||||
),
|
||||
_buildItem,
|
||||
(result) {
|
||||
safeAdd(_OnItemTransformed(result.items));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _clearSelection(Emitter<_State> emit) {
|
||||
emit(state.copyWith(selectedItems: const {}));
|
||||
}
|
||||
|
||||
final Account account;
|
||||
final FilesController controller;
|
||||
final PrefController prefController;
|
||||
|
||||
final _itemTransformerQueue =
|
||||
ComputeQueue<_ItemTransformerArgument, _ItemTransformerResult>();
|
||||
final _subscriptions = <StreamSubscription>[];
|
||||
var _isHandlingError = false;
|
||||
}
|
||||
|
||||
@toString
|
||||
class _UnarchiveFailedError implements Exception {
|
||||
const _UnarchiveFailedError(this.count);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final int count;
|
||||
}
|
||||
|
||||
_ItemTransformerResult _buildItem(_ItemTransformerArgument arg) {
|
||||
final sortedFiles = arg.files
|
||||
.where((f) => f.fdIsArchived == true)
|
||||
.sorted(compareFileDescriptorDateTimeDescending);
|
||||
|
||||
final transformed = <_Item>[];
|
||||
for (int i = 0; i < sortedFiles.length; ++i) {
|
||||
final file = sortedFiles[i];
|
||||
final item = _buildSingleItem(arg.account, file);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
transformed.add(item);
|
||||
}
|
||||
return _ItemTransformerResult(items: transformed);
|
||||
}
|
||||
|
||||
_Item? _buildSingleItem(Account account, FileDescriptor file) {
|
||||
if (file_util.isSupportedImageFormat(file)) {
|
||||
return _PhotoItem(
|
||||
file: file,
|
||||
account: account,
|
||||
);
|
||||
} else if (file_util.isSupportedVideoFormat(file)) {
|
||||
return _VideoItem(
|
||||
file: file,
|
||||
account: account,
|
||||
);
|
||||
} else {
|
||||
_$__NpLog.log
|
||||
.shout("[_buildSingleItem] Unsupported file format: ${file.fdMime}");
|
||||
return null;
|
||||
}
|
||||
}
|
140
app/lib/widget/archive_browser/state_event.dart
Normal file
140
app/lib/widget/archive_browser/state_event.dart
Normal file
|
@ -0,0 +1,140 @@
|
|||
part of '../archive_browser.dart';
|
||||
|
||||
@genCopyWith
|
||||
@toString
|
||||
class _State {
|
||||
const _State({
|
||||
required this.files,
|
||||
required this.isLoading,
|
||||
required this.transformedItems,
|
||||
required this.selectedItems,
|
||||
required this.visibleItems,
|
||||
required this.zoom,
|
||||
this.scale,
|
||||
this.error,
|
||||
});
|
||||
|
||||
factory _State.init({
|
||||
required int zoom,
|
||||
}) =>
|
||||
_State(
|
||||
files: const [],
|
||||
isLoading: false,
|
||||
transformedItems: const [],
|
||||
selectedItems: const {},
|
||||
visibleItems: const {},
|
||||
zoom: zoom,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final List<FileDescriptor> files;
|
||||
final bool isLoading;
|
||||
final List<_Item> transformedItems;
|
||||
final Set<_Item> selectedItems;
|
||||
final Set<_VisibleItem> visibleItems;
|
||||
|
||||
final int zoom;
|
||||
final double? scale;
|
||||
|
||||
final ExceptionEvent? error;
|
||||
}
|
||||
|
||||
abstract class _Event {}
|
||||
|
||||
/// Load the files
|
||||
@toString
|
||||
class _LoadItems implements _Event {
|
||||
const _LoadItems();
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
}
|
||||
|
||||
@toString
|
||||
class _Reload implements _Event {
|
||||
const _Reload();
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
}
|
||||
|
||||
/// Transform the file list (e.g., filtering, sorting, etc)
|
||||
@toString
|
||||
class _TransformItems implements _Event {
|
||||
const _TransformItems(this.items);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final List<FileDescriptor> items;
|
||||
}
|
||||
|
||||
@toString
|
||||
class _OnItemTransformed implements _Event {
|
||||
const _OnItemTransformed(this.items);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final List<_Item> items;
|
||||
}
|
||||
|
||||
/// Set the currently selected items
|
||||
@toString
|
||||
class _SetSelectedItems implements _Event {
|
||||
const _SetSelectedItems({
|
||||
required this.items,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final Set<_Item> items;
|
||||
}
|
||||
|
||||
@toString
|
||||
class _UnarchiveSelectedItems implements _Event {
|
||||
const _UnarchiveSelectedItems();
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
}
|
||||
|
||||
@toString
|
||||
class _StartScaling implements _Event {
|
||||
const _StartScaling();
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
}
|
||||
|
||||
@toString
|
||||
class _EndScaling implements _Event {
|
||||
const _EndScaling();
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
}
|
||||
|
||||
@toString
|
||||
class _SetScale implements _Event {
|
||||
const _SetScale(this.scale);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final double scale;
|
||||
}
|
||||
|
||||
@toString
|
||||
class _SetError implements _Event {
|
||||
const _SetError(this.error, [this.stackTrace]);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final Object error;
|
||||
final StackTrace? stackTrace;
|
||||
}
|
109
app/lib/widget/archive_browser/type.dart
Normal file
109
app/lib/widget/archive_browser/type.dart
Normal file
|
@ -0,0 +1,109 @@
|
|||
part of '../archive_browser.dart';
|
||||
|
||||
abstract class _Item implements SelectableItemMetadata {
|
||||
const _Item();
|
||||
|
||||
StaggeredTile get staggeredTile;
|
||||
|
||||
Widget buildWidget(BuildContext context);
|
||||
}
|
||||
|
||||
abstract class _FileItem extends _Item {
|
||||
const _FileItem({
|
||||
required this.file,
|
||||
});
|
||||
|
||||
@override
|
||||
bool get isSelectable => true;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is _FileItem && file.compareServerIdentity(other.file));
|
||||
|
||||
@override
|
||||
int get hashCode => file.identityHashCode;
|
||||
|
||||
final FileDescriptor file;
|
||||
}
|
||||
|
||||
class _PhotoItem extends _FileItem {
|
||||
_PhotoItem({
|
||||
required super.file,
|
||||
required this.account,
|
||||
}) : _previewUrl = NetworkRectThumbnail.imageUrlForFile(account, file);
|
||||
|
||||
@override
|
||||
StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1);
|
||||
|
||||
@override
|
||||
Widget buildWidget(BuildContext context) {
|
||||
return PhotoListImage(
|
||||
account: account,
|
||||
previewUrl: _previewUrl,
|
||||
isGif: file.fdMime == "image/gif",
|
||||
isFavorite: file.fdIsFavorite,
|
||||
heroKey: flutter_util.getImageHeroTag(file),
|
||||
);
|
||||
}
|
||||
|
||||
final Account account;
|
||||
final String _previewUrl;
|
||||
}
|
||||
|
||||
class _VideoItem extends _FileItem {
|
||||
_VideoItem({
|
||||
required super.file,
|
||||
required this.account,
|
||||
}) : _previewUrl = NetworkRectThumbnail.imageUrlForFile(account, file);
|
||||
|
||||
@override
|
||||
StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1);
|
||||
|
||||
@override
|
||||
Widget buildWidget(BuildContext context) {
|
||||
return PhotoListVideo(
|
||||
account: account,
|
||||
previewUrl: _previewUrl,
|
||||
isFavorite: file.fdIsFavorite,
|
||||
);
|
||||
}
|
||||
|
||||
final Account account;
|
||||
final String _previewUrl;
|
||||
}
|
||||
|
||||
class _VisibleItem implements Comparable<_VisibleItem> {
|
||||
const _VisibleItem(this.index, this.item);
|
||||
|
||||
@override
|
||||
bool operator ==(Object? other) =>
|
||||
other is _VisibleItem && index == other.index;
|
||||
|
||||
@override
|
||||
int compareTo(_VisibleItem other) => index.compareTo(other.index);
|
||||
|
||||
@override
|
||||
int get hashCode => index.hashCode;
|
||||
|
||||
final int index;
|
||||
final _Item item;
|
||||
}
|
||||
|
||||
class _ItemTransformerArgument {
|
||||
const _ItemTransformerArgument({
|
||||
required this.account,
|
||||
required this.files,
|
||||
});
|
||||
|
||||
final Account account;
|
||||
final List<FileDescriptor> files;
|
||||
}
|
||||
|
||||
class _ItemTransformerResult {
|
||||
const _ItemTransformerResult({
|
||||
required this.items,
|
||||
});
|
||||
|
||||
final List<_Item> items;
|
||||
}
|
90
app/lib/widget/archive_browser/view.dart
Normal file
90
app/lib/widget/archive_browser/view.dart
Normal file
|
@ -0,0 +1,90 @@
|
|||
part of '../archive_browser.dart';
|
||||
|
||||
class _ContentList extends StatelessWidget {
|
||||
const _ContentList();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _BlocSelector<int>(
|
||||
selector: (state) => state.zoom,
|
||||
builder: (context, zoom) => _ContentListBody(
|
||||
maxCrossAxisExtent: photo_list_util.getThumbSize(zoom).toDouble(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScalingList extends StatelessWidget {
|
||||
const _ScalingList();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _BlocBuilder(
|
||||
buildWhen: (previous, current) => previous.scale != current.scale,
|
||||
builder: (context, state) {
|
||||
if (state.scale == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
int nextZoom;
|
||||
if (state.scale! > 1) {
|
||||
nextZoom = state.zoom + 1;
|
||||
} else {
|
||||
nextZoom = state.zoom - 1;
|
||||
}
|
||||
nextZoom = nextZoom.clamp(-1, 2);
|
||||
return _ContentListBody(
|
||||
maxCrossAxisExtent: photo_list_util.getThumbSize(nextZoom).toDouble(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@npLog
|
||||
class _ContentListBody extends StatelessWidget {
|
||||
const _ContentListBody({
|
||||
required this.maxCrossAxisExtent,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _BlocBuilder(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.transformedItems != current.transformedItems ||
|
||||
previous.selectedItems != current.selectedItems,
|
||||
builder: (context, state) => SelectableItemList<_Item>(
|
||||
maxCrossAxisExtent: maxCrossAxisExtent,
|
||||
items: state.transformedItems,
|
||||
itemBuilder: (context, _, item) => item.buildWidget(context),
|
||||
staggeredTileBuilder: (_, item) => item.staggeredTile,
|
||||
selectedItems: state.selectedItems,
|
||||
onSelectionChange: (_, selected) {
|
||||
context.addEvent(_SetSelectedItems(items: selected.cast()));
|
||||
},
|
||||
onItemTap: (context, index, _) {
|
||||
if (state.transformedItems[index] is! _FileItem) {
|
||||
return;
|
||||
}
|
||||
final actualIndex = index -
|
||||
state.transformedItems
|
||||
.sublist(0, index)
|
||||
.where((e) => e is! _FileItem)
|
||||
.length;
|
||||
Navigator.of(context).pushNamed(
|
||||
Viewer.routeName,
|
||||
arguments: ViewerArguments(
|
||||
context.bloc.account,
|
||||
state.transformedItems
|
||||
.whereType<_FileItem>()
|
||||
.map((e) => e.file)
|
||||
.toList(),
|
||||
actualIndex,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final double maxCrossAxisExtent;
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:bloc_concurrency/bloc_concurrency.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
@ -19,8 +20,8 @@ import 'package:nc_photos/cache_manager_util.dart';
|
|||
import 'package:nc_photos/controller/account_controller.dart';
|
||||
import 'package:nc_photos/controller/collection_items_controller.dart';
|
||||
import 'package:nc_photos/controller/collections_controller.dart';
|
||||
import 'package:nc_photos/controller/files_controller.dart';
|
||||
import 'package:nc_photos/controller/pref_controller.dart';
|
||||
import 'package:nc_photos/debug_util.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/download_handler.dart';
|
||||
import 'package:nc_photos/entity/collection.dart';
|
||||
|
@ -41,9 +42,6 @@ import 'package:nc_photos/np_api_util.dart';
|
|||
import 'package:nc_photos/object_extension.dart';
|
||||
import 'package:nc_photos/session_storage.dart';
|
||||
import 'package:nc_photos/snack_bar_manager.dart';
|
||||
import 'package:nc_photos/use_case/archive_file.dart';
|
||||
import 'package:nc_photos/use_case/inflate_file_descriptor.dart';
|
||||
import 'package:nc_photos/use_case/remove.dart';
|
||||
import 'package:nc_photos/widget/album_share_outlier_browser.dart';
|
||||
import 'package:nc_photos/widget/collection_picker.dart';
|
||||
import 'package:nc_photos/widget/draggable_item_list.dart';
|
||||
|
@ -103,13 +101,14 @@ class CollectionBrowser extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accountController = context.read<AccountController>();
|
||||
return BlocProvider(
|
||||
create: (_) => _Bloc(
|
||||
container: KiwiContainer().resolve(),
|
||||
account: context.read<AccountController>().account,
|
||||
account: accountController.account,
|
||||
prefController: context.read(),
|
||||
collectionsController:
|
||||
context.read<AccountController>().collectionsController,
|
||||
collectionsController: accountController.collectionsController,
|
||||
filesController: accountController.filesController,
|
||||
collection: collection,
|
||||
),
|
||||
child: const _WrappedCollectionBrowser(),
|
||||
|
@ -211,14 +210,22 @@ class _WrappedCollectionBrowserState extends State<_WrappedCollectionBrowser>
|
|||
}
|
||||
},
|
||||
),
|
||||
_BlocListener(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.error != current.error,
|
||||
listener: (context, state) {
|
||||
if (state.error != null && isPageVisible()) {
|
||||
_BlocListenerT<ExceptionEvent?>(
|
||||
selector: (state) => state.error,
|
||||
listener: (context, error) {
|
||||
if (error != null && isPageVisible()) {
|
||||
final String content;
|
||||
if (error.error is _ArchiveFailedError) {
|
||||
content = L10n.global().archiveSelectedFailureNotification(
|
||||
(error.error as _ArchiveFailedError).count);
|
||||
} else if (error.error is _RemoveFailedError) {
|
||||
content = L10n.global().deleteSelectedFailureNotification(
|
||||
(error.error as _RemoveFailedError).count);
|
||||
} else {
|
||||
content = exception_util.toUserString(error.error);
|
||||
}
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content:
|
||||
Text(exception_util.toUserString(state.error!.error)),
|
||||
content: Text(content),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
}
|
||||
|
@ -395,6 +402,7 @@ class _WrappedCollectionBrowserState extends State<_WrappedCollectionBrowser>
|
|||
|
||||
typedef _BlocBuilder = BlocBuilder<_Bloc, _State>;
|
||||
typedef _BlocListener = BlocListener<_Bloc, _State>;
|
||||
typedef _BlocListenerT<T> = BlocListenerT<_Bloc, _State, T>;
|
||||
// typedef _BlocSelector<T> = BlocSelector<_Bloc, _State, T>;
|
||||
|
||||
extension on BuildContext {
|
||||
|
|
|
@ -17,6 +17,8 @@ abstract class $_StateCopyWithWorker {
|
|||
{Collection? collection,
|
||||
String? coverUrl,
|
||||
List<CollectionItem>? items,
|
||||
List<CollectionItem>? rawItems,
|
||||
Set<int>? itemsWhitelist,
|
||||
bool? isLoading,
|
||||
List<_Item>? transformedItems,
|
||||
Set<_Item>? selectedItems,
|
||||
|
@ -45,6 +47,8 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker {
|
|||
{dynamic collection,
|
||||
dynamic coverUrl = copyWithNull,
|
||||
dynamic items,
|
||||
dynamic rawItems,
|
||||
dynamic itemsWhitelist = copyWithNull,
|
||||
dynamic isLoading,
|
||||
dynamic transformedItems,
|
||||
dynamic selectedItems,
|
||||
|
@ -68,6 +72,10 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker {
|
|||
coverUrl:
|
||||
coverUrl == copyWithNull ? that.coverUrl : coverUrl as String?,
|
||||
items: items as List<CollectionItem>? ?? that.items,
|
||||
rawItems: rawItems as List<CollectionItem>? ?? that.rawItems,
|
||||
itemsWhitelist: itemsWhitelist == copyWithNull
|
||||
? that.itemsWhitelist
|
||||
: itemsWhitelist as Set<int>?,
|
||||
isLoading: isLoading as bool? ?? that.isLoading,
|
||||
transformedItems:
|
||||
transformedItems as List<_Item>? ?? that.transformedItems,
|
||||
|
@ -143,7 +151,7 @@ extension _$_BlocNpLog on _Bloc {
|
|||
extension _$_StateToString on _State {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_State {collection: $collection, coverUrl: $coverUrl, items: [length: ${items.length}], isLoading: $isLoading, transformedItems: [length: ${transformedItems.length}], selectedItems: {length: ${selectedItems.length}}, isSelectionRemovable: $isSelectionRemovable, isSelectionManageableFile: $isSelectionManageableFile, isSelectionDeletable: $isSelectionDeletable, isEditMode: $isEditMode, isEditBusy: $isEditBusy, editName: $editName, editItems: ${editItems == null ? null : "[length: ${editItems!.length}]"}, editTransformedItems: ${editTransformedItems == null ? null : "[length: ${editTransformedItems!.length}]"}, editSort: ${editSort == null ? null : "${editSort!.name}"}, isDragging: $isDragging, zoom: $zoom, scale: ${scale == null ? null : "${scale!.toStringAsFixed(3)}"}, importResult: $importResult, error: $error, message: $message}";
|
||||
return "_State {collection: $collection, coverUrl: $coverUrl, items: [length: ${items.length}], rawItems: [length: ${rawItems.length}], itemsWhitelist: ${itemsWhitelist == null ? null : "{length: ${itemsWhitelist!.length}}"}, isLoading: $isLoading, transformedItems: [length: ${transformedItems.length}], selectedItems: {length: ${selectedItems.length}}, isSelectionRemovable: $isSelectionRemovable, isSelectionManageableFile: $isSelectionManageableFile, isSelectionDeletable: $isSelectionDeletable, isEditMode: $isEditMode, isEditBusy: $isEditBusy, editName: $editName, editItems: ${editItems == null ? null : "[length: ${editItems!.length}]"}, editTransformedItems: ${editTransformedItems == null ? null : "[length: ${editTransformedItems!.length}]"}, editSort: ${editSort == null ? null : "${editSort!.name}"}, isDragging: $isDragging, zoom: $zoom, scale: ${scale == null ? null : "${scale!.toStringAsFixed(3)}"}, importResult: $importResult, error: $error, message: $message}";
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -338,3 +346,17 @@ extension _$_SetMessageToString on _SetMessage {
|
|||
return "_SetMessage {message: $message}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_ArchiveFailedErrorToString on _ArchiveFailedError {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_ArchiveFailedError {count: $count}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_RemoveFailedErrorToString on _RemoveFailedError {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_RemoveFailedError {count: $count}";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
|
|||
required this.account,
|
||||
required this.prefController,
|
||||
required this.collectionsController,
|
||||
required this.filesController,
|
||||
required Collection collection,
|
||||
}) : _c = container,
|
||||
_isAdHocCollection = !collectionsController.stream.value.data
|
||||
|
@ -14,7 +15,7 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
|
|||
super(_State.init(
|
||||
collection: collection,
|
||||
coverUrl: _getCoverUrl(collection),
|
||||
zoom: prefController.albumBrowserZoomLevel.value,
|
||||
zoom: prefController.albumBrowserZoomLevelValue,
|
||||
)) {
|
||||
_initItemController(collection);
|
||||
|
||||
|
@ -109,20 +110,40 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
|
|||
|
||||
Future<void> _onLoad(_LoadItems ev, Emitter<_State> emit) async {
|
||||
_log.info(ev);
|
||||
return emit.forEach<CollectionItemStreamData>(
|
||||
itemsController.stream,
|
||||
onData: (data) => state.copyWith(
|
||||
items: data.items,
|
||||
isLoading: data.hasNext,
|
||||
await Future.wait([
|
||||
emit.forEach<CollectionItemStreamData>(
|
||||
itemsController.stream,
|
||||
onData: (data) => state.copyWith(
|
||||
items: _filterItems(data.items, state.itemsWhitelist),
|
||||
rawItems: data.items,
|
||||
isLoading: data.hasNext,
|
||||
),
|
||||
onError: (e, stackTrace) {
|
||||
_log.severe("[_onLoad] Uncaught exception", e, stackTrace);
|
||||
return state.copyWith(
|
||||
isLoading: false,
|
||||
error: ExceptionEvent(e, stackTrace),
|
||||
);
|
||||
},
|
||||
),
|
||||
onError: (e, stackTrace) {
|
||||
_log.severe("[_onLoad] Uncaught exception", e, stackTrace);
|
||||
return state.copyWith(
|
||||
isLoading: false,
|
||||
error: ExceptionEvent(e, stackTrace),
|
||||
);
|
||||
},
|
||||
);
|
||||
emit.forEach<FilesStreamEvent>(
|
||||
filesController.stream,
|
||||
onData: (data) {
|
||||
final whitelist = HashSet.of(data.dataMap.keys);
|
||||
return state.copyWith(
|
||||
items: _filterItems(state.rawItems, whitelist),
|
||||
itemsWhitelist: whitelist,
|
||||
);
|
||||
},
|
||||
onError: (e, stackTrace) {
|
||||
_log.severe("[_onLoad] Uncaught exception", e, stackTrace);
|
||||
return state.copyWith(
|
||||
isLoading: false,
|
||||
error: ExceptionEvent(e, stackTrace),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
void _onTransformItems(_TransformItems ev, Emitter<_State> emit) {
|
||||
|
@ -306,23 +327,18 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _onArchiveSelectedItems(
|
||||
_ArchiveSelectedItems ev, Emitter<_State> emit) async {
|
||||
void _onArchiveSelectedItems(_ArchiveSelectedItems ev, Emitter<_State> emit) {
|
||||
_log.info(ev);
|
||||
final selected = state.selectedItems;
|
||||
_clearSelection(emit);
|
||||
final selectedFds =
|
||||
final selectedFiles =
|
||||
selected.whereType<_FileItem>().map((e) => e.file).toList();
|
||||
if (selectedFds.isNotEmpty) {
|
||||
final selectedFiles =
|
||||
await InflateFileDescriptor(_c)(account, selectedFds);
|
||||
final count = await ArchiveFile(_c)(account, selectedFiles);
|
||||
if (count != selectedFiles.length) {
|
||||
emit(state.copyWith(
|
||||
message: L10n.global()
|
||||
.archiveSelectedFailureNotification(selectedFiles.length - count),
|
||||
));
|
||||
}
|
||||
if (selectedFiles.isNotEmpty) {
|
||||
filesController.updateProperty(
|
||||
selectedFiles,
|
||||
isArchived: const OrNull(true),
|
||||
errorBuilder: (fileIds) => _ArchiveFailedError(fileIds.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -332,29 +348,25 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
|
|||
final selected = state.selectedItems;
|
||||
_clearSelection(emit);
|
||||
final adapter = CollectionAdapter.of(_c, account, state.collection);
|
||||
final selectedItems = selected
|
||||
.whereType<_ActualItem>()
|
||||
.map((e) => e.original)
|
||||
.where(adapter.isItemRemovable)
|
||||
.toList();
|
||||
final selectedFiles = selected
|
||||
.whereType<_FileItem>()
|
||||
.where((e) => adapter.isItemDeletable(e.original))
|
||||
.map((e) => e.file)
|
||||
.toList();
|
||||
if (selectedFiles.isNotEmpty) {
|
||||
final count = await Remove(_c)(
|
||||
account,
|
||||
await filesController.remove(
|
||||
selectedFiles,
|
||||
onError: (_, f, e, stackTrace) {
|
||||
_log.severe(
|
||||
"[_onDeleteSelectedItems] Failed while Remove: ${logFilename(f.strippedPath)}",
|
||||
e,
|
||||
stackTrace,
|
||||
);
|
||||
errorBuilder: (fileIds) {
|
||||
return _RemoveFailedError(fileIds.length);
|
||||
},
|
||||
);
|
||||
if (count != selectedFiles.length) {
|
||||
emit(state.copyWith(
|
||||
message: L10n.global()
|
||||
.deleteSelectedFailureNotification(selectedFiles.length - count),
|
||||
));
|
||||
}
|
||||
// deleting files will also remove them from the collection
|
||||
unawaited(itemsController.removeItems(selectedItems));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -413,6 +425,7 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
|
|||
);
|
||||
itemsController = CollectionItemsController(
|
||||
_c,
|
||||
filesController: filesController,
|
||||
account: account,
|
||||
collection: collection,
|
||||
onCollectionUpdated: (_) {},
|
||||
|
@ -488,6 +501,26 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
|
|||
);
|
||||
}
|
||||
|
||||
/// Remove file items not recognized by the app
|
||||
List<CollectionItem> _filterItems(
|
||||
List<CollectionItem> rawItems, Set<int>? whitelist) {
|
||||
if (whitelist == null) {
|
||||
return rawItems;
|
||||
}
|
||||
final results = rawItems.where((e) {
|
||||
if (e is CollectionFileItem) {
|
||||
return whitelist.contains(e.file.fdId);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}).toList();
|
||||
if (rawItems.length != results.length) {
|
||||
_log.fine(
|
||||
"[_filterItems] ${rawItems.length - results.length} items filtered out");
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
String? _getCoverUrlByItems() {
|
||||
try {
|
||||
final firstFile =
|
||||
|
@ -524,6 +557,7 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
|
|||
final Account account;
|
||||
final PrefController prefController;
|
||||
final CollectionsController collectionsController;
|
||||
final FilesController filesController;
|
||||
late final CollectionItemsController itemsController;
|
||||
|
||||
/// Specify if the supplied [collection] is an "inline" one, which means it's
|
||||
|
|
|
@ -7,6 +7,8 @@ class _State {
|
|||
required this.collection,
|
||||
this.coverUrl,
|
||||
required this.items,
|
||||
required this.rawItems,
|
||||
this.itemsWhitelist,
|
||||
required this.isLoading,
|
||||
required this.transformedItems,
|
||||
required this.selectedItems,
|
||||
|
@ -36,6 +38,7 @@ class _State {
|
|||
collection: collection,
|
||||
coverUrl: coverUrl,
|
||||
items: const [],
|
||||
rawItems: const [],
|
||||
isLoading: false,
|
||||
transformedItems: const [],
|
||||
selectedItems: const {},
|
||||
|
@ -57,6 +60,8 @@ class _State {
|
|||
final Collection collection;
|
||||
final String? coverUrl;
|
||||
final List<CollectionItem> items;
|
||||
final List<CollectionItem> rawItems;
|
||||
final Set<int>? itemsWhitelist;
|
||||
final bool isLoading;
|
||||
final List<_Item> transformedItems;
|
||||
|
||||
|
|
|
@ -174,3 +174,23 @@ class _DateItem extends _Item {
|
|||
|
||||
final DateTime date;
|
||||
}
|
||||
|
||||
@toString
|
||||
class _ArchiveFailedError implements Exception {
|
||||
const _ArchiveFailedError(this.count);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final int count;
|
||||
}
|
||||
|
||||
@toString
|
||||
class _RemoveFailedError implements Exception {
|
||||
const _RemoveFailedError(this.count);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final int count;
|
||||
}
|
||||
|
|
|
@ -102,8 +102,8 @@ class _EditContentList extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<int>(
|
||||
stream: context.read<PrefController>().albumBrowserZoomLevel,
|
||||
initialData: context.read<PrefController>().albumBrowserZoomLevel.value,
|
||||
stream: context.read<PrefController>().albumBrowserZoomLevelChange,
|
||||
initialData: context.read<PrefController>().albumBrowserZoomLevelValue,
|
||||
builder: (_, zoomLevel) {
|
||||
if (zoomLevel.hasError) {
|
||||
context.addEvent(
|
||||
|
@ -159,9 +159,9 @@ class _UnmodifiableEditContentList extends StatelessWidget {
|
|||
sliver: SliverOpacity(
|
||||
opacity: .25,
|
||||
sliver: StreamBuilder<int>(
|
||||
stream: context.read<PrefController>().albumBrowserZoomLevel,
|
||||
stream: context.read<PrefController>().albumBrowserZoomLevelChange,
|
||||
initialData:
|
||||
context.read<PrefController>().albumBrowserZoomLevel.value,
|
||||
context.read<PrefController>().albumBrowserZoomLevelValue,
|
||||
builder: (_, zoomLevel) {
|
||||
if (zoomLevel.hasError) {
|
||||
context.addEvent(_SetMessage(
|
||||
|
|
|
@ -30,7 +30,7 @@ class ArchiveSelectionHandler {
|
|||
return NotifiedListAction<File>(
|
||||
list: selectedFiles,
|
||||
action: (file) async {
|
||||
await UpdateProperty(_c.fileRepo).updateIsArchived(account, file, true);
|
||||
await UpdateProperty(_c).updateIsArchived(account, file, true);
|
||||
},
|
||||
processingText: shouldShowProcessingText
|
||||
? L10n.global()
|
||||
|
|
|
@ -1,16 +1,12 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/app_localizations.dart';
|
||||
import 'package:nc_photos/debug_util.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/controller/files_controller.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:nc_photos/k.dart' as k;
|
||||
import 'package:nc_photos/navigation_manager.dart';
|
||||
import 'package:nc_photos/snack_bar_manager.dart';
|
||||
import 'package:nc_photos/use_case/inflate_file_descriptor.dart';
|
||||
import 'package:nc_photos/use_case/remove.dart';
|
||||
import 'package:nc_photos/widget/trashbin_browser.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
|
||||
|
@ -18,11 +14,9 @@ part 'remove_selection_handler.g.dart';
|
|||
|
||||
@npLog
|
||||
class RemoveSelectionHandler {
|
||||
RemoveSelectionHandler(this._c)
|
||||
: assert(require(_c)),
|
||||
assert(InflateFileDescriptor.require(_c));
|
||||
|
||||
static bool require(DiContainer c) => true;
|
||||
const RemoveSelectionHandler({
|
||||
required this.filesController,
|
||||
});
|
||||
|
||||
/// Remove [selectedFiles] and return the removed count
|
||||
Future<int> call({
|
||||
|
@ -31,44 +25,25 @@ class RemoveSelectionHandler {
|
|||
bool shouldCleanupAlbum = true,
|
||||
bool isRemoveOpened = false,
|
||||
bool isMoveToTrash = false,
|
||||
bool shouldShowProcessingText = true,
|
||||
}) async {
|
||||
final selectedFiles = await InflateFileDescriptor(_c)(account, selection);
|
||||
final String processingText, successText;
|
||||
final String successText;
|
||||
final String Function(int) failureText;
|
||||
if (isRemoveOpened) {
|
||||
processingText = L10n.global().deleteProcessingNotification;
|
||||
successText = L10n.global().deleteSuccessNotification;
|
||||
failureText = (_) => L10n.global().deleteFailureNotification;
|
||||
} else {
|
||||
processingText = L10n.global()
|
||||
.deleteSelectedProcessingNotification(selectedFiles.length);
|
||||
successText = L10n.global().deleteSelectedSuccessNotification;
|
||||
failureText =
|
||||
(count) => L10n.global().deleteSelectedFailureNotification(count);
|
||||
}
|
||||
if (shouldShowProcessingText) {
|
||||
SnackBarManager().showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(processingText),
|
||||
duration: k.snackBarDurationShort,
|
||||
),
|
||||
canBeReplaced: true,
|
||||
);
|
||||
}
|
||||
|
||||
var failureCount = 0;
|
||||
await Remove(KiwiContainer().resolve<DiContainer>())(
|
||||
account,
|
||||
selectedFiles,
|
||||
onError: (_, file, e, stackTrace) {
|
||||
_log.shout(
|
||||
"[call] Failed while removing file: ${logFilename(file.fdPath)}",
|
||||
e,
|
||||
stackTrace);
|
||||
++failureCount;
|
||||
await filesController.remove(
|
||||
selection,
|
||||
errorBuilder: (fileIds) {
|
||||
failureCount = fileIds.length;
|
||||
return RemoveFailureError(fileIds);
|
||||
},
|
||||
shouldCleanUp: shouldCleanupAlbum,
|
||||
);
|
||||
final trashAction = isMoveToTrash
|
||||
? SnackBarAction(
|
||||
|
@ -93,8 +68,8 @@ class RemoveSelectionHandler {
|
|||
action: trashAction,
|
||||
));
|
||||
}
|
||||
return selectedFiles.length - failureCount;
|
||||
return selection.length - failureCount;
|
||||
}
|
||||
|
||||
final DiContainer _c;
|
||||
final FilesController filesController;
|
||||
}
|
||||
|
|
|
@ -30,8 +30,7 @@ class UnarchiveSelectionHandler {
|
|||
return await NotifiedListAction<File>(
|
||||
list: selectedFiles,
|
||||
action: (file) async {
|
||||
await UpdateProperty(_c.fileRepo)
|
||||
.updateIsArchived(account, file, false);
|
||||
await UpdateProperty(_c).updateIsArchived(account, file, false);
|
||||
},
|
||||
processingText: shouldShowProcessingText
|
||||
? L10n.global()
|
||||
|
|
|
@ -8,6 +8,7 @@ import 'package:logging/logging.dart';
|
|||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/app_localizations.dart';
|
||||
import 'package:nc_photos/controller/account_controller.dart';
|
||||
import 'package:nc_photos/controller/account_pref_controller.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/album.dart';
|
||||
import 'package:nc_photos/entity/album/data_source.dart';
|
||||
|
@ -19,7 +20,7 @@ import 'package:nc_photos/theme.dart';
|
|||
import 'package:nc_photos/theme/dimension.dart';
|
||||
import 'package:nc_photos/use_case/import_potential_shared_album.dart';
|
||||
import 'package:nc_photos/widget/home_collections.dart';
|
||||
import 'package:nc_photos/widget/home_photos.dart';
|
||||
import 'package:nc_photos/widget/home_photos2.dart';
|
||||
import 'package:nc_photos/widget/home_search.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_common/or_null.dart';
|
||||
|
@ -141,9 +142,7 @@ class _HomeState extends State<Home> with TickerProviderStateMixin {
|
|||
Widget _buildPage(BuildContext context, int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return HomePhotos(
|
||||
account: widget.account,
|
||||
);
|
||||
return const HomePhotos2();
|
||||
|
||||
case 1:
|
||||
return HomeSearch(
|
||||
|
@ -163,7 +162,7 @@ class _HomeState extends State<Home> with TickerProviderStateMixin {
|
|||
if (index == 0) {
|
||||
KiwiContainer()
|
||||
.resolve<EventBus>()
|
||||
.fire(const HomePhotosBackToTopEvent());
|
||||
.fire(const HomePhotos2BackToTopEvent());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
@ -189,8 +188,7 @@ class _HomeState extends State<Home> with TickerProviderStateMixin {
|
|||
context
|
||||
.read<AccountController>()
|
||||
.accountPrefController
|
||||
.shareFolder
|
||||
.value,
|
||||
.shareFolderValue,
|
||||
);
|
||||
} catch (e, stacktrace) {
|
||||
_log.shout(
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||
import 'package:nc_photos/controller/account_controller.dart';
|
||||
import 'package:nc_photos/controller/account_pref_controller.dart';
|
||||
import 'package:nc_photos/stream_util.dart';
|
||||
import 'package:nc_photos/theme.dart';
|
||||
import 'package:nc_photos/widget/account_picker_dialog.dart';
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue