mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-03-25 08:24:43 +01:00
Merge branch 'feat-favorites' into dev
This commit is contained in:
commit
73980ddf3f
31 changed files with 1650 additions and 9 deletions
|
@ -398,6 +398,44 @@ class _Files {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Response> report({
|
||||||
|
required String path,
|
||||||
|
bool? favorite,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final namespaces = <String, String>{
|
||||||
|
"DAV:": "d",
|
||||||
|
"http://owncloud.org/ns": "oc",
|
||||||
|
};
|
||||||
|
final builder = XmlBuilder();
|
||||||
|
builder
|
||||||
|
..processing("xml", "version=\"1.0\"")
|
||||||
|
..element("oc:filter-files", namespaces: namespaces, nest: () {
|
||||||
|
builder.element("oc:filter-rules", nest: () {
|
||||||
|
if (favorite != null) {
|
||||||
|
builder.element("oc:favorite", nest: () {
|
||||||
|
builder.text(favorite ? "1" : "0");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
builder.element("d:prop", nest: () {
|
||||||
|
builder.element("oc:fileid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return await _api.request(
|
||||||
|
"REPORT",
|
||||||
|
path,
|
||||||
|
header: {
|
||||||
|
"Content-Type": "application/xml",
|
||||||
|
},
|
||||||
|
body: builder.buildDocument().toXmlString(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[report] Failed while report", e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static final _log = Logger("api.api._Files");
|
static final _log = Logger("api.api._Files");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,7 @@ import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
class AppDb {
|
class AppDb {
|
||||||
static const dbName = "app.db";
|
static const dbName = "app.db";
|
||||||
static const dbVersion = 5;
|
static const dbVersion = 6;
|
||||||
static const albumStoreName = "albums";
|
static const albumStoreName = "albums";
|
||||||
static const file2StoreName = "files2";
|
static const file2StoreName = "files2";
|
||||||
static const dirStoreName = "dirs";
|
static const dirStoreName = "dirs";
|
||||||
|
@ -193,6 +193,10 @@ class AppDb {
|
||||||
metaStore =
|
metaStore =
|
||||||
db.createObjectStore(metaStoreName, keyPath: AppDbMetaEntry.keyPath);
|
db.createObjectStore(metaStoreName, keyPath: AppDbMetaEntry.keyPath);
|
||||||
}
|
}
|
||||||
|
if (event.oldVersion < 6) {
|
||||||
|
file2Store.createIndex(AppDbFile2Entry.fileIsFavoriteIndexName,
|
||||||
|
AppDbFile2Entry.fileIsFavoriteKeyPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onPostUpgrade(
|
Future<void> _onPostUpgrade(
|
||||||
|
@ -264,6 +268,9 @@ class AppDbFile2Entry with EquatableMixin {
|
||||||
static const dateTimeEpochMsIndexName = "server_userId_dateTimeEpochMs";
|
static const dateTimeEpochMsIndexName = "server_userId_dateTimeEpochMs";
|
||||||
static const dateTimeEpochMsKeyPath = ["server", "userId", "dateTimeEpochMs"];
|
static const dateTimeEpochMsKeyPath = ["server", "userId", "dateTimeEpochMs"];
|
||||||
|
|
||||||
|
static const fileIsFavoriteIndexName = "server_userId_fileIsFavorite";
|
||||||
|
static const fileIsFavoriteKeyPath = ["server", "userId", "file.isFavorite"];
|
||||||
|
|
||||||
AppDbFile2Entry(this.server, this.userId, this.strippedPath,
|
AppDbFile2Entry(this.server, this.userId, this.strippedPath,
|
||||||
this.dateTimeEpochMs, this.file);
|
this.dateTimeEpochMs, this.file);
|
||||||
|
|
||||||
|
@ -332,6 +339,14 @@ class AppDbFile2Entry with EquatableMixin {
|
||||||
epochMs,
|
epochMs,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
static List<Object> toFileIsFavoriteIndexKey(
|
||||||
|
Account account, bool isFavorite) =>
|
||||||
|
[
|
||||||
|
account.url,
|
||||||
|
account.username.toCaseInsensitiveString(),
|
||||||
|
isFavorite ? 1 : 0,
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
get props => [
|
get props => [
|
||||||
server,
|
server,
|
||||||
|
|
141
lib/bloc/list_favorite.dart
Normal file
141
lib/bloc/list_favorite.dart
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
import 'package:bloc/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/di_container.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/use_case/cache_favorite.dart';
|
||||||
|
import 'package:nc_photos/use_case/list_favorite.dart';
|
||||||
|
import 'package:nc_photos/use_case/list_favorite_offline.dart';
|
||||||
|
|
||||||
|
abstract class ListFavoriteBlocEvent {
|
||||||
|
const ListFavoriteBlocEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListFavoriteBlocQuery extends ListFavoriteBlocEvent {
|
||||||
|
const ListFavoriteBlocQuery(this.account);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() => "$runtimeType {"
|
||||||
|
"account: $account, "
|
||||||
|
"}";
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class ListFavoriteBlocState {
|
||||||
|
const ListFavoriteBlocState(this.account, this.items);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() => "$runtimeType {"
|
||||||
|
"account: $account, "
|
||||||
|
"items: List {length: ${items.length}}, "
|
||||||
|
"}";
|
||||||
|
|
||||||
|
final Account? account;
|
||||||
|
final List<File> items;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListFavoriteBlocInit extends ListFavoriteBlocState {
|
||||||
|
const ListFavoriteBlocInit() : super(null, const []);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListFavoriteBlocLoading extends ListFavoriteBlocState {
|
||||||
|
const ListFavoriteBlocLoading(Account? account, List<File> items)
|
||||||
|
: super(account, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListFavoriteBlocSuccess extends ListFavoriteBlocState {
|
||||||
|
const ListFavoriteBlocSuccess(Account? account, List<File> items)
|
||||||
|
: super(account, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListFavoriteBlocFailure extends ListFavoriteBlocState {
|
||||||
|
const ListFavoriteBlocFailure(
|
||||||
|
Account? account, List<File> items, this.exception)
|
||||||
|
: super(account, items);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() => "$runtimeType {"
|
||||||
|
"super: ${super.toString()}, "
|
||||||
|
"exception: $exception, "
|
||||||
|
"}";
|
||||||
|
|
||||||
|
final dynamic exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all favorites for this account
|
||||||
|
class ListFavoriteBloc
|
||||||
|
extends Bloc<ListFavoriteBlocEvent, ListFavoriteBlocState> {
|
||||||
|
ListFavoriteBloc(this._c)
|
||||||
|
: assert(require(_c)),
|
||||||
|
assert(ListFavorite.require(_c)),
|
||||||
|
super(const ListFavoriteBlocInit());
|
||||||
|
|
||||||
|
static bool require(DiContainer c) => true;
|
||||||
|
|
||||||
|
static ListFavoriteBloc of(Account account) {
|
||||||
|
final name =
|
||||||
|
bloc_util.getInstNameForRootAwareAccount("ListFavoriteBloc", account);
|
||||||
|
try {
|
||||||
|
_log.fine("[of] Resolving bloc for '$name'");
|
||||||
|
return KiwiContainer().resolve<ListFavoriteBloc>(name);
|
||||||
|
} catch (_) {
|
||||||
|
// no created instance for this account, make a new one
|
||||||
|
_log.info("[of] New bloc instance for account: $account");
|
||||||
|
final bloc = ListFavoriteBloc(KiwiContainer().resolve<DiContainer>());
|
||||||
|
KiwiContainer().registerInstance<ListFavoriteBloc>(bloc, name: name);
|
||||||
|
return bloc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
mapEventToState(ListFavoriteBlocEvent event) async* {
|
||||||
|
_log.info("[mapEventToState] $event");
|
||||||
|
if (event is ListFavoriteBlocQuery) {
|
||||||
|
yield* _onEventQuery(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<ListFavoriteBlocState> _onEventQuery(ListFavoriteBlocQuery ev) async* {
|
||||||
|
List<File>? cache;
|
||||||
|
try {
|
||||||
|
yield ListFavoriteBlocLoading(ev.account, state.items);
|
||||||
|
cache = await _queryOffline(ev);
|
||||||
|
if (cache != null) {
|
||||||
|
yield ListFavoriteBlocLoading(ev.account, cache);
|
||||||
|
}
|
||||||
|
final remote = await _queryOnline(ev);
|
||||||
|
yield ListFavoriteBlocSuccess(ev.account, remote);
|
||||||
|
|
||||||
|
if (cache != null) {
|
||||||
|
CacheFavorite(_c)(ev.account, remote, cache: cache)
|
||||||
|
.onError((e, stackTrace) {
|
||||||
|
_log.shout(
|
||||||
|
"[_onEventQuery] Failed while CacheFavorite", e, stackTrace);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_log.severe("[_onEventQuery] Exception while request", e, stackTrace);
|
||||||
|
yield ListFavoriteBlocFailure(ev.account, cache ?? state.items, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<File>?> _queryOffline(ListFavoriteBlocQuery ev) async {
|
||||||
|
try {
|
||||||
|
return await ListFavoriteOffline(_c)(ev.account);
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_log.shout("[_query] Failed", e, stackTrace);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<File>> _queryOnline(ListFavoriteBlocQuery ev) {
|
||||||
|
return ListFavorite(_c)(ev.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
final DiContainer _c;
|
||||||
|
|
||||||
|
static final _log = Logger("bloc.list_favorite.ListFavoriteBloc");
|
||||||
|
}
|
|
@ -109,6 +109,7 @@ class ScanAccountDirBloc
|
||||||
_filePropertyUpdatedEventListener.begin();
|
_filePropertyUpdatedEventListener.begin();
|
||||||
_fileTrashbinRestoredEventListener.begin();
|
_fileTrashbinRestoredEventListener.begin();
|
||||||
_fileMovedEventListener.begin();
|
_fileMovedEventListener.begin();
|
||||||
|
_favoriteResyncedEventListener.begin();
|
||||||
_prefUpdatedEventListener.begin();
|
_prefUpdatedEventListener.begin();
|
||||||
_accountPrefUpdatedEventListener.begin();
|
_accountPrefUpdatedEventListener.begin();
|
||||||
}
|
}
|
||||||
|
@ -159,6 +160,7 @@ class ScanAccountDirBloc
|
||||||
_filePropertyUpdatedEventListener.end();
|
_filePropertyUpdatedEventListener.end();
|
||||||
_fileTrashbinRestoredEventListener.end();
|
_fileTrashbinRestoredEventListener.end();
|
||||||
_fileMovedEventListener.end();
|
_fileMovedEventListener.end();
|
||||||
|
_favoriteResyncedEventListener.end();
|
||||||
_prefUpdatedEventListener.end();
|
_prefUpdatedEventListener.end();
|
||||||
_accountPrefUpdatedEventListener.end();
|
_accountPrefUpdatedEventListener.end();
|
||||||
|
|
||||||
|
@ -208,6 +210,7 @@ class ScanAccountDirBloc
|
||||||
FilePropertyUpdatedEvent.propMetadata,
|
FilePropertyUpdatedEvent.propMetadata,
|
||||||
FilePropertyUpdatedEvent.propIsArchived,
|
FilePropertyUpdatedEvent.propIsArchived,
|
||||||
FilePropertyUpdatedEvent.propOverrideDateTime,
|
FilePropertyUpdatedEvent.propOverrideDateTime,
|
||||||
|
FilePropertyUpdatedEvent.propFavorite,
|
||||||
])) {
|
])) {
|
||||||
// not interested
|
// not interested
|
||||||
return;
|
return;
|
||||||
|
@ -223,6 +226,7 @@ class ScanAccountDirBloc
|
||||||
if (ev.hasAnyProperties([
|
if (ev.hasAnyProperties([
|
||||||
FilePropertyUpdatedEvent.propIsArchived,
|
FilePropertyUpdatedEvent.propIsArchived,
|
||||||
FilePropertyUpdatedEvent.propOverrideDateTime,
|
FilePropertyUpdatedEvent.propOverrideDateTime,
|
||||||
|
FilePropertyUpdatedEvent.propFavorite,
|
||||||
])) {
|
])) {
|
||||||
_refreshThrottler.trigger(
|
_refreshThrottler.trigger(
|
||||||
maxResponceTime: const Duration(seconds: 3),
|
maxResponceTime: const Duration(seconds: 3),
|
||||||
|
@ -260,6 +264,19 @@ class ScanAccountDirBloc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onFavoriteResyncedEvent(FavoriteResyncedEvent ev) {
|
||||||
|
if (state is ScanAccountDirBlocInit) {
|
||||||
|
// no data in this bloc, ignore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((ev.newFavorites + ev.removedFavorites).any(_isFileOfInterest)) {
|
||||||
|
_refreshThrottler.trigger(
|
||||||
|
maxResponceTime: const Duration(seconds: 3),
|
||||||
|
maxPendingCount: 10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onPrefUpdatedEvent(PrefUpdatedEvent ev) {
|
void _onPrefUpdatedEvent(PrefUpdatedEvent ev) {
|
||||||
if (state is ScanAccountDirBlocInit) {
|
if (state is ScanAccountDirBlocInit) {
|
||||||
// no data in this bloc, ignore
|
// no data in this bloc, ignore
|
||||||
|
@ -460,6 +477,8 @@ class ScanAccountDirBloc
|
||||||
AppEventListener<FileTrashbinRestoredEvent>(_onFileTrashbinRestoredEvent);
|
AppEventListener<FileTrashbinRestoredEvent>(_onFileTrashbinRestoredEvent);
|
||||||
late final _fileMovedEventListener =
|
late final _fileMovedEventListener =
|
||||||
AppEventListener<FileMovedEvent>(_onFileMovedEvent);
|
AppEventListener<FileMovedEvent>(_onFileMovedEvent);
|
||||||
|
late final _favoriteResyncedEventListener =
|
||||||
|
AppEventListener<FavoriteResyncedEvent>(_onFavoriteResyncedEvent);
|
||||||
late final _prefUpdatedEventListener =
|
late final _prefUpdatedEventListener =
|
||||||
AppEventListener<PrefUpdatedEvent>(_onPrefUpdatedEvent);
|
AppEventListener<PrefUpdatedEvent>(_onPrefUpdatedEvent);
|
||||||
late final _accountPrefUpdatedEventListener =
|
late final _accountPrefUpdatedEventListener =
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:nc_photos/app_db.dart';
|
import 'package:nc_photos/app_db.dart';
|
||||||
import 'package:nc_photos/entity/album.dart';
|
import 'package:nc_photos/entity/album.dart';
|
||||||
import 'package:nc_photos/entity/face.dart';
|
import 'package:nc_photos/entity/face.dart';
|
||||||
|
import 'package:nc_photos/entity/favorite.dart';
|
||||||
import 'package:nc_photos/entity/file.dart';
|
import 'package:nc_photos/entity/file.dart';
|
||||||
import 'package:nc_photos/entity/person.dart';
|
import 'package:nc_photos/entity/person.dart';
|
||||||
import 'package:nc_photos/entity/share.dart';
|
import 'package:nc_photos/entity/share.dart';
|
||||||
|
@ -15,6 +16,7 @@ enum DiType {
|
||||||
personRepo,
|
personRepo,
|
||||||
shareRepo,
|
shareRepo,
|
||||||
shareeRepo,
|
shareeRepo,
|
||||||
|
favoriteRepo,
|
||||||
appDb,
|
appDb,
|
||||||
pref,
|
pref,
|
||||||
}
|
}
|
||||||
|
@ -27,6 +29,7 @@ class DiContainer {
|
||||||
PersonRepo? personRepo,
|
PersonRepo? personRepo,
|
||||||
ShareRepo? shareRepo,
|
ShareRepo? shareRepo,
|
||||||
ShareeRepo? shareeRepo,
|
ShareeRepo? shareeRepo,
|
||||||
|
FavoriteRepo? favoriteRepo,
|
||||||
AppDb? appDb,
|
AppDb? appDb,
|
||||||
Pref? pref,
|
Pref? pref,
|
||||||
}) : _albumRepo = albumRepo,
|
}) : _albumRepo = albumRepo,
|
||||||
|
@ -35,6 +38,7 @@ class DiContainer {
|
||||||
_personRepo = personRepo,
|
_personRepo = personRepo,
|
||||||
_shareRepo = shareRepo,
|
_shareRepo = shareRepo,
|
||||||
_shareeRepo = shareeRepo,
|
_shareeRepo = shareeRepo,
|
||||||
|
_favoriteRepo = favoriteRepo,
|
||||||
_appDb = appDb,
|
_appDb = appDb,
|
||||||
_pref = pref;
|
_pref = pref;
|
||||||
|
|
||||||
|
@ -52,6 +56,8 @@ class DiContainer {
|
||||||
return contianer._shareRepo != null;
|
return contianer._shareRepo != null;
|
||||||
case DiType.shareeRepo:
|
case DiType.shareeRepo:
|
||||||
return contianer._shareeRepo != null;
|
return contianer._shareeRepo != null;
|
||||||
|
case DiType.favoriteRepo:
|
||||||
|
return contianer._favoriteRepo != null;
|
||||||
case DiType.appDb:
|
case DiType.appDb:
|
||||||
return contianer._appDb != null;
|
return contianer._appDb != null;
|
||||||
case DiType.pref:
|
case DiType.pref:
|
||||||
|
@ -66,6 +72,7 @@ class DiContainer {
|
||||||
OrNull<PersonRepo>? personRepo,
|
OrNull<PersonRepo>? personRepo,
|
||||||
OrNull<ShareRepo>? shareRepo,
|
OrNull<ShareRepo>? shareRepo,
|
||||||
OrNull<ShareeRepo>? shareeRepo,
|
OrNull<ShareeRepo>? shareeRepo,
|
||||||
|
OrNull<FavoriteRepo>? favoriteRepo,
|
||||||
OrNull<AppDb>? appDb,
|
OrNull<AppDb>? appDb,
|
||||||
OrNull<Pref>? pref,
|
OrNull<Pref>? pref,
|
||||||
}) {
|
}) {
|
||||||
|
@ -76,6 +83,7 @@ class DiContainer {
|
||||||
personRepo: personRepo == null ? _personRepo : personRepo.obj,
|
personRepo: personRepo == null ? _personRepo : personRepo.obj,
|
||||||
shareRepo: shareRepo == null ? _shareRepo : shareRepo.obj,
|
shareRepo: shareRepo == null ? _shareRepo : shareRepo.obj,
|
||||||
shareeRepo: shareeRepo == null ? _shareeRepo : shareeRepo.obj,
|
shareeRepo: shareeRepo == null ? _shareeRepo : shareeRepo.obj,
|
||||||
|
favoriteRepo: favoriteRepo == null ? _favoriteRepo : favoriteRepo.obj,
|
||||||
appDb: appDb == null ? _appDb : appDb.obj,
|
appDb: appDb == null ? _appDb : appDb.obj,
|
||||||
pref: pref == null ? _pref : pref.obj,
|
pref: pref == null ? _pref : pref.obj,
|
||||||
);
|
);
|
||||||
|
@ -87,6 +95,7 @@ class DiContainer {
|
||||||
PersonRepo get personRepo => _personRepo!;
|
PersonRepo get personRepo => _personRepo!;
|
||||||
ShareRepo get shareRepo => _shareRepo!;
|
ShareRepo get shareRepo => _shareRepo!;
|
||||||
ShareeRepo get shareeRepo => _shareeRepo!;
|
ShareeRepo get shareeRepo => _shareeRepo!;
|
||||||
|
FavoriteRepo get favoriteRepo => _favoriteRepo!;
|
||||||
|
|
||||||
AppDb get appDb => _appDb!;
|
AppDb get appDb => _appDb!;
|
||||||
Pref get pref => _pref!;
|
Pref get pref => _pref!;
|
||||||
|
@ -97,6 +106,7 @@ class DiContainer {
|
||||||
final PersonRepo? _personRepo;
|
final PersonRepo? _personRepo;
|
||||||
final ShareRepo? _shareRepo;
|
final ShareRepo? _shareRepo;
|
||||||
final ShareeRepo? _shareeRepo;
|
final ShareeRepo? _shareeRepo;
|
||||||
|
final FavoriteRepo? _favoriteRepo;
|
||||||
|
|
||||||
final AppDb? _appDb;
|
final AppDb? _appDb;
|
||||||
final Pref? _pref;
|
final Pref? _pref;
|
||||||
|
|
36
lib/entity/favorite.dart
Normal file
36
lib/entity/favorite.dart
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
|
||||||
|
class Favorite with EquatableMixin {
|
||||||
|
const Favorite({
|
||||||
|
required this.fileId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() => "$runtimeType {"
|
||||||
|
"fileId: '$fileId', "
|
||||||
|
"}";
|
||||||
|
|
||||||
|
@override
|
||||||
|
get props => [
|
||||||
|
fileId,
|
||||||
|
];
|
||||||
|
|
||||||
|
final int fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FavoriteRepo {
|
||||||
|
const FavoriteRepo(this.dataSrc);
|
||||||
|
|
||||||
|
/// See [FavoriteDataSource.list]
|
||||||
|
Future<List<Favorite>> list(Account account, File dir) =>
|
||||||
|
dataSrc.list(account, dir);
|
||||||
|
|
||||||
|
final FavoriteDataSource dataSrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class FavoriteDataSource {
|
||||||
|
/// List all favorites for a user under [dir]
|
||||||
|
Future<List<Favorite>> list(Account account, File dir);
|
||||||
|
}
|
33
lib/entity/favorite/data_source.dart
Normal file
33
lib/entity/favorite/data_source.dart
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api.dart';
|
||||||
|
import 'package:nc_photos/entity/favorite.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/entity/webdav_response_parser.dart';
|
||||||
|
import 'package:nc_photos/exception.dart';
|
||||||
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
class FavoriteRemoteDataSource implements FavoriteDataSource {
|
||||||
|
const FavoriteRemoteDataSource();
|
||||||
|
|
||||||
|
@override
|
||||||
|
list(Account account, File dir) async {
|
||||||
|
_log.info("[list] ${dir.path}");
|
||||||
|
final response = await Api(account).files().report(
|
||||||
|
path: dir.path,
|
||||||
|
favorite: true,
|
||||||
|
);
|
||||||
|
if (!response.isGood) {
|
||||||
|
_log.severe("[list] Failed requesting server: $response");
|
||||||
|
throw ApiException(
|
||||||
|
response: response,
|
||||||
|
message: "Failed communicating with server: ${response.statusCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
final xml = XmlDocument.parse(response.body);
|
||||||
|
return WebdavResponseParser().parseFavorites(xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _log =
|
||||||
|
Logger("entity.favorite.data_source.FavoriteRemoteDataSource");
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import 'package:nc_photos/account.dart';
|
||||||
import 'package:nc_photos/ci_string.dart';
|
import 'package:nc_photos/ci_string.dart';
|
||||||
import 'package:nc_photos/debug_util.dart';
|
import 'package:nc_photos/debug_util.dart';
|
||||||
import 'package:nc_photos/entity/exif.dart';
|
import 'package:nc_photos/entity/exif.dart';
|
||||||
|
import 'package:nc_photos/json_util.dart' as json_util;
|
||||||
import 'package:nc_photos/or_null.dart';
|
import 'package:nc_photos/or_null.dart';
|
||||||
import 'package:nc_photos/string_extension.dart';
|
import 'package:nc_photos/string_extension.dart';
|
||||||
import 'package:nc_photos/type.dart';
|
import 'package:nc_photos/type.dart';
|
||||||
|
@ -229,6 +230,7 @@ class File with EquatableMixin {
|
||||||
this.usedBytes,
|
this.usedBytes,
|
||||||
this.hasPreview,
|
this.hasPreview,
|
||||||
this.fileId,
|
this.fileId,
|
||||||
|
this.isFavorite,
|
||||||
this.ownerId,
|
this.ownerId,
|
||||||
this.metadata,
|
this.metadata,
|
||||||
this.isArchived,
|
this.isArchived,
|
||||||
|
@ -265,6 +267,7 @@ class File with EquatableMixin {
|
||||||
usedBytes: json["usedBytes"],
|
usedBytes: json["usedBytes"],
|
||||||
hasPreview: json["hasPreview"],
|
hasPreview: json["hasPreview"],
|
||||||
fileId: json["fileId"],
|
fileId: json["fileId"],
|
||||||
|
isFavorite: json_util.boolFromJson(json["isFavorite"]),
|
||||||
ownerId: json["ownerId"] == null ? null : CiString(json["ownerId"]),
|
ownerId: json["ownerId"] == null ? null : CiString(json["ownerId"]),
|
||||||
trashbinFilename: json["trashbinFilename"],
|
trashbinFilename: json["trashbinFilename"],
|
||||||
trashbinOriginalLocation: json["trashbinOriginalLocation"],
|
trashbinOriginalLocation: json["trashbinOriginalLocation"],
|
||||||
|
@ -319,6 +322,9 @@ class File with EquatableMixin {
|
||||||
if (fileId != null) {
|
if (fileId != null) {
|
||||||
product += "fileId: $fileId, ";
|
product += "fileId: $fileId, ";
|
||||||
}
|
}
|
||||||
|
if (isFavorite != null) {
|
||||||
|
product += "isFavorite: $isFavorite, ";
|
||||||
|
}
|
||||||
if (ownerId != null) {
|
if (ownerId != null) {
|
||||||
product += "ownerId: '$ownerId', ";
|
product += "ownerId: '$ownerId', ";
|
||||||
}
|
}
|
||||||
|
@ -355,6 +361,7 @@ class File with EquatableMixin {
|
||||||
if (usedBytes != null) "usedBytes": usedBytes,
|
if (usedBytes != null) "usedBytes": usedBytes,
|
||||||
if (hasPreview != null) "hasPreview": hasPreview,
|
if (hasPreview != null) "hasPreview": hasPreview,
|
||||||
if (fileId != null) "fileId": fileId,
|
if (fileId != null) "fileId": fileId,
|
||||||
|
if (isFavorite != null) "isFavorite": json_util.boolToJson(isFavorite),
|
||||||
if (ownerId != null) "ownerId": ownerId.toString(),
|
if (ownerId != null) "ownerId": ownerId.toString(),
|
||||||
if (trashbinFilename != null) "trashbinFilename": trashbinFilename,
|
if (trashbinFilename != null) "trashbinFilename": trashbinFilename,
|
||||||
if (trashbinOriginalLocation != null)
|
if (trashbinOriginalLocation != null)
|
||||||
|
@ -378,6 +385,7 @@ class File with EquatableMixin {
|
||||||
int? usedBytes,
|
int? usedBytes,
|
||||||
bool? hasPreview,
|
bool? hasPreview,
|
||||||
int? fileId,
|
int? fileId,
|
||||||
|
bool? isFavorite,
|
||||||
CiString? ownerId,
|
CiString? ownerId,
|
||||||
String? trashbinFilename,
|
String? trashbinFilename,
|
||||||
String? trashbinOriginalLocation,
|
String? trashbinOriginalLocation,
|
||||||
|
@ -396,6 +404,7 @@ class File with EquatableMixin {
|
||||||
usedBytes: usedBytes ?? this.usedBytes,
|
usedBytes: usedBytes ?? this.usedBytes,
|
||||||
hasPreview: hasPreview ?? this.hasPreview,
|
hasPreview: hasPreview ?? this.hasPreview,
|
||||||
fileId: fileId ?? this.fileId,
|
fileId: fileId ?? this.fileId,
|
||||||
|
isFavorite: isFavorite ?? this.isFavorite,
|
||||||
ownerId: ownerId ?? this.ownerId,
|
ownerId: ownerId ?? this.ownerId,
|
||||||
trashbinFilename: trashbinFilename ?? this.trashbinFilename,
|
trashbinFilename: trashbinFilename ?? this.trashbinFilename,
|
||||||
trashbinOriginalLocation:
|
trashbinOriginalLocation:
|
||||||
|
@ -420,6 +429,7 @@ class File with EquatableMixin {
|
||||||
usedBytes,
|
usedBytes,
|
||||||
hasPreview,
|
hasPreview,
|
||||||
fileId,
|
fileId,
|
||||||
|
isFavorite,
|
||||||
ownerId,
|
ownerId,
|
||||||
trashbinFilename,
|
trashbinFilename,
|
||||||
trashbinOriginalLocation,
|
trashbinOriginalLocation,
|
||||||
|
@ -439,6 +449,7 @@ class File with EquatableMixin {
|
||||||
final bool? hasPreview;
|
final bool? hasPreview;
|
||||||
// maybe null when loaded from old cache
|
// maybe null when loaded from old cache
|
||||||
final int? fileId;
|
final int? fileId;
|
||||||
|
final bool? isFavorite;
|
||||||
final CiString? ownerId;
|
final CiString? ownerId;
|
||||||
final String? trashbinFilename;
|
final String? trashbinFilename;
|
||||||
final String? trashbinOriginalLocation;
|
final String? trashbinOriginalLocation;
|
||||||
|
@ -564,6 +575,7 @@ class FileRepo {
|
||||||
OrNull<Metadata>? metadata,
|
OrNull<Metadata>? metadata,
|
||||||
OrNull<bool>? isArchived,
|
OrNull<bool>? isArchived,
|
||||||
OrNull<DateTime>? overrideDateTime,
|
OrNull<DateTime>? overrideDateTime,
|
||||||
|
bool? favorite,
|
||||||
}) =>
|
}) =>
|
||||||
dataSrc.updateProperty(
|
dataSrc.updateProperty(
|
||||||
account,
|
account,
|
||||||
|
@ -571,6 +583,7 @@ class FileRepo {
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
isArchived: isArchived,
|
isArchived: isArchived,
|
||||||
overrideDateTime: overrideDateTime,
|
overrideDateTime: overrideDateTime,
|
||||||
|
favorite: favorite,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// See [FileDataSource.copy]
|
/// See [FileDataSource.copy]
|
||||||
|
@ -631,6 +644,7 @@ abstract class FileDataSource {
|
||||||
OrNull<Metadata>? metadata,
|
OrNull<Metadata>? metadata,
|
||||||
OrNull<bool>? isArchived,
|
OrNull<bool>? isArchived,
|
||||||
OrNull<DateTime>? overrideDateTime,
|
OrNull<DateTime>? overrideDateTime,
|
||||||
|
bool? favorite,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Copy [f] to [destination]
|
/// Copy [f] to [destination]
|
||||||
|
|
|
@ -42,6 +42,7 @@ class FileWebdavDataSource implements FileDataSource {
|
||||||
getcontentlength: 1,
|
getcontentlength: 1,
|
||||||
hasPreview: 1,
|
hasPreview: 1,
|
||||||
fileid: 1,
|
fileid: 1,
|
||||||
|
favorite: 1,
|
||||||
ownerId: 1,
|
ownerId: 1,
|
||||||
trashbinFilename: 1,
|
trashbinFilename: 1,
|
||||||
trashbinOriginalLocation: 1,
|
trashbinOriginalLocation: 1,
|
||||||
|
@ -147,6 +148,7 @@ class FileWebdavDataSource implements FileDataSource {
|
||||||
OrNull<Metadata>? metadata,
|
OrNull<Metadata>? metadata,
|
||||||
OrNull<bool>? isArchived,
|
OrNull<bool>? isArchived,
|
||||||
OrNull<DateTime>? overrideDateTime,
|
OrNull<DateTime>? overrideDateTime,
|
||||||
|
bool? favorite,
|
||||||
}) async {
|
}) async {
|
||||||
_log.info("[updateProperty] ${f.path}");
|
_log.info("[updateProperty] ${f.path}");
|
||||||
if (metadata?.obj != null && metadata!.obj!.fileEtag != f.etag) {
|
if (metadata?.obj != null && metadata!.obj!.fileEtag != f.etag) {
|
||||||
|
@ -160,6 +162,7 @@ class FileWebdavDataSource implements FileDataSource {
|
||||||
if (overrideDateTime?.obj != null)
|
if (overrideDateTime?.obj != null)
|
||||||
"app:override-date-time":
|
"app:override-date-time":
|
||||||
overrideDateTime!.obj!.toUtc().toIso8601String(),
|
overrideDateTime!.obj!.toUtc().toIso8601String(),
|
||||||
|
if (favorite != null) "oc:favorite": favorite ? 1 : 0,
|
||||||
};
|
};
|
||||||
final removeProps = [
|
final removeProps = [
|
||||||
if (OrNull.isSetNull(metadata)) "app:metadata",
|
if (OrNull.isSetNull(metadata)) "app:metadata",
|
||||||
|
@ -170,6 +173,7 @@ class FileWebdavDataSource implements FileDataSource {
|
||||||
path: f.path,
|
path: f.path,
|
||||||
namespaces: {
|
namespaces: {
|
||||||
"com.nkming.nc_photos": "app",
|
"com.nkming.nc_photos": "app",
|
||||||
|
"http://owncloud.org/ns": "oc",
|
||||||
},
|
},
|
||||||
set: setProps.isNotEmpty ? setProps : null,
|
set: setProps.isNotEmpty ? setProps : null,
|
||||||
remove: removeProps.isNotEmpty ? removeProps : null,
|
remove: removeProps.isNotEmpty ? removeProps : null,
|
||||||
|
@ -372,6 +376,7 @@ class FileAppDbDataSource implements FileDataSource {
|
||||||
OrNull<Metadata>? metadata,
|
OrNull<Metadata>? metadata,
|
||||||
OrNull<bool>? isArchived,
|
OrNull<bool>? isArchived,
|
||||||
OrNull<DateTime>? overrideDateTime,
|
OrNull<DateTime>? overrideDateTime,
|
||||||
|
bool? favorite,
|
||||||
}) {
|
}) {
|
||||||
_log.info("[updateProperty] ${f.path}");
|
_log.info("[updateProperty] ${f.path}");
|
||||||
return appDb.use((db) async {
|
return appDb.use((db) async {
|
||||||
|
@ -383,6 +388,7 @@ class FileAppDbDataSource implements FileDataSource {
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
isArchived: isArchived,
|
isArchived: isArchived,
|
||||||
overrideDateTime: overrideDateTime,
|
overrideDateTime: overrideDateTime,
|
||||||
|
isFavorite: favorite,
|
||||||
);
|
);
|
||||||
final fileStore = transaction.objectStore(AppDb.file2StoreName);
|
final fileStore = transaction.objectStore(AppDb.file2StoreName);
|
||||||
await fileStore.put(AppDbFile2Entry.fromFile(account, newFile).toJson(),
|
await fileStore.put(AppDbFile2Entry.fromFile(account, newFile).toJson(),
|
||||||
|
@ -500,6 +506,7 @@ class FileCachedDataSource implements FileDataSource {
|
||||||
OrNull<Metadata>? metadata,
|
OrNull<Metadata>? metadata,
|
||||||
OrNull<bool>? isArchived,
|
OrNull<bool>? isArchived,
|
||||||
OrNull<DateTime>? overrideDateTime,
|
OrNull<DateTime>? overrideDateTime,
|
||||||
|
bool? favorite,
|
||||||
}) async {
|
}) async {
|
||||||
await _remoteSrc
|
await _remoteSrc
|
||||||
.updateProperty(
|
.updateProperty(
|
||||||
|
@ -508,6 +515,7 @@ class FileCachedDataSource implements FileDataSource {
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
isArchived: isArchived,
|
isArchived: isArchived,
|
||||||
overrideDateTime: overrideDateTime,
|
overrideDateTime: overrideDateTime,
|
||||||
|
favorite: favorite,
|
||||||
)
|
)
|
||||||
.then((_) => _appDbSrc.updateProperty(
|
.then((_) => _appDbSrc.updateProperty(
|
||||||
account,
|
account,
|
||||||
|
@ -515,6 +523,7 @@ class FileCachedDataSource implements FileDataSource {
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
isArchived: isArchived,
|
isArchived: isArchived,
|
||||||
overrideDateTime: overrideDateTime,
|
overrideDateTime: overrideDateTime,
|
||||||
|
favorite: favorite,
|
||||||
));
|
));
|
||||||
|
|
||||||
// generate a new random token
|
// generate a new random token
|
||||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:nc_photos/ci_string.dart';
|
import 'package:nc_photos/ci_string.dart';
|
||||||
|
import 'package:nc_photos/entity/favorite.dart';
|
||||||
import 'package:nc_photos/entity/file.dart';
|
import 'package:nc_photos/entity/file.dart';
|
||||||
import 'package:nc_photos/string_extension.dart';
|
import 'package:nc_photos/string_extension.dart';
|
||||||
import 'package:xml/xml.dart';
|
import 'package:xml/xml.dart';
|
||||||
|
@ -10,6 +11,9 @@ import 'package:xml/xml.dart';
|
||||||
class WebdavResponseParser {
|
class WebdavResponseParser {
|
||||||
List<File> parseFiles(XmlDocument xml) => _parse<File>(xml, _toFile);
|
List<File> parseFiles(XmlDocument xml) => _parse<File>(xml, _toFile);
|
||||||
|
|
||||||
|
List<Favorite> parseFavorites(XmlDocument xml) =>
|
||||||
|
_parse<Favorite>(xml, _toFavorite);
|
||||||
|
|
||||||
Map<String, String> get namespaces => _namespaces;
|
Map<String, String> get namespaces => _namespaces;
|
||||||
|
|
||||||
List<T> _parse<T>(XmlDocument xml, T Function(XmlElement) mapper) {
|
List<T> _parse<T>(XmlDocument xml, T Function(XmlElement) mapper) {
|
||||||
|
@ -67,6 +71,7 @@ class WebdavResponseParser {
|
||||||
int? usedBytes;
|
int? usedBytes;
|
||||||
bool? hasPreview;
|
bool? hasPreview;
|
||||||
int? fileId;
|
int? fileId;
|
||||||
|
bool? isFavorite;
|
||||||
CiString? ownerId;
|
CiString? ownerId;
|
||||||
Metadata? metadata;
|
Metadata? metadata;
|
||||||
bool? isArchived;
|
bool? isArchived;
|
||||||
|
@ -103,6 +108,7 @@ class WebdavResponseParser {
|
||||||
usedBytes = propParser.usedBytes;
|
usedBytes = propParser.usedBytes;
|
||||||
hasPreview = propParser.hasPreview;
|
hasPreview = propParser.hasPreview;
|
||||||
fileId = propParser.fileId;
|
fileId = propParser.fileId;
|
||||||
|
isFavorite = propParser.isFavorite;
|
||||||
ownerId = propParser.ownerId;
|
ownerId = propParser.ownerId;
|
||||||
metadata = propParser.metadata;
|
metadata = propParser.metadata;
|
||||||
isArchived = propParser.isArchived;
|
isArchived = propParser.isArchived;
|
||||||
|
@ -123,6 +129,7 @@ class WebdavResponseParser {
|
||||||
usedBytes: usedBytes,
|
usedBytes: usedBytes,
|
||||||
hasPreview: hasPreview,
|
hasPreview: hasPreview,
|
||||||
fileId: fileId,
|
fileId: fileId,
|
||||||
|
isFavorite: isFavorite,
|
||||||
ownerId: ownerId,
|
ownerId: ownerId,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
isArchived: isArchived,
|
isArchived: isArchived,
|
||||||
|
@ -133,6 +140,40 @@ class WebdavResponseParser {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map <DAV:response> contents to Favorite
|
||||||
|
Favorite _toFavorite(XmlElement element) {
|
||||||
|
String? path;
|
||||||
|
int? fileId;
|
||||||
|
|
||||||
|
for (final child in element.children.whereType<XmlElement>()) {
|
||||||
|
if (child.matchQualifiedName("href",
|
||||||
|
prefix: "DAV:", namespaces: _namespaces)) {
|
||||||
|
path = _hrefToPath(child);
|
||||||
|
} else if (child.matchQualifiedName("propstat",
|
||||||
|
prefix: "DAV:", namespaces: _namespaces)) {
|
||||||
|
final status = child.children
|
||||||
|
.whereType<XmlElement>()
|
||||||
|
.firstWhere((element) => element.matchQualifiedName("status",
|
||||||
|
prefix: "DAV:", namespaces: _namespaces))
|
||||||
|
.innerText;
|
||||||
|
if (!status.contains(" 200 ")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final prop = child.children.whereType<XmlElement>().firstWhere(
|
||||||
|
(element) => element.matchQualifiedName("prop",
|
||||||
|
prefix: "DAV:", namespaces: _namespaces));
|
||||||
|
final propParser =
|
||||||
|
_FileIdPropParser(namespaces: _namespaces, logFilePath: path);
|
||||||
|
propParser.parse(prop);
|
||||||
|
fileId = propParser.fileId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Favorite(
|
||||||
|
fileId: fileId!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String _hrefToPath(XmlElement href) {
|
String _hrefToPath(XmlElement href) {
|
||||||
final rawPath = Uri.decodeComponent(href.innerText).trimLeftAny("/");
|
final rawPath = Uri.decodeComponent(href.innerText).trimLeftAny("/");
|
||||||
final pos = rawPath.indexOf("remote.php");
|
final pos = rawPath.indexOf("remote.php");
|
||||||
|
@ -186,6 +227,9 @@ class _FilePropParser {
|
||||||
} else if (child.matchQualifiedName("fileid",
|
} else if (child.matchQualifiedName("fileid",
|
||||||
prefix: "http://owncloud.org/ns", namespaces: namespaces)) {
|
prefix: "http://owncloud.org/ns", namespaces: namespaces)) {
|
||||||
_fileId = int.parse(child.innerText);
|
_fileId = int.parse(child.innerText);
|
||||||
|
} else if (child.matchQualifiedName("favorite",
|
||||||
|
prefix: "http://owncloud.org/ns", namespaces: namespaces)) {
|
||||||
|
_isFavorite = child.innerText != "0";
|
||||||
} else if (child.matchQualifiedName("owner-id",
|
} else if (child.matchQualifiedName("owner-id",
|
||||||
prefix: "http://owncloud.org/ns", namespaces: namespaces)) {
|
prefix: "http://owncloud.org/ns", namespaces: namespaces)) {
|
||||||
_ownerId = child.innerText.toCi();
|
_ownerId = child.innerText.toCi();
|
||||||
|
@ -234,6 +278,7 @@ class _FilePropParser {
|
||||||
bool? get isCollection => _isCollection;
|
bool? get isCollection => _isCollection;
|
||||||
bool? get hasPreview => _hasPreview;
|
bool? get hasPreview => _hasPreview;
|
||||||
int? get fileId => _fileId;
|
int? get fileId => _fileId;
|
||||||
|
bool? get isFavorite => _isFavorite;
|
||||||
CiString? get ownerId => _ownerId;
|
CiString? get ownerId => _ownerId;
|
||||||
Metadata? get metadata => _metadata;
|
Metadata? get metadata => _metadata;
|
||||||
bool? get isArchived => _isArchived;
|
bool? get isArchived => _isArchived;
|
||||||
|
@ -255,6 +300,7 @@ class _FilePropParser {
|
||||||
bool? _isCollection;
|
bool? _isCollection;
|
||||||
bool? _hasPreview;
|
bool? _hasPreview;
|
||||||
int? _fileId;
|
int? _fileId;
|
||||||
|
bool? _isFavorite;
|
||||||
CiString? _ownerId;
|
CiString? _ownerId;
|
||||||
Metadata? _metadata;
|
Metadata? _metadata;
|
||||||
bool? _isArchived;
|
bool? _isArchived;
|
||||||
|
@ -264,6 +310,32 @@ class _FilePropParser {
|
||||||
DateTime? _trashbinDeletionTime;
|
DateTime? _trashbinDeletionTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FileIdPropParser {
|
||||||
|
_FileIdPropParser({
|
||||||
|
this.namespaces = const {},
|
||||||
|
this.logFilePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Parse <DAV:prop> element contents
|
||||||
|
void parse(XmlElement element) {
|
||||||
|
for (final child in element.children.whereType<XmlElement>()) {
|
||||||
|
if (child.matchQualifiedName("fileid",
|
||||||
|
prefix: "http://owncloud.org/ns", namespaces: namespaces)) {
|
||||||
|
_fileId = int.parse(child.innerText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? get fileId => _fileId;
|
||||||
|
|
||||||
|
final Map<String, String> namespaces;
|
||||||
|
|
||||||
|
/// File path for logging only
|
||||||
|
final String? logFilePath;
|
||||||
|
|
||||||
|
int? _fileId;
|
||||||
|
}
|
||||||
|
|
||||||
extension on XmlElement {
|
extension on XmlElement {
|
||||||
bool matchQualifiedName(
|
bool matchQualifiedName(
|
||||||
String local, {
|
String local, {
|
||||||
|
|
|
@ -70,6 +70,7 @@ class FilePropertyUpdatedEvent {
|
||||||
static const propMetadata = 0x01;
|
static const propMetadata = 0x01;
|
||||||
static const propIsArchived = 0x02;
|
static const propIsArchived = 0x02;
|
||||||
static const propOverrideDateTime = 0x04;
|
static const propOverrideDateTime = 0x04;
|
||||||
|
static const propFavorite = 0x08;
|
||||||
}
|
}
|
||||||
|
|
||||||
class FileRemovedEvent {
|
class FileRemovedEvent {
|
||||||
|
@ -108,6 +109,15 @@ class ShareRemovedEvent {
|
||||||
final Share share;
|
final Share share;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FavoriteResyncedEvent {
|
||||||
|
const FavoriteResyncedEvent(
|
||||||
|
this.account, this.newFavorites, this.removedFavorites);
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final List<File> newFavorites;
|
||||||
|
final List<File> removedFavorites;
|
||||||
|
}
|
||||||
|
|
||||||
class ThemeChangedEvent {}
|
class ThemeChangedEvent {}
|
||||||
|
|
||||||
class LanguageChangedEvent {}
|
class LanguageChangedEvent {}
|
||||||
|
|
13
lib/iterator_extension.dart
Normal file
13
lib/iterator_extension.dart
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
extension IteratorExtionsion<T> on Iterator<T> {
|
||||||
|
void iterate(void Function(T obj) fn) {
|
||||||
|
while (moveNext()) {
|
||||||
|
fn(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<T> toList() {
|
||||||
|
final list = <T>[];
|
||||||
|
iterate((obj) => list.add(obj));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
10
lib/json_util.dart
Normal file
10
lib/json_util.dart
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import 'package:nc_photos/object_extension.dart';
|
||||||
|
|
||||||
|
/// Convert a boolean to an indexable type in json for DB
|
||||||
|
///
|
||||||
|
/// This is needed because IndexedDB currently does not support creating an
|
||||||
|
/// index on a boolean value
|
||||||
|
Object? boolToJson(bool? value) => value?.run((v) => v ? 1 : 0);
|
||||||
|
|
||||||
|
/// Convert a boolean from an indexable type in json for DB
|
||||||
|
bool? boolFromJson(Object? value) => value?.run((v) => v != 0);
|
|
@ -1123,6 +1123,34 @@
|
||||||
"@convertAlbumSuccessNotification": {
|
"@convertAlbumSuccessNotification": {
|
||||||
"description": "Successfully converted the album"
|
"description": "Successfully converted the album"
|
||||||
},
|
},
|
||||||
|
"collectionFavoritesLabel": "Favorites",
|
||||||
|
"@collectionFavoritesLabel": {
|
||||||
|
"description": "Browse photos added to favorites"
|
||||||
|
},
|
||||||
|
"favoriteTooltip": "Favorite",
|
||||||
|
"@favoriteTooltip": {
|
||||||
|
"description": "Add photo to favorites"
|
||||||
|
},
|
||||||
|
"favoriteSuccessNotification": "Added to favorites",
|
||||||
|
"@favoriteSuccessNotification": {
|
||||||
|
"description": "Successfully added photos to favorites"
|
||||||
|
},
|
||||||
|
"favoriteFailureNotification": "Failed adding to favorites",
|
||||||
|
"@favoriteFailureNotification": {
|
||||||
|
"description": "Failed adding photos to favorites"
|
||||||
|
},
|
||||||
|
"unfavoriteTooltip": "Unfavorite",
|
||||||
|
"@unfavoriteTooltip": {
|
||||||
|
"description": "Remove photo to favorites"
|
||||||
|
},
|
||||||
|
"unfavoriteSuccessNotification": "Removed from favorites",
|
||||||
|
"@unfavoriteSuccessNotification": {
|
||||||
|
"description": "Successfully removed photos from favorites"
|
||||||
|
},
|
||||||
|
"unfavoriteFailureNotification": "Failed removing from favorites",
|
||||||
|
"@unfavoriteFailureNotification": {
|
||||||
|
"description": "Failed removing photos from favorites"
|
||||||
|
},
|
||||||
|
|
||||||
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
|
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
|
||||||
"@errorUnauthenticated": {
|
"@errorUnauthenticated": {
|
||||||
|
|
|
@ -71,6 +71,13 @@
|
||||||
"convertAlbumTooltip",
|
"convertAlbumTooltip",
|
||||||
"convertAlbumConfirmationDialogContent",
|
"convertAlbumConfirmationDialogContent",
|
||||||
"convertAlbumSuccessNotification",
|
"convertAlbumSuccessNotification",
|
||||||
|
"collectionFavoritesLabel",
|
||||||
|
"favoriteTooltip",
|
||||||
|
"favoriteSuccessNotification",
|
||||||
|
"favoriteFailureNotification",
|
||||||
|
"unfavoriteTooltip",
|
||||||
|
"unfavoriteSuccessNotification",
|
||||||
|
"unfavoriteFailureNotification",
|
||||||
"errorAlbumDowngrade"
|
"errorAlbumDowngrade"
|
||||||
],
|
],
|
||||||
|
|
||||||
|
@ -160,6 +167,13 @@
|
||||||
"convertAlbumTooltip",
|
"convertAlbumTooltip",
|
||||||
"convertAlbumConfirmationDialogContent",
|
"convertAlbumConfirmationDialogContent",
|
||||||
"convertAlbumSuccessNotification",
|
"convertAlbumSuccessNotification",
|
||||||
|
"collectionFavoritesLabel",
|
||||||
|
"favoriteTooltip",
|
||||||
|
"favoriteSuccessNotification",
|
||||||
|
"favoriteFailureNotification",
|
||||||
|
"unfavoriteTooltip",
|
||||||
|
"unfavoriteSuccessNotification",
|
||||||
|
"unfavoriteFailureNotification",
|
||||||
"errorAlbumDowngrade"
|
"errorAlbumDowngrade"
|
||||||
],
|
],
|
||||||
|
|
||||||
|
@ -304,6 +318,13 @@
|
||||||
"convertAlbumTooltip",
|
"convertAlbumTooltip",
|
||||||
"convertAlbumConfirmationDialogContent",
|
"convertAlbumConfirmationDialogContent",
|
||||||
"convertAlbumSuccessNotification",
|
"convertAlbumSuccessNotification",
|
||||||
|
"collectionFavoritesLabel",
|
||||||
|
"favoriteTooltip",
|
||||||
|
"favoriteSuccessNotification",
|
||||||
|
"favoriteFailureNotification",
|
||||||
|
"unfavoriteTooltip",
|
||||||
|
"unfavoriteSuccessNotification",
|
||||||
|
"unfavoriteFailureNotification",
|
||||||
"errorAlbumDowngrade"
|
"errorAlbumDowngrade"
|
||||||
],
|
],
|
||||||
|
|
||||||
|
@ -331,6 +352,13 @@
|
||||||
"convertAlbumTooltip",
|
"convertAlbumTooltip",
|
||||||
"convertAlbumConfirmationDialogContent",
|
"convertAlbumConfirmationDialogContent",
|
||||||
"convertAlbumSuccessNotification",
|
"convertAlbumSuccessNotification",
|
||||||
|
"collectionFavoritesLabel",
|
||||||
|
"favoriteTooltip",
|
||||||
|
"favoriteSuccessNotification",
|
||||||
|
"favoriteFailureNotification",
|
||||||
|
"unfavoriteTooltip",
|
||||||
|
"unfavoriteSuccessNotification",
|
||||||
|
"unfavoriteFailureNotification",
|
||||||
"errorAlbumDowngrade"
|
"errorAlbumDowngrade"
|
||||||
],
|
],
|
||||||
|
|
||||||
|
@ -342,7 +370,14 @@
|
||||||
"createCollectionDialogFolderDescription",
|
"createCollectionDialogFolderDescription",
|
||||||
"convertAlbumTooltip",
|
"convertAlbumTooltip",
|
||||||
"convertAlbumConfirmationDialogContent",
|
"convertAlbumConfirmationDialogContent",
|
||||||
"convertAlbumSuccessNotification"
|
"convertAlbumSuccessNotification",
|
||||||
|
"collectionFavoritesLabel",
|
||||||
|
"favoriteTooltip",
|
||||||
|
"favoriteSuccessNotification",
|
||||||
|
"favoriteFailureNotification",
|
||||||
|
"unfavoriteTooltip",
|
||||||
|
"unfavoriteSuccessNotification",
|
||||||
|
"unfavoriteFailureNotification"
|
||||||
],
|
],
|
||||||
|
|
||||||
"fr": [
|
"fr": [
|
||||||
|
@ -466,6 +501,13 @@
|
||||||
"convertAlbumTooltip",
|
"convertAlbumTooltip",
|
||||||
"convertAlbumConfirmationDialogContent",
|
"convertAlbumConfirmationDialogContent",
|
||||||
"convertAlbumSuccessNotification",
|
"convertAlbumSuccessNotification",
|
||||||
|
"collectionFavoritesLabel",
|
||||||
|
"favoriteTooltip",
|
||||||
|
"favoriteSuccessNotification",
|
||||||
|
"favoriteFailureNotification",
|
||||||
|
"unfavoriteTooltip",
|
||||||
|
"unfavoriteSuccessNotification",
|
||||||
|
"unfavoriteFailureNotification",
|
||||||
"errorAlbumDowngrade"
|
"errorAlbumDowngrade"
|
||||||
],
|
],
|
||||||
|
|
||||||
|
@ -477,7 +519,14 @@
|
||||||
"createCollectionDialogFolderDescription",
|
"createCollectionDialogFolderDescription",
|
||||||
"convertAlbumTooltip",
|
"convertAlbumTooltip",
|
||||||
"convertAlbumConfirmationDialogContent",
|
"convertAlbumConfirmationDialogContent",
|
||||||
"convertAlbumSuccessNotification"
|
"convertAlbumSuccessNotification",
|
||||||
|
"collectionFavoritesLabel",
|
||||||
|
"favoriteTooltip",
|
||||||
|
"favoriteSuccessNotification",
|
||||||
|
"favoriteFailureNotification",
|
||||||
|
"unfavoriteTooltip",
|
||||||
|
"unfavoriteSuccessNotification",
|
||||||
|
"unfavoriteFailureNotification"
|
||||||
],
|
],
|
||||||
|
|
||||||
"ru": [
|
"ru": [
|
||||||
|
@ -574,6 +623,13 @@
|
||||||
"convertAlbumTooltip",
|
"convertAlbumTooltip",
|
||||||
"convertAlbumConfirmationDialogContent",
|
"convertAlbumConfirmationDialogContent",
|
||||||
"convertAlbumSuccessNotification",
|
"convertAlbumSuccessNotification",
|
||||||
|
"collectionFavoritesLabel",
|
||||||
|
"favoriteTooltip",
|
||||||
|
"favoriteSuccessNotification",
|
||||||
|
"favoriteFailureNotification",
|
||||||
|
"unfavoriteTooltip",
|
||||||
|
"unfavoriteSuccessNotification",
|
||||||
|
"unfavoriteFailureNotification",
|
||||||
"errorAlbumDowngrade"
|
"errorAlbumDowngrade"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
61
lib/list_util.dart
Normal file
61
lib/list_util.dart
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import 'package:nc_photos/iterator_extension.dart';
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
|
/// Return the difference between two sorted lists, [a] and [b]
|
||||||
|
///
|
||||||
|
/// [a] and [b] MUST be sorted in ascending order, otherwise the result is
|
||||||
|
/// undefined.
|
||||||
|
///
|
||||||
|
/// The first returned list contains items exist in [b] but not [a], the second
|
||||||
|
/// returned list contains items exist in [a] but not [b]
|
||||||
|
Tuple2<List<T>, List<T>> diffWith<T>(
|
||||||
|
List<T> a, List<T> b, int Function(T a, T b) comparator) {
|
||||||
|
final aIt = a.iterator, bIt = b.iterator;
|
||||||
|
final aMissing = <T>[], bMissing = <T>[];
|
||||||
|
while (true) {
|
||||||
|
if (!aIt.moveNext()) {
|
||||||
|
// no more elements in a
|
||||||
|
bIt.iterate((obj) => aMissing.add(obj));
|
||||||
|
return Tuple2(aMissing, bMissing);
|
||||||
|
}
|
||||||
|
if (!bIt.moveNext()) {
|
||||||
|
// no more elements in b
|
||||||
|
// needed because aIt has already advanced
|
||||||
|
bMissing.add(aIt.current);
|
||||||
|
aIt.iterate((obj) => bMissing.add(obj));
|
||||||
|
return Tuple2(aMissing, bMissing);
|
||||||
|
}
|
||||||
|
final result = _diffUntilEqual(aIt, bIt, comparator);
|
||||||
|
aMissing.addAll(result.item1);
|
||||||
|
bMissing.addAll(result.item2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Tuple2<List<T>, List<T>> diff<T extends Comparable>(List<T> a, List<T> b) =>
|
||||||
|
diffWith(a, b, Comparable.compare);
|
||||||
|
|
||||||
|
Tuple2<List<T>, List<T>> _diffUntilEqual<T>(
|
||||||
|
Iterator<T> aIt, Iterator<T> bIt, int Function(T a, T b) comparator) {
|
||||||
|
final a = aIt.current, b = bIt.current;
|
||||||
|
final diff = comparator(a, b);
|
||||||
|
if (diff < 0) {
|
||||||
|
// a < b
|
||||||
|
if (!aIt.moveNext()) {
|
||||||
|
return Tuple2([b] + bIt.toList(), [a]);
|
||||||
|
} else {
|
||||||
|
final result = _diffUntilEqual(aIt, bIt, comparator);
|
||||||
|
return Tuple2(result.item1, [a] + result.item2);
|
||||||
|
}
|
||||||
|
} else if (diff > 0) {
|
||||||
|
// a > b
|
||||||
|
if (!bIt.moveNext()) {
|
||||||
|
return Tuple2([b], [a] + aIt.toList());
|
||||||
|
} else {
|
||||||
|
final result = _diffUntilEqual(aIt, bIt, comparator);
|
||||||
|
return Tuple2([b] + result.item1, result.item2);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// a == b
|
||||||
|
return const Tuple2([], []);
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,6 +13,8 @@ import 'package:nc_photos/di_container.dart';
|
||||||
import 'package:nc_photos/entity/album.dart';
|
import 'package:nc_photos/entity/album.dart';
|
||||||
import 'package:nc_photos/entity/face.dart';
|
import 'package:nc_photos/entity/face.dart';
|
||||||
import 'package:nc_photos/entity/face/data_source.dart';
|
import 'package:nc_photos/entity/face/data_source.dart';
|
||||||
|
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.dart';
|
||||||
import 'package:nc_photos/entity/file/data_source.dart';
|
import 'package:nc_photos/entity/file/data_source.dart';
|
||||||
import 'package:nc_photos/entity/person.dart';
|
import 'package:nc_photos/entity/person.dart';
|
||||||
|
@ -154,6 +156,7 @@ void _initDiContainer() {
|
||||||
personRepo: const PersonRepo(PersonRemoteDataSource()),
|
personRepo: const PersonRepo(PersonRemoteDataSource()),
|
||||||
shareRepo: ShareRepo(ShareRemoteDataSource()),
|
shareRepo: ShareRepo(ShareRemoteDataSource()),
|
||||||
shareeRepo: ShareeRepo(ShareeRemoteDataSource()),
|
shareeRepo: ShareeRepo(ShareeRemoteDataSource()),
|
||||||
|
favoriteRepo: const FavoriteRepo(FavoriteRemoteDataSource()),
|
||||||
appDb: AppDb(),
|
appDb: AppDb(),
|
||||||
pref: Pref(),
|
pref: Pref(),
|
||||||
));
|
));
|
||||||
|
|
78
lib/use_case/cache_favorite.dart
Normal file
78
lib/use_case/cache_favorite.dart
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import 'package:event_bus/event_bus.dart';
|
||||||
|
import 'package:idb_shim/idb_client.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/app_db.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/event/event.dart';
|
||||||
|
import 'package:nc_photos/iterable_extension.dart';
|
||||||
|
import 'package:nc_photos/list_util.dart' as list_util;
|
||||||
|
import 'package:nc_photos/use_case/list_favorite_offline.dart';
|
||||||
|
|
||||||
|
class CacheFavorite {
|
||||||
|
CacheFavorite(this._c)
|
||||||
|
: assert(require(_c)),
|
||||||
|
assert(ListFavoriteOffline.require(_c));
|
||||||
|
|
||||||
|
static bool require(DiContainer c) => DiContainer.has(c, DiType.appDb);
|
||||||
|
|
||||||
|
/// Cache favorites
|
||||||
|
Future<void> call(
|
||||||
|
Account account,
|
||||||
|
List<File> remote, {
|
||||||
|
List<File>? cache,
|
||||||
|
}) async {
|
||||||
|
cache ??= await ListFavoriteOffline(_c)(account);
|
||||||
|
final remoteSorted =
|
||||||
|
remote.sorted((a, b) => a.fileId!.compareTo(b.fileId!));
|
||||||
|
final cacheSorted = cache.sorted((a, b) => a.fileId!.compareTo(b.fileId!));
|
||||||
|
final result = list_util.diffWith<File>(
|
||||||
|
cacheSorted, remoteSorted, (a, b) => a.fileId!.compareTo(b.fileId!));
|
||||||
|
final newFavorites = result.item1;
|
||||||
|
final removedFavorites =
|
||||||
|
result.item2.map((f) => f.copyWith(isFavorite: false)).toList();
|
||||||
|
if (newFavorites.isEmpty && removedFavorites.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _c.appDb.use((db) async {
|
||||||
|
final transaction =
|
||||||
|
db.transaction(AppDb.file2StoreName, idbModeReadWrite);
|
||||||
|
final fileStore = transaction.objectStore(AppDb.file2StoreName);
|
||||||
|
await Future.wait(newFavorites.map((f) async {
|
||||||
|
_log.info("[call] New favorite: ${f.path}");
|
||||||
|
try {
|
||||||
|
await fileStore.put(AppDbFile2Entry.fromFile(account, f).toJson(),
|
||||||
|
AppDbFile2Entry.toPrimaryKeyForFile(account, f));
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_log.shout(
|
||||||
|
"[call] Failed while writing new favorite to AppDb: ${logFilename(f.path)}",
|
||||||
|
e,
|
||||||
|
stackTrace);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
await Future.wait(removedFavorites.map((f) async {
|
||||||
|
_log.info("[call] Remove favorite: ${f.path}");
|
||||||
|
try {
|
||||||
|
await fileStore.put(AppDbFile2Entry.fromFile(account, f).toJson(),
|
||||||
|
AppDbFile2Entry.toPrimaryKeyForFile(account, f));
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_log.shout(
|
||||||
|
"[call] Failed while writing removed favorite to AppDb: ${logFilename(f.path)}",
|
||||||
|
e,
|
||||||
|
stackTrace);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
KiwiContainer()
|
||||||
|
.resolve<EventBus>()
|
||||||
|
.fire(FavoriteResyncedEvent(account, newFavorites, removedFavorites));
|
||||||
|
}
|
||||||
|
|
||||||
|
final DiContainer _c;
|
||||||
|
|
||||||
|
static final _log = Logger("use_case.cache_favorite.CacheFavorite");
|
||||||
|
}
|
41
lib/use_case/list_favorite.dart
Normal file
41
lib/use_case/list_favorite.dart
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/di_container.dart';
|
||||||
|
import 'package:nc_photos/entity/favorite.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
|
import 'package:nc_photos/use_case/find_file.dart';
|
||||||
|
|
||||||
|
class ListFavorite {
|
||||||
|
ListFavorite(this._c)
|
||||||
|
: assert(require(_c)),
|
||||||
|
assert(FindFile.require(_c));
|
||||||
|
|
||||||
|
static bool require(DiContainer c) => DiContainer.has(c, DiType.favoriteRepo);
|
||||||
|
|
||||||
|
/// List all favorites for [account]
|
||||||
|
Future<List<File>> call(Account account) async {
|
||||||
|
final favorites = <Favorite>[];
|
||||||
|
for (final r in account.roots) {
|
||||||
|
favorites.addAll(await _c.favoriteRepo
|
||||||
|
.list(account, File(path: file_util.unstripPath(account, r))));
|
||||||
|
}
|
||||||
|
final files = await FindFile(_c)(
|
||||||
|
account,
|
||||||
|
favorites.map((f) => f.fileId).toList(),
|
||||||
|
onFileNotFound: (id) {
|
||||||
|
// ignore missing file
|
||||||
|
_log.warning("[call] Missing file: $id");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return files
|
||||||
|
.where((f) => file_util.isSupportedFormat(f))
|
||||||
|
// The file in AppDb may not be marked as favorite correctly
|
||||||
|
.map((f) => f.copyWith(isFavorite: true))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final DiContainer _c;
|
||||||
|
|
||||||
|
static final _log = Logger("use_case.list_favorite.ListFavorite");
|
||||||
|
}
|
42
lib/use_case/list_favorite_offline.dart
Normal file
42
lib/use_case/list_favorite_offline.dart
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import 'package:idb_shim/idb_client.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/app_db.dart';
|
||||||
|
import 'package:nc_photos/di_container.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
|
import 'package:nc_photos/use_case/find_file.dart';
|
||||||
|
|
||||||
|
class ListFavoriteOffline {
|
||||||
|
ListFavoriteOffline(this._c)
|
||||||
|
: assert(require(_c)),
|
||||||
|
assert(FindFile.require(_c));
|
||||||
|
|
||||||
|
static bool require(DiContainer c) => DiContainer.has(c, DiType.appDb);
|
||||||
|
|
||||||
|
/// List all favorites for [account] from the local DB
|
||||||
|
Future<List<File>> call(Account account) {
|
||||||
|
final rootDirs = account.roots
|
||||||
|
.map((r) => File(path: file_util.unstripPath(account, r)))
|
||||||
|
.toList();
|
||||||
|
return _c.appDb.use((db) async {
|
||||||
|
final transaction = db.transaction(AppDb.file2StoreName, idbModeReadOnly);
|
||||||
|
final fileStore = transaction.objectStore(AppDb.file2StoreName);
|
||||||
|
final fileIsFavoriteIndex =
|
||||||
|
fileStore.index(AppDbFile2Entry.fileIsFavoriteIndexName);
|
||||||
|
return await fileIsFavoriteIndex
|
||||||
|
.openCursor(
|
||||||
|
key: AppDbFile2Entry.toFileIsFavoriteIndexKey(account, true),
|
||||||
|
autoAdvance: true,
|
||||||
|
)
|
||||||
|
.map((c) => AppDbFile2Entry.fromJson(
|
||||||
|
(c.value as Map).cast<String, dynamic>()))
|
||||||
|
.map((e) => e.file)
|
||||||
|
.where((f) =>
|
||||||
|
file_util.isSupportedFormat(f) &&
|
||||||
|
rootDirs.any((r) => file_util.isOrUnderDir(f, r)))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final DiContainer _c;
|
||||||
|
}
|
25
lib/use_case/sync_favorite.dart
Normal file
25
lib/use_case/sync_favorite.dart
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/di_container.dart';
|
||||||
|
import 'package:nc_photos/use_case/cache_favorite.dart';
|
||||||
|
import 'package:nc_photos/use_case/list_favorite.dart';
|
||||||
|
|
||||||
|
class SyncFavorite {
|
||||||
|
SyncFavorite(this._c)
|
||||||
|
: assert(require(_c)),
|
||||||
|
assert(CacheFavorite.require(_c)),
|
||||||
|
assert(ListFavorite.require(_c));
|
||||||
|
|
||||||
|
static bool require(DiContainer c) => true;
|
||||||
|
|
||||||
|
/// Sync favorites in AppDb with remote server
|
||||||
|
Future<void> call(Account account) async {
|
||||||
|
_log.info("[call] Sync favorites with remote");
|
||||||
|
final remote = await ListFavorite(_c)(account);
|
||||||
|
await CacheFavorite(_c)(account, remote);
|
||||||
|
}
|
||||||
|
|
||||||
|
final DiContainer _c;
|
||||||
|
|
||||||
|
static final _log = Logger("use_case.sync_favorite.SyncFavorite");
|
||||||
|
}
|
|
@ -15,8 +15,12 @@ class UpdateProperty {
|
||||||
OrNull<Metadata>? metadata,
|
OrNull<Metadata>? metadata,
|
||||||
OrNull<bool>? isArchived,
|
OrNull<bool>? isArchived,
|
||||||
OrNull<DateTime>? overrideDateTime,
|
OrNull<DateTime>? overrideDateTime,
|
||||||
|
bool? favorite,
|
||||||
}) async {
|
}) async {
|
||||||
if (metadata == null && isArchived == null && overrideDateTime == null) {
|
if (metadata == null &&
|
||||||
|
isArchived == null &&
|
||||||
|
overrideDateTime == null &&
|
||||||
|
favorite == null) {
|
||||||
// ?
|
// ?
|
||||||
_log.warning("[call] Nothing to update");
|
_log.warning("[call] Nothing to update");
|
||||||
return;
|
return;
|
||||||
|
@ -32,6 +36,7 @@ class UpdateProperty {
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
isArchived: isArchived,
|
isArchived: isArchived,
|
||||||
overrideDateTime: overrideDateTime,
|
overrideDateTime: overrideDateTime,
|
||||||
|
favorite: favorite,
|
||||||
);
|
);
|
||||||
|
|
||||||
int properties = 0;
|
int properties = 0;
|
||||||
|
@ -44,6 +49,9 @@ class UpdateProperty {
|
||||||
if (overrideDateTime != null) {
|
if (overrideDateTime != null) {
|
||||||
properties |= FilePropertyUpdatedEvent.propOverrideDateTime;
|
properties |= FilePropertyUpdatedEvent.propOverrideDateTime;
|
||||||
}
|
}
|
||||||
|
if (favorite != null) {
|
||||||
|
properties |= FilePropertyUpdatedEvent.propFavorite;
|
||||||
|
}
|
||||||
assert(properties != 0);
|
assert(properties != 0);
|
||||||
KiwiContainer()
|
KiwiContainer()
|
||||||
.resolve<EventBus>()
|
.resolve<EventBus>()
|
||||||
|
|
519
lib/widget/favorite_browser.dart
Normal file
519
lib/widget/favorite_browser.dart
Normal file
|
@ -0,0 +1,519 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||||
|
import 'package:nc_photos/app_localizations.dart';
|
||||||
|
import 'package:nc_photos/bloc/list_favorite.dart';
|
||||||
|
import 'package:nc_photos/di_container.dart';
|
||||||
|
import 'package:nc_photos/download_handler.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
|
import 'package:nc_photos/exception_util.dart' as exception_util;
|
||||||
|
import 'package:nc_photos/iterable_extension.dart';
|
||||||
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
import 'package:nc_photos/pref.dart';
|
||||||
|
import 'package:nc_photos/share_handler.dart';
|
||||||
|
import 'package:nc_photos/snack_bar_manager.dart';
|
||||||
|
import 'package:nc_photos/theme.dart';
|
||||||
|
import 'package:nc_photos/widget/empty_list_indicator.dart';
|
||||||
|
import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart';
|
||||||
|
import 'package:nc_photos/widget/handler/archive_selection_handler.dart';
|
||||||
|
import 'package:nc_photos/widget/handler/remove_selection_handler.dart';
|
||||||
|
import 'package:nc_photos/widget/photo_list_item.dart';
|
||||||
|
import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util;
|
||||||
|
import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart';
|
||||||
|
import 'package:nc_photos/widget/selection_app_bar.dart';
|
||||||
|
import 'package:nc_photos/widget/viewer.dart';
|
||||||
|
import 'package:nc_photos/widget/zoom_menu_button.dart';
|
||||||
|
|
||||||
|
class FavoriteBrowserArguments {
|
||||||
|
FavoriteBrowserArguments(this.account);
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FavoriteBrowser extends StatefulWidget {
|
||||||
|
static const routeName = "/favorite-browser";
|
||||||
|
|
||||||
|
static Route buildRoute(FavoriteBrowserArguments args) => MaterialPageRoute(
|
||||||
|
builder: (context) => FavoriteBrowser.fromArgs(args),
|
||||||
|
);
|
||||||
|
|
||||||
|
const FavoriteBrowser({
|
||||||
|
Key? key,
|
||||||
|
required this.account,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
FavoriteBrowser.fromArgs(FavoriteBrowserArguments args, {Key? key})
|
||||||
|
: this(
|
||||||
|
key: key,
|
||||||
|
account: args.account,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
createState() => _FavoriteBrowserState();
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FavoriteBrowserState extends State<FavoriteBrowser>
|
||||||
|
with SelectableItemStreamListMixin<FavoriteBrowser> {
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
_initBloc();
|
||||||
|
_thumbZoomLevel = Pref().getAlbumBrowserZoomLevelOr(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return AppTheme(
|
||||||
|
child: Scaffold(
|
||||||
|
body: BlocListener<ListFavoriteBloc, ListFavoriteBlocState>(
|
||||||
|
bloc: _bloc,
|
||||||
|
listener: (context, state) => _onStateChange(context, state),
|
||||||
|
child: BlocBuilder<ListFavoriteBloc, ListFavoriteBlocState>(
|
||||||
|
bloc: _bloc,
|
||||||
|
builder: (context, state) => _buildContent(context, state),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initBloc() {
|
||||||
|
if (_bloc.state is ListFavoriteBlocInit) {
|
||||||
|
_log.info("[_initBloc] Initialize bloc");
|
||||||
|
_reqQuery();
|
||||||
|
} else {
|
||||||
|
// process the current state
|
||||||
|
WidgetsBinding.instance!.addPostFrameCallback((_) {
|
||||||
|
setState(() {
|
||||||
|
_onStateChange(context, _bloc.state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context, ListFavoriteBlocState state) {
|
||||||
|
if (state is ListFavoriteBlocSuccess && itemStreamListItems.isEmpty) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
AppBar(
|
||||||
|
title: Text(L10n.global().collectionFavoritesLabel),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: EmptyListIndicator(
|
||||||
|
icon: Icons.star_border,
|
||||||
|
text: L10n.global().listEmptyText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
buildItemStreamListOuter(
|
||||||
|
context,
|
||||||
|
child: Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
colorScheme: Theme.of(context).colorScheme.copyWith(
|
||||||
|
secondary: AppTheme.getOverscrollIndicatorColor(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: RefreshIndicator(
|
||||||
|
backgroundColor: Colors.grey[100],
|
||||||
|
onRefresh: () async {
|
||||||
|
_onRefreshSelected();
|
||||||
|
await _waitRefresh();
|
||||||
|
},
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context),
|
||||||
|
buildItemStreamList(
|
||||||
|
maxCrossAxisExtent: _thumbSize.toDouble(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state is ListFavoriteBlocLoading)
|
||||||
|
const Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: LinearProgressIndicator(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context) {
|
||||||
|
if (isSelectionMode) {
|
||||||
|
return _buildSelectionAppBar(context);
|
||||||
|
} else {
|
||||||
|
return _buildNormalAppBar(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectionAppBar(BuildContext conetxt) {
|
||||||
|
return SelectionAppBar(
|
||||||
|
count: selectedListItems.length,
|
||||||
|
onClosePressed: () {
|
||||||
|
setState(() {
|
||||||
|
clearSelectedItems();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.share),
|
||||||
|
tooltip: L10n.global().shareTooltip,
|
||||||
|
onPressed: () => _onSelectionSharePressed(context),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
tooltip: L10n.global().addToAlbumTooltip,
|
||||||
|
onPressed: () => _onSelectionAddToAlbumPressed(context),
|
||||||
|
),
|
||||||
|
PopupMenuButton<_SelectionMenuOption>(
|
||||||
|
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: _SelectionMenuOption.download,
|
||||||
|
child: Text(L10n.global().downloadTooltip),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: _SelectionMenuOption.archive,
|
||||||
|
child: Text(L10n.global().archiveTooltip),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: _SelectionMenuOption.delete,
|
||||||
|
child: Text(L10n.global().deleteTooltip),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onSelected: (option) => _onSelectionMenuSelected(context, option),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNormalAppBar(BuildContext context) {
|
||||||
|
return SliverAppBar(
|
||||||
|
title: Text(L10n.global().collectionFavoritesLabel),
|
||||||
|
floating: true,
|
||||||
|
actions: [
|
||||||
|
ZoomMenuButton(
|
||||||
|
initialZoom: _thumbZoomLevel,
|
||||||
|
minZoom: -1,
|
||||||
|
maxZoom: 2,
|
||||||
|
onZoomChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_setThumbZoomLevel(value.round());
|
||||||
|
});
|
||||||
|
Pref().setHomePhotosZoomLevel(_thumbZoomLevel);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onStateChange(BuildContext context, ListFavoriteBlocState state) {
|
||||||
|
if (state is ListFavoriteBlocInit) {
|
||||||
|
itemStreamListItems = [];
|
||||||
|
} else if (state is ListFavoriteBlocSuccess ||
|
||||||
|
state is ListFavoriteBlocLoading) {
|
||||||
|
_transformItems(state.items);
|
||||||
|
} else if (state is ListFavoriteBlocFailure) {
|
||||||
|
_transformItems(state.items);
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(exception_util.toUserString(state.exception)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onItemTap(int index) {
|
||||||
|
Navigator.pushNamed(context, Viewer.routeName,
|
||||||
|
arguments: ViewerArguments(widget.account, _backingFiles, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onRefreshSelected() {
|
||||||
|
_reqRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSelectionMenuSelected(
|
||||||
|
BuildContext context, _SelectionMenuOption option) {
|
||||||
|
switch (option) {
|
||||||
|
case _SelectionMenuOption.archive:
|
||||||
|
_onSelectionArchivePressed(context);
|
||||||
|
break;
|
||||||
|
case _SelectionMenuOption.delete:
|
||||||
|
_onSelectionDeletePressed(context);
|
||||||
|
break;
|
||||||
|
case _SelectionMenuOption.download:
|
||||||
|
_onSelectionDownloadPressed();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
_log.shout("[_onSelectionMenuSelected] Unknown option: $option");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSelectionSharePressed(BuildContext context) {
|
||||||
|
final selected = selectedListItems
|
||||||
|
.whereType<_FileListItem>()
|
||||||
|
.map((e) => e.file)
|
||||||
|
.toList();
|
||||||
|
ShareHandler(
|
||||||
|
context: context,
|
||||||
|
clearSelection: () {
|
||||||
|
setState(() {
|
||||||
|
clearSelectedItems();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
).shareFiles(widget.account, selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSelectionAddToAlbumPressed(BuildContext context) {
|
||||||
|
return AddSelectionToAlbumHandler()(
|
||||||
|
context: context,
|
||||||
|
account: widget.account,
|
||||||
|
selectedFiles: selectedListItems
|
||||||
|
.whereType<_FileListItem>()
|
||||||
|
.map((e) => e.file)
|
||||||
|
.toList(),
|
||||||
|
clearSelection: () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
clearSelectedItems();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSelectionDownloadPressed() {
|
||||||
|
final selected = selectedListItems
|
||||||
|
.whereType<_FileListItem>()
|
||||||
|
.map((e) => e.file)
|
||||||
|
.toList();
|
||||||
|
DownloadHandler().downloadFiles(widget.account, selected);
|
||||||
|
setState(() {
|
||||||
|
clearSelectedItems();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSelectionArchivePressed(BuildContext context) async {
|
||||||
|
final selectedFiles = selectedListItems
|
||||||
|
.whereType<_FileListItem>()
|
||||||
|
.map((e) => e.file)
|
||||||
|
.toList();
|
||||||
|
setState(() {
|
||||||
|
clearSelectedItems();
|
||||||
|
});
|
||||||
|
await ArchiveSelectionHandler(KiwiContainer().resolve<DiContainer>())(
|
||||||
|
account: widget.account,
|
||||||
|
selectedFiles: selectedFiles,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSelectionDeletePressed(BuildContext context) async {
|
||||||
|
final selectedFiles = selectedListItems
|
||||||
|
.whereType<_FileListItem>()
|
||||||
|
.map((e) => e.file)
|
||||||
|
.toList();
|
||||||
|
setState(() {
|
||||||
|
clearSelectedItems();
|
||||||
|
});
|
||||||
|
await RemoveSelectionHandler()(
|
||||||
|
account: widget.account,
|
||||||
|
selectedFiles: selectedFiles,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _transformItems(List<File> files) {
|
||||||
|
_backingFiles = files
|
||||||
|
.where((f) => f.isArchived != true)
|
||||||
|
.sorted(compareFileDateTimeDescending);
|
||||||
|
|
||||||
|
final isMonthOnly = _thumbZoomLevel < 0;
|
||||||
|
final dateHelper = photo_list_util.DateGroupHelper(
|
||||||
|
isMonthOnly: isMonthOnly,
|
||||||
|
);
|
||||||
|
itemStreamListItems = () sync* {
|
||||||
|
for (int i = 0; i < _backingFiles.length; ++i) {
|
||||||
|
final f = _backingFiles[i];
|
||||||
|
final date = dateHelper.onFile(f);
|
||||||
|
if (date != null) {
|
||||||
|
yield _DateListItem(date: date, isMonthOnly: isMonthOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
final previewUrl = api_util.getFilePreviewUrl(widget.account, f,
|
||||||
|
width: k.photoThumbSize, height: k.photoThumbSize);
|
||||||
|
if (file_util.isSupportedImageFormat(f)) {
|
||||||
|
yield _ImageListItem(
|
||||||
|
file: f,
|
||||||
|
account: widget.account,
|
||||||
|
previewUrl: previewUrl,
|
||||||
|
onTap: () => _onItemTap(i),
|
||||||
|
);
|
||||||
|
} else if (file_util.isSupportedVideoFormat(f)) {
|
||||||
|
yield _VideoListItem(
|
||||||
|
file: f,
|
||||||
|
account: widget.account,
|
||||||
|
previewUrl: previewUrl,
|
||||||
|
onTap: () => _onItemTap(i),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_log.shout(
|
||||||
|
"[_transformItems] Unsupported file format: ${f.contentType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reqQuery() {
|
||||||
|
_bloc.add(ListFavoriteBlocQuery(widget.account));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reqRefresh() {
|
||||||
|
_bloc.add(ListFavoriteBlocQuery(widget.account));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _waitRefresh() async {
|
||||||
|
while (true) {
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
if (_bloc.state is! ListFavoriteBlocLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setThumbZoomLevel(int level) {
|
||||||
|
final prevLevel = _thumbZoomLevel;
|
||||||
|
_thumbZoomLevel = level;
|
||||||
|
if ((prevLevel >= 0) != (level >= 0)) {
|
||||||
|
_transformItems(_backingFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _bloc = ListFavoriteBloc.of(widget.account);
|
||||||
|
|
||||||
|
var _backingFiles = <File>[];
|
||||||
|
|
||||||
|
var _thumbZoomLevel = 0;
|
||||||
|
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
|
||||||
|
|
||||||
|
static final _log = Logger("widget.archive_browser._FavoriteBrowserState");
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _ListItem implements SelectableItem {
|
||||||
|
_ListItem({
|
||||||
|
VoidCallback? onTap,
|
||||||
|
}) : _onTap = onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
get onTap => _onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
get isSelectable => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
get staggeredTile => const StaggeredTile.count(1, 1);
|
||||||
|
|
||||||
|
final VoidCallback? _onTap;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DateListItem extends _ListItem {
|
||||||
|
_DateListItem({
|
||||||
|
required this.date,
|
||||||
|
this.isMonthOnly = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
get isSelectable => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
get staggeredTile => const StaggeredTile.extent(99, 32);
|
||||||
|
|
||||||
|
@override
|
||||||
|
buildWidget(BuildContext context) {
|
||||||
|
return PhotoListDate(
|
||||||
|
date: date,
|
||||||
|
isMonthOnly: isMonthOnly,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final DateTime date;
|
||||||
|
final bool isMonthOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _FileListItem extends _ListItem {
|
||||||
|
_FileListItem({
|
||||||
|
required this.file,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
}) : super(onTap: onTap);
|
||||||
|
|
||||||
|
@override
|
||||||
|
operator ==(Object other) {
|
||||||
|
return other is _FileListItem && file.path == other.file.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
get hashCode => file.path.hashCode;
|
||||||
|
|
||||||
|
final File file;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImageListItem extends _FileListItem {
|
||||||
|
_ImageListItem({
|
||||||
|
required File file,
|
||||||
|
required this.account,
|
||||||
|
required this.previewUrl,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
}) : super(file: file, onTap: onTap);
|
||||||
|
|
||||||
|
@override
|
||||||
|
buildWidget(BuildContext context) {
|
||||||
|
return PhotoListImage(
|
||||||
|
account: account,
|
||||||
|
previewUrl: previewUrl,
|
||||||
|
isGif: file.contentType == "image/gif",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final String previewUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoListItem extends _FileListItem {
|
||||||
|
_VideoListItem({
|
||||||
|
required File file,
|
||||||
|
required this.account,
|
||||||
|
required this.previewUrl,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
}) : super(file: file, onTap: onTap);
|
||||||
|
|
||||||
|
@override
|
||||||
|
buildWidget(BuildContext context) {
|
||||||
|
return PhotoListVideo(
|
||||||
|
account: account,
|
||||||
|
previewUrl: previewUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final String previewUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _SelectionMenuOption {
|
||||||
|
archive,
|
||||||
|
delete,
|
||||||
|
download,
|
||||||
|
}
|
|
@ -26,6 +26,7 @@ import 'package:nc_photos/widget/archive_browser.dart';
|
||||||
import 'package:nc_photos/widget/builder/album_grid_item_builder.dart';
|
import 'package:nc_photos/widget/builder/album_grid_item_builder.dart';
|
||||||
import 'package:nc_photos/widget/dynamic_album_browser.dart';
|
import 'package:nc_photos/widget/dynamic_album_browser.dart';
|
||||||
import 'package:nc_photos/widget/fancy_option_picker.dart';
|
import 'package:nc_photos/widget/fancy_option_picker.dart';
|
||||||
|
import 'package:nc_photos/widget/favorite_browser.dart';
|
||||||
import 'package:nc_photos/widget/home_app_bar.dart';
|
import 'package:nc_photos/widget/home_app_bar.dart';
|
||||||
import 'package:nc_photos/widget/new_album_dialog.dart';
|
import 'package:nc_photos/widget/new_album_dialog.dart';
|
||||||
import 'package:nc_photos/widget/page_visibility_mixin.dart';
|
import 'package:nc_photos/widget/page_visibility_mixin.dart';
|
||||||
|
@ -194,6 +195,19 @@ class _HomeAlbumsState extends State<HomeAlbums>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SelectableItem _buildFavoriteItem(BuildContext context) {
|
||||||
|
return _ButtonListItem(
|
||||||
|
icon: Icons.star_border,
|
||||||
|
label: L10n.global().collectionFavoritesLabel,
|
||||||
|
onTap: () {
|
||||||
|
if (!isSelectionMode) {
|
||||||
|
Navigator.of(context).pushNamed(FavoriteBrowser.routeName,
|
||||||
|
arguments: FavoriteBrowserArguments(widget.account));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
SelectableItem _buildPersonItem(BuildContext context) {
|
SelectableItem _buildPersonItem(BuildContext context) {
|
||||||
return _ButtonListItem(
|
return _ButtonListItem(
|
||||||
icon: Icons.person_outlined,
|
icon: Icons.person_outlined,
|
||||||
|
@ -441,6 +455,7 @@ class _HomeAlbumsState extends State<HomeAlbums>
|
||||||
final sortedAlbums =
|
final sortedAlbums =
|
||||||
album_util.sorted(items.map((e) => e.album).toList(), sort);
|
album_util.sorted(items.map((e) => e.album).toList(), sort);
|
||||||
itemStreamListItems = [
|
itemStreamListItems = [
|
||||||
|
_buildFavoriteItem(context),
|
||||||
if (AccountPref.of(widget.account).isEnableFaceRecognitionAppOr())
|
if (AccountPref.of(widget.account).isEnableFaceRecognitionAppOr())
|
||||||
_buildPersonItem(context),
|
_buildPersonItem(context),
|
||||||
_buildSharingItem(context),
|
_buildSharingItem(context),
|
||||||
|
|
|
@ -17,6 +17,7 @@ import 'package:nc_photos/entity/album.dart';
|
||||||
import 'package:nc_photos/entity/file.dart';
|
import 'package:nc_photos/entity/file.dart';
|
||||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
import 'package:nc_photos/event/event.dart';
|
import 'package:nc_photos/event/event.dart';
|
||||||
|
import 'package:nc_photos/exception.dart';
|
||||||
import 'package:nc_photos/exception_util.dart' as exception_util;
|
import 'package:nc_photos/exception_util.dart' as exception_util;
|
||||||
import 'package:nc_photos/iterable_extension.dart';
|
import 'package:nc_photos/iterable_extension.dart';
|
||||||
import 'package:nc_photos/k.dart' as k;
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
@ -26,6 +27,7 @@ import 'package:nc_photos/primitive.dart';
|
||||||
import 'package:nc_photos/share_handler.dart';
|
import 'package:nc_photos/share_handler.dart';
|
||||||
import 'package:nc_photos/snack_bar_manager.dart';
|
import 'package:nc_photos/snack_bar_manager.dart';
|
||||||
import 'package:nc_photos/theme.dart';
|
import 'package:nc_photos/theme.dart';
|
||||||
|
import 'package:nc_photos/use_case/sync_favorite.dart';
|
||||||
import 'package:nc_photos/widget/album_browser_util.dart' as album_browser_util;
|
import 'package:nc_photos/widget/album_browser_util.dart' as album_browser_util;
|
||||||
import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart';
|
import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart';
|
||||||
import 'package:nc_photos/widget/handler/archive_selection_handler.dart';
|
import 'package:nc_photos/widget/handler/archive_selection_handler.dart';
|
||||||
|
@ -369,6 +371,7 @@ class _HomePhotosState extends State<HomePhotos>
|
||||||
state is ScanAccountDirBlocLoading) {
|
state is ScanAccountDirBlocLoading) {
|
||||||
_transformItems(state.files);
|
_transformItems(state.files);
|
||||||
if (state is ScanAccountDirBlocSuccess) {
|
if (state is ScanAccountDirBlocSuccess) {
|
||||||
|
_syncFavorite();
|
||||||
_tryStartMetadataTask();
|
_tryStartMetadataTask();
|
||||||
}
|
}
|
||||||
} else if (state is ScanAccountDirBlocFailure) {
|
} else if (state is ScanAccountDirBlocFailure) {
|
||||||
|
@ -526,6 +529,21 @@ class _HomePhotosState extends State<HomePhotos>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _syncFavorite() async {
|
||||||
|
if (!_hasResyncedFavorites.value) {
|
||||||
|
final c = KiwiContainer().resolve<DiContainer>();
|
||||||
|
try {
|
||||||
|
await SyncFavorite(c)(widget.account);
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
if (e is! ApiException) {
|
||||||
|
_log.shout(
|
||||||
|
"[_syncFavorite] Failed while SyncFavorite", e, stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_hasResyncedFavorites.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Transform a File list to grid items
|
/// Transform a File list to grid items
|
||||||
void _transformItems(List<File> files) {
|
void _transformItems(List<File> files) {
|
||||||
_backingFiles = files
|
_backingFiles = files
|
||||||
|
@ -653,6 +671,21 @@ class _HomePhotosState extends State<HomePhotos>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Primitive<bool> get _hasResyncedFavorites {
|
||||||
|
final name = bloc_util.getInstNameForRootAwareAccount(
|
||||||
|
"HomePhotosState._hasResyncedFavorites", widget.account);
|
||||||
|
try {
|
||||||
|
_log.fine("[_hasResyncedFavorites] Resolving for '$name'");
|
||||||
|
return KiwiContainer().resolve<Primitive<bool>>(name);
|
||||||
|
} catch (_) {
|
||||||
|
_log.info(
|
||||||
|
"[_hasResyncedFavorites] New instance for account: ${widget.account}");
|
||||||
|
final obj = Primitive(false);
|
||||||
|
KiwiContainer().registerInstance<Primitive<bool>>(obj, name: name);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
late final _bloc = ScanAccountDirBloc.of(widget.account);
|
late final _bloc = ScanAccountDirBloc.of(widget.account);
|
||||||
|
|
||||||
var _backingFiles = <File>[];
|
var _backingFiles = <File>[];
|
||||||
|
@ -760,6 +793,7 @@ class _ImageListItem extends _FileListItem {
|
||||||
account: account,
|
account: account,
|
||||||
previewUrl: previewUrl,
|
previewUrl: previewUrl,
|
||||||
isGif: file.contentType == "image/gif",
|
isGif: file.contentType == "image/gif",
|
||||||
|
isFavorite: file.isFavorite == true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -780,6 +814,7 @@ class _VideoListItem extends _FileListItem {
|
||||||
return PhotoListVideo(
|
return PhotoListVideo(
|
||||||
account: account,
|
account: account,
|
||||||
previewUrl: previewUrl,
|
previewUrl: previewUrl,
|
||||||
|
isFavorite: file.isFavorite == true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,7 @@ import 'package:nc_photos/widget/album_share_outlier_browser.dart';
|
||||||
import 'package:nc_photos/widget/archive_browser.dart';
|
import 'package:nc_photos/widget/archive_browser.dart';
|
||||||
import 'package:nc_photos/widget/connect.dart';
|
import 'package:nc_photos/widget/connect.dart';
|
||||||
import 'package:nc_photos/widget/dynamic_album_browser.dart';
|
import 'package:nc_photos/widget/dynamic_album_browser.dart';
|
||||||
|
import 'package:nc_photos/widget/favorite_browser.dart';
|
||||||
import 'package:nc_photos/widget/home.dart';
|
import 'package:nc_photos/widget/home.dart';
|
||||||
import 'package:nc_photos/widget/people_browser.dart';
|
import 'package:nc_photos/widget/people_browser.dart';
|
||||||
import 'package:nc_photos/widget/person_browser.dart';
|
import 'package:nc_photos/widget/person_browser.dart';
|
||||||
|
@ -155,6 +156,7 @@ class _MyAppState extends State<MyApp> implements SnackBarHandler {
|
||||||
route ??= _handleShareFolderPickerRoute(settings);
|
route ??= _handleShareFolderPickerRoute(settings);
|
||||||
route ??= _handleAlbumPickerRoute(settings);
|
route ??= _handleAlbumPickerRoute(settings);
|
||||||
route ??= _handleSmartAlbumBrowserRoute(settings);
|
route ??= _handleSmartAlbumBrowserRoute(settings);
|
||||||
|
route ??= _handleFavoriteBrowserRoute(settings);
|
||||||
return route;
|
return route;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -471,6 +473,20 @@ class _MyAppState extends State<MyApp> implements SnackBarHandler {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Route<dynamic>? _handleFavoriteBrowserRoute(RouteSettings settings) {
|
||||||
|
try {
|
||||||
|
if (settings.name == FavoriteBrowser.routeName &&
|
||||||
|
settings.arguments != null) {
|
||||||
|
final args = settings.arguments as FavoriteBrowserArguments;
|
||||||
|
return FavoriteBrowser.buildRoute(args);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe(
|
||||||
|
"[_handleFavoriteBrowserRoute] Failed while handling route", e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||||
|
|
||||||
late AppEventListener<ThemeChangedEvent> _themeChangedListener;
|
late AppEventListener<ThemeChangedEvent> _themeChangedListener;
|
||||||
|
|
|
@ -15,6 +15,7 @@ class PhotoListImage extends StatelessWidget {
|
||||||
required this.previewUrl,
|
required this.previewUrl,
|
||||||
this.padding = const EdgeInsets.all(2),
|
this.padding = const EdgeInsets.all(2),
|
||||||
this.isGif = false,
|
this.isGif = false,
|
||||||
|
this.isFavorite = false,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -74,6 +75,18 @@ class PhotoListImage extends StatelessWidget {
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (isFavorite)
|
||||||
|
Container(
|
||||||
|
// arbitrary size here
|
||||||
|
constraints: BoxConstraints.tight(const Size(128, 128)),
|
||||||
|
alignment: AlignmentDirectional.bottomStart,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.star,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -84,6 +97,7 @@ class PhotoListImage extends StatelessWidget {
|
||||||
final String? previewUrl;
|
final String? previewUrl;
|
||||||
final bool isGif;
|
final bool isGif;
|
||||||
final EdgeInsetsGeometry padding;
|
final EdgeInsetsGeometry padding;
|
||||||
|
final bool isFavorite;
|
||||||
}
|
}
|
||||||
|
|
||||||
class PhotoListVideo extends StatelessWidget {
|
class PhotoListVideo extends StatelessWidget {
|
||||||
|
@ -91,6 +105,7 @@ class PhotoListVideo extends StatelessWidget {
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.account,
|
required this.account,
|
||||||
required this.previewUrl,
|
required this.previewUrl,
|
||||||
|
this.isFavorite = false,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -139,6 +154,18 @@ class PhotoListVideo extends StatelessWidget {
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (isFavorite)
|
||||||
|
Container(
|
||||||
|
// arbitrary size here
|
||||||
|
constraints: BoxConstraints.tight(const Size(128, 128)),
|
||||||
|
alignment: AlignmentDirectional.bottomStart,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.star,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -147,6 +174,7 @@ class PhotoListVideo extends StatelessWidget {
|
||||||
|
|
||||||
final Account account;
|
final Account account;
|
||||||
final String previewUrl;
|
final String previewUrl;
|
||||||
|
final bool isFavorite;
|
||||||
}
|
}
|
||||||
|
|
||||||
class PhotoListLabel extends StatelessWidget {
|
class PhotoListLabel extends StatelessWidget {
|
||||||
|
|
|
@ -5,17 +5,21 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:nc_photos/account.dart';
|
import 'package:nc_photos/account.dart';
|
||||||
import 'package:nc_photos/app_localizations.dart';
|
import 'package:nc_photos/app_localizations.dart';
|
||||||
|
import 'package:nc_photos/di_container.dart';
|
||||||
import 'package:nc_photos/download_handler.dart';
|
import 'package:nc_photos/download_handler.dart';
|
||||||
import 'package:nc_photos/entity/album.dart';
|
import 'package:nc_photos/entity/album.dart';
|
||||||
import 'package:nc_photos/entity/file.dart';
|
import 'package:nc_photos/entity/file.dart';
|
||||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
import 'package:nc_photos/k.dart' as k;
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
import 'package:nc_photos/notified_action.dart';
|
||||||
import 'package:nc_photos/pref.dart';
|
import 'package:nc_photos/pref.dart';
|
||||||
import 'package:nc_photos/share_handler.dart';
|
import 'package:nc_photos/share_handler.dart';
|
||||||
import 'package:nc_photos/theme.dart';
|
import 'package:nc_photos/theme.dart';
|
||||||
|
import 'package:nc_photos/use_case/update_property.dart';
|
||||||
import 'package:nc_photos/widget/animated_visibility.dart';
|
import 'package:nc_photos/widget/animated_visibility.dart';
|
||||||
import 'package:nc_photos/widget/disposable.dart';
|
import 'package:nc_photos/widget/disposable.dart';
|
||||||
import 'package:nc_photos/widget/handler/remove_selection_handler.dart';
|
import 'package:nc_photos/widget/handler/remove_selection_handler.dart';
|
||||||
|
@ -125,6 +129,9 @@ class _ViewerState extends State<Viewer>
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppBar(BuildContext context) {
|
Widget _buildAppBar(BuildContext context) {
|
||||||
|
final index =
|
||||||
|
_isViewerLoaded ? _viewerController.currentPage : widget.startIndex;
|
||||||
|
final file = widget.streamFiles[index];
|
||||||
return Wrap(
|
return Wrap(
|
||||||
children: [
|
children: [
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
|
@ -151,13 +158,26 @@ class _ViewerState extends State<Viewer>
|
||||||
shadowColor: Colors.transparent,
|
shadowColor: Colors.transparent,
|
||||||
foregroundColor: Colors.white.withOpacity(.87),
|
foregroundColor: Colors.white.withOpacity(.87),
|
||||||
actions: [
|
actions: [
|
||||||
if (!_isDetailPaneActive && _canOpenDetailPane())
|
if (!_isDetailPaneActive && _canOpenDetailPane()) ...[
|
||||||
|
(_pageStates[index]?.favoriteOverride ?? file.isFavorite) ==
|
||||||
|
true
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.star),
|
||||||
|
tooltip: L10n.global().unfavoriteTooltip,
|
||||||
|
onPressed: () => _onUnfavoritePressed(index),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.star_border),
|
||||||
|
tooltip: L10n.global().favoriteTooltip,
|
||||||
|
onPressed: () => _onFavoritePressed(index),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert),
|
||||||
tooltip: L10n.global().detailsTooltip,
|
tooltip: L10n.global().detailsTooltip,
|
||||||
onPressed: _onDetailsPressed,
|
onPressed: _onDetailsPressed,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@ -454,6 +474,72 @@ class _ViewerState extends State<Viewer>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onFavoritePressed(int index) async {
|
||||||
|
if (_pageStates[index]!.isProcessingFavorite) {
|
||||||
|
_log.fine("[_onFavoritePressed] Process ongoing, ignored");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final file = widget.streamFiles[_viewerController.currentPage];
|
||||||
|
final c = KiwiContainer().resolve<DiContainer>();
|
||||||
|
setState(() {
|
||||||
|
_pageStates[index]!.favoriteOverride = true;
|
||||||
|
});
|
||||||
|
_pageStates[index]!.isProcessingFavorite = true;
|
||||||
|
try {
|
||||||
|
await NotifiedAction(
|
||||||
|
() => UpdateProperty(c.fileRepo)(
|
||||||
|
widget.account,
|
||||||
|
file,
|
||||||
|
favorite: true,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
L10n.global().favoriteSuccessNotification,
|
||||||
|
failureText: L10n.global().favoriteFailureNotification,
|
||||||
|
)();
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_log.shout(
|
||||||
|
"[_onFavoritePressed] Failed while UpdateProperty", e, stackTrace);
|
||||||
|
setState(() {
|
||||||
|
_pageStates[index]!.favoriteOverride = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_pageStates[index]!.isProcessingFavorite = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onUnfavoritePressed(int index) async {
|
||||||
|
if (_pageStates[index]!.isProcessingFavorite) {
|
||||||
|
_log.fine("[_onUnfavoritePressed] Process ongoing, ignored");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final file = widget.streamFiles[_viewerController.currentPage];
|
||||||
|
final c = KiwiContainer().resolve<DiContainer>();
|
||||||
|
setState(() {
|
||||||
|
_pageStates[index]!.favoriteOverride = false;
|
||||||
|
});
|
||||||
|
_pageStates[index]!.isProcessingFavorite = true;
|
||||||
|
try {
|
||||||
|
await NotifiedAction(
|
||||||
|
() => UpdateProperty(c.fileRepo)(
|
||||||
|
widget.account,
|
||||||
|
file,
|
||||||
|
favorite: false,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
L10n.global().unfavoriteSuccessNotification,
|
||||||
|
failureText: L10n.global().unfavoriteFailureNotification,
|
||||||
|
)();
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_log.shout(
|
||||||
|
"[_onUnfavoritePressed] Failed while UpdateProperty", e, stackTrace);
|
||||||
|
setState(() {
|
||||||
|
_pageStates[index]!.favoriteOverride = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_pageStates[index]!.isProcessingFavorite = false;
|
||||||
|
}
|
||||||
|
|
||||||
void _onDetailsPressed() {
|
void _onDetailsPressed() {
|
||||||
if (!_isDetailPaneActive) {
|
if (!_isDetailPaneActive) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
@ -614,4 +700,7 @@ class _PageState {
|
||||||
ScrollController scrollController;
|
ScrollController scrollController;
|
||||||
double? itemHeight;
|
double? itemHeight;
|
||||||
bool hasLoaded = false;
|
bool hasLoaded = false;
|
||||||
|
|
||||||
|
bool isProcessingFavorite = false;
|
||||||
|
bool? favoriteOverride;
|
||||||
}
|
}
|
||||||
|
|
20
test/iterator_extension_test.dart
Normal file
20
test/iterator_extension_test.dart
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import 'package:nc_photos/iterator_extension.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group("iterator_extension", () {
|
||||||
|
group("IteratorExtionsion", () {
|
||||||
|
test("iterate", () {
|
||||||
|
final it = [1, 2, 3, 4, 5].iterator;
|
||||||
|
final result = <int>[];
|
||||||
|
it.iterate((obj) => result.add(obj));
|
||||||
|
expect(result, [1, 2, 3, 4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("toList", () {
|
||||||
|
final it = [1, 2, 3, 4, 5].iterator;
|
||||||
|
expect(it.toList(), [1, 2, 3, 4, 5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
153
test/list_util_test.dart
Normal file
153
test/list_util_test.dart
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
import 'package:nc_photos/list_util.dart' as list_util;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group("list_util", () {
|
||||||
|
group("diff", () {
|
||||||
|
test("extra b begin", _diffExtraBBegin);
|
||||||
|
test("extra b end", _diffExtraBEnd);
|
||||||
|
test("extra b mid", _diffExtraBMid);
|
||||||
|
test("empty a", _diffAEmpty);
|
||||||
|
test("extra a begin", _diffExtraABegin);
|
||||||
|
test("extra a end", _diffExtraAEnd);
|
||||||
|
test("extra a mid", _diffExtraAMid);
|
||||||
|
test("empty b", _diffBEmpty);
|
||||||
|
test("no matches", _diffNoMatches);
|
||||||
|
test("repeated elements", _diffRepeatedElements);
|
||||||
|
test("repeated elements 2", _diffRepeatedElements2);
|
||||||
|
test("mix", _diffMix);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff with extra elements at the beginning of list b
|
||||||
|
///
|
||||||
|
/// a: [3, 4, 5]
|
||||||
|
/// b: [1, 2, 3, 4, 5]
|
||||||
|
/// Expect: [1, 2], []
|
||||||
|
void _diffExtraBBegin() {
|
||||||
|
final diff = list_util.diff([3, 4, 5], [1, 2, 3, 4, 5]);
|
||||||
|
expect(diff.item1, [1, 2]);
|
||||||
|
expect(diff.item2, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff with extra elements at the end of list b
|
||||||
|
///
|
||||||
|
/// a: [1, 2, 3]
|
||||||
|
/// b: [1, 2, 3, 4, 5]
|
||||||
|
/// Expect: [4, 5], []
|
||||||
|
void _diffExtraBEnd() {
|
||||||
|
final diff = list_util.diff([1, 2, 3], [1, 2, 3, 4, 5]);
|
||||||
|
expect(diff.item1, [4, 5]);
|
||||||
|
expect(diff.item2, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff with extra elements in the middle of list b
|
||||||
|
///
|
||||||
|
/// a: [1, 2, 5]
|
||||||
|
/// b: [1, 2, 3, 4, 5]
|
||||||
|
/// Expect: [3, 4], []
|
||||||
|
void _diffExtraBMid() {
|
||||||
|
final diff = list_util.diff([1, 2, 5], [1, 2, 3, 4, 5]);
|
||||||
|
expect(diff.item1, [3, 4]);
|
||||||
|
expect(diff.item2, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff with list a being empty
|
||||||
|
///
|
||||||
|
/// a: []
|
||||||
|
/// b: [1, 2, 3]
|
||||||
|
/// Expect: [1, 2, 3], []
|
||||||
|
void _diffAEmpty() {
|
||||||
|
final diff = list_util.diff(<int>[], [1, 2, 3]);
|
||||||
|
expect(diff.item1, [1, 2, 3]);
|
||||||
|
expect(diff.item2, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff with extra elements at the beginning of list a
|
||||||
|
///
|
||||||
|
/// a: [1, 2, 3, 4, 5]
|
||||||
|
/// b: [3, 4, 5]
|
||||||
|
/// Expect: [], [1, 2]
|
||||||
|
void _diffExtraABegin() {
|
||||||
|
final diff = list_util.diff([1, 2, 3, 4, 5], [3, 4, 5]);
|
||||||
|
expect(diff.item1, []);
|
||||||
|
expect(diff.item2, [1, 2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff with extra elements at the end of list a
|
||||||
|
///
|
||||||
|
/// a: [1, 2, 3, 4, 5]
|
||||||
|
/// b: [1, 2, 3]
|
||||||
|
/// Expect: [], [4, 5]
|
||||||
|
void _diffExtraAEnd() {
|
||||||
|
final diff = list_util.diff([1, 2, 3, 4, 5], [1, 2, 3]);
|
||||||
|
expect(diff.item1, []);
|
||||||
|
expect(diff.item2, [4, 5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff with extra elements in the middle of list a
|
||||||
|
///
|
||||||
|
/// a: [1, 2, 3, 4, 5]
|
||||||
|
/// b: [1, 2, 5]
|
||||||
|
/// Expect: [], [3, 4]
|
||||||
|
void _diffExtraAMid() {
|
||||||
|
final diff = list_util.diff([1, 2, 3, 4, 5], [1, 2, 5]);
|
||||||
|
expect(diff.item1, []);
|
||||||
|
expect(diff.item2, [3, 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff with list b being empty
|
||||||
|
///
|
||||||
|
/// a: [1, 2, 3]
|
||||||
|
/// b: []
|
||||||
|
/// Expect: [], [1, 2, 3]
|
||||||
|
void _diffBEmpty() {
|
||||||
|
final diff = list_util.diff([1, 2, 3], <int>[]);
|
||||||
|
expect(diff.item1, []);
|
||||||
|
expect(diff.item2, [1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff with no matches between list a and b
|
||||||
|
///
|
||||||
|
/// a: [1, 3, 5]
|
||||||
|
/// b: [2, 4]
|
||||||
|
/// Expect: [2, 4], [1, 3, 5]
|
||||||
|
void _diffNoMatches() {
|
||||||
|
final diff = list_util.diff([1, 3, 5], [2, 4]);
|
||||||
|
expect(diff.item1, [2, 4]);
|
||||||
|
expect(diff.item2, [1, 3, 5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff between list a and b with repeated elements
|
||||||
|
///
|
||||||
|
/// a: [1, 2, 3]
|
||||||
|
/// b: [1, 2, 2, 3]
|
||||||
|
/// Expect: [2], []
|
||||||
|
void _diffRepeatedElements() {
|
||||||
|
final diff = list_util.diff([1, 2, 3], [1, 2, 2, 3]);
|
||||||
|
expect(diff.item1, [2]);
|
||||||
|
expect(diff.item2, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff between list a and b with repeated elements
|
||||||
|
///
|
||||||
|
/// a: [1, 3, 4, 4, 5]
|
||||||
|
/// b: [1, 2, 2, 3, 5]
|
||||||
|
/// Expect: [2, 2], [4, 4]
|
||||||
|
void _diffRepeatedElements2() {
|
||||||
|
final diff = list_util.diff([1, 3, 4, 4, 5], [1, 2, 2, 3, 5]);
|
||||||
|
expect(diff.item1, [2, 2]);
|
||||||
|
expect(diff.item2, [4, 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff between list a and b
|
||||||
|
///
|
||||||
|
/// a: [2, 3, 7, 10, 11, 12]
|
||||||
|
/// b: [1, 3, 4, 8, 13, 14]
|
||||||
|
/// Expect: [1, 4, 8, 13, 14], [2, 7, 10, 11, 12]
|
||||||
|
void _diffMix() {
|
||||||
|
final diff = list_util.diff([2, 3, 7, 10, 11, 12], [1, 3, 4, 8, 13, 14]);
|
||||||
|
expect(diff.item1, [1, 4, 8, 13, 14]);
|
||||||
|
expect(diff.item2, [2, 7, 10, 11, 12]);
|
||||||
|
}
|
|
@ -242,10 +242,14 @@ class MockFileRepo implements FileRepo {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> updateProperty(Account account, File file,
|
Future<void> updateProperty(
|
||||||
{OrNull<Metadata>? metadata,
|
Account account,
|
||||||
|
File file, {
|
||||||
|
OrNull<Metadata>? metadata,
|
||||||
OrNull<bool>? isArchived,
|
OrNull<bool>? isArchived,
|
||||||
OrNull<DateTime>? overrideDateTime}) {
|
OrNull<DateTime>? overrideDateTime,
|
||||||
|
bool? favorite,
|
||||||
|
}) {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue