mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-01-22 16:56:19 +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");
|
||||
}
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ import 'package:synchronized/synchronized.dart';
|
|||
|
||||
class AppDb {
|
||||
static const dbName = "app.db";
|
||||
static const dbVersion = 5;
|
||||
static const dbVersion = 6;
|
||||
static const albumStoreName = "albums";
|
||||
static const file2StoreName = "files2";
|
||||
static const dirStoreName = "dirs";
|
||||
|
@ -193,6 +193,10 @@ class AppDb {
|
|||
metaStore =
|
||||
db.createObjectStore(metaStoreName, keyPath: AppDbMetaEntry.keyPath);
|
||||
}
|
||||
if (event.oldVersion < 6) {
|
||||
file2Store.createIndex(AppDbFile2Entry.fileIsFavoriteIndexName,
|
||||
AppDbFile2Entry.fileIsFavoriteKeyPath);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onPostUpgrade(
|
||||
|
@ -264,6 +268,9 @@ class AppDbFile2Entry with EquatableMixin {
|
|||
static const dateTimeEpochMsIndexName = "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,
|
||||
this.dateTimeEpochMs, this.file);
|
||||
|
||||
|
@ -332,6 +339,14 @@ class AppDbFile2Entry with EquatableMixin {
|
|||
epochMs,
|
||||
];
|
||||
|
||||
static List<Object> toFileIsFavoriteIndexKey(
|
||||
Account account, bool isFavorite) =>
|
||||
[
|
||||
account.url,
|
||||
account.username.toCaseInsensitiveString(),
|
||||
isFavorite ? 1 : 0,
|
||||
];
|
||||
|
||||
@override
|
||||
get props => [
|
||||
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();
|
||||
_fileTrashbinRestoredEventListener.begin();
|
||||
_fileMovedEventListener.begin();
|
||||
_favoriteResyncedEventListener.begin();
|
||||
_prefUpdatedEventListener.begin();
|
||||
_accountPrefUpdatedEventListener.begin();
|
||||
}
|
||||
|
@ -159,6 +160,7 @@ class ScanAccountDirBloc
|
|||
_filePropertyUpdatedEventListener.end();
|
||||
_fileTrashbinRestoredEventListener.end();
|
||||
_fileMovedEventListener.end();
|
||||
_favoriteResyncedEventListener.end();
|
||||
_prefUpdatedEventListener.end();
|
||||
_accountPrefUpdatedEventListener.end();
|
||||
|
||||
|
@ -208,6 +210,7 @@ class ScanAccountDirBloc
|
|||
FilePropertyUpdatedEvent.propMetadata,
|
||||
FilePropertyUpdatedEvent.propIsArchived,
|
||||
FilePropertyUpdatedEvent.propOverrideDateTime,
|
||||
FilePropertyUpdatedEvent.propFavorite,
|
||||
])) {
|
||||
// not interested
|
||||
return;
|
||||
|
@ -223,6 +226,7 @@ class ScanAccountDirBloc
|
|||
if (ev.hasAnyProperties([
|
||||
FilePropertyUpdatedEvent.propIsArchived,
|
||||
FilePropertyUpdatedEvent.propOverrideDateTime,
|
||||
FilePropertyUpdatedEvent.propFavorite,
|
||||
])) {
|
||||
_refreshThrottler.trigger(
|
||||
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) {
|
||||
if (state is ScanAccountDirBlocInit) {
|
||||
// no data in this bloc, ignore
|
||||
|
@ -460,6 +477,8 @@ class ScanAccountDirBloc
|
|||
AppEventListener<FileTrashbinRestoredEvent>(_onFileTrashbinRestoredEvent);
|
||||
late final _fileMovedEventListener =
|
||||
AppEventListener<FileMovedEvent>(_onFileMovedEvent);
|
||||
late final _favoriteResyncedEventListener =
|
||||
AppEventListener<FavoriteResyncedEvent>(_onFavoriteResyncedEvent);
|
||||
late final _prefUpdatedEventListener =
|
||||
AppEventListener<PrefUpdatedEvent>(_onPrefUpdatedEvent);
|
||||
late final _accountPrefUpdatedEventListener =
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:nc_photos/app_db.dart';
|
||||
import 'package:nc_photos/entity/album.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/person.dart';
|
||||
import 'package:nc_photos/entity/share.dart';
|
||||
|
@ -15,6 +16,7 @@ enum DiType {
|
|||
personRepo,
|
||||
shareRepo,
|
||||
shareeRepo,
|
||||
favoriteRepo,
|
||||
appDb,
|
||||
pref,
|
||||
}
|
||||
|
@ -27,6 +29,7 @@ class DiContainer {
|
|||
PersonRepo? personRepo,
|
||||
ShareRepo? shareRepo,
|
||||
ShareeRepo? shareeRepo,
|
||||
FavoriteRepo? favoriteRepo,
|
||||
AppDb? appDb,
|
||||
Pref? pref,
|
||||
}) : _albumRepo = albumRepo,
|
||||
|
@ -35,6 +38,7 @@ class DiContainer {
|
|||
_personRepo = personRepo,
|
||||
_shareRepo = shareRepo,
|
||||
_shareeRepo = shareeRepo,
|
||||
_favoriteRepo = favoriteRepo,
|
||||
_appDb = appDb,
|
||||
_pref = pref;
|
||||
|
||||
|
@ -52,6 +56,8 @@ class DiContainer {
|
|||
return contianer._shareRepo != null;
|
||||
case DiType.shareeRepo:
|
||||
return contianer._shareeRepo != null;
|
||||
case DiType.favoriteRepo:
|
||||
return contianer._favoriteRepo != null;
|
||||
case DiType.appDb:
|
||||
return contianer._appDb != null;
|
||||
case DiType.pref:
|
||||
|
@ -66,6 +72,7 @@ class DiContainer {
|
|||
OrNull<PersonRepo>? personRepo,
|
||||
OrNull<ShareRepo>? shareRepo,
|
||||
OrNull<ShareeRepo>? shareeRepo,
|
||||
OrNull<FavoriteRepo>? favoriteRepo,
|
||||
OrNull<AppDb>? appDb,
|
||||
OrNull<Pref>? pref,
|
||||
}) {
|
||||
|
@ -76,6 +83,7 @@ class DiContainer {
|
|||
personRepo: personRepo == null ? _personRepo : personRepo.obj,
|
||||
shareRepo: shareRepo == null ? _shareRepo : shareRepo.obj,
|
||||
shareeRepo: shareeRepo == null ? _shareeRepo : shareeRepo.obj,
|
||||
favoriteRepo: favoriteRepo == null ? _favoriteRepo : favoriteRepo.obj,
|
||||
appDb: appDb == null ? _appDb : appDb.obj,
|
||||
pref: pref == null ? _pref : pref.obj,
|
||||
);
|
||||
|
@ -87,6 +95,7 @@ class DiContainer {
|
|||
PersonRepo get personRepo => _personRepo!;
|
||||
ShareRepo get shareRepo => _shareRepo!;
|
||||
ShareeRepo get shareeRepo => _shareeRepo!;
|
||||
FavoriteRepo get favoriteRepo => _favoriteRepo!;
|
||||
|
||||
AppDb get appDb => _appDb!;
|
||||
Pref get pref => _pref!;
|
||||
|
@ -97,6 +106,7 @@ class DiContainer {
|
|||
final PersonRepo? _personRepo;
|
||||
final ShareRepo? _shareRepo;
|
||||
final ShareeRepo? _shareeRepo;
|
||||
final FavoriteRepo? _favoriteRepo;
|
||||
|
||||
final AppDb? _appDb;
|
||||
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/debug_util.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/string_extension.dart';
|
||||
import 'package:nc_photos/type.dart';
|
||||
|
@ -229,6 +230,7 @@ class File with EquatableMixin {
|
|||
this.usedBytes,
|
||||
this.hasPreview,
|
||||
this.fileId,
|
||||
this.isFavorite,
|
||||
this.ownerId,
|
||||
this.metadata,
|
||||
this.isArchived,
|
||||
|
@ -265,6 +267,7 @@ class File with EquatableMixin {
|
|||
usedBytes: json["usedBytes"],
|
||||
hasPreview: json["hasPreview"],
|
||||
fileId: json["fileId"],
|
||||
isFavorite: json_util.boolFromJson(json["isFavorite"]),
|
||||
ownerId: json["ownerId"] == null ? null : CiString(json["ownerId"]),
|
||||
trashbinFilename: json["trashbinFilename"],
|
||||
trashbinOriginalLocation: json["trashbinOriginalLocation"],
|
||||
|
@ -319,6 +322,9 @@ class File with EquatableMixin {
|
|||
if (fileId != null) {
|
||||
product += "fileId: $fileId, ";
|
||||
}
|
||||
if (isFavorite != null) {
|
||||
product += "isFavorite: $isFavorite, ";
|
||||
}
|
||||
if (ownerId != null) {
|
||||
product += "ownerId: '$ownerId', ";
|
||||
}
|
||||
|
@ -355,6 +361,7 @@ class File with EquatableMixin {
|
|||
if (usedBytes != null) "usedBytes": usedBytes,
|
||||
if (hasPreview != null) "hasPreview": hasPreview,
|
||||
if (fileId != null) "fileId": fileId,
|
||||
if (isFavorite != null) "isFavorite": json_util.boolToJson(isFavorite),
|
||||
if (ownerId != null) "ownerId": ownerId.toString(),
|
||||
if (trashbinFilename != null) "trashbinFilename": trashbinFilename,
|
||||
if (trashbinOriginalLocation != null)
|
||||
|
@ -378,6 +385,7 @@ class File with EquatableMixin {
|
|||
int? usedBytes,
|
||||
bool? hasPreview,
|
||||
int? fileId,
|
||||
bool? isFavorite,
|
||||
CiString? ownerId,
|
||||
String? trashbinFilename,
|
||||
String? trashbinOriginalLocation,
|
||||
|
@ -396,6 +404,7 @@ class File with EquatableMixin {
|
|||
usedBytes: usedBytes ?? this.usedBytes,
|
||||
hasPreview: hasPreview ?? this.hasPreview,
|
||||
fileId: fileId ?? this.fileId,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
ownerId: ownerId ?? this.ownerId,
|
||||
trashbinFilename: trashbinFilename ?? this.trashbinFilename,
|
||||
trashbinOriginalLocation:
|
||||
|
@ -420,6 +429,7 @@ class File with EquatableMixin {
|
|||
usedBytes,
|
||||
hasPreview,
|
||||
fileId,
|
||||
isFavorite,
|
||||
ownerId,
|
||||
trashbinFilename,
|
||||
trashbinOriginalLocation,
|
||||
|
@ -439,6 +449,7 @@ class File with EquatableMixin {
|
|||
final bool? hasPreview;
|
||||
// maybe null when loaded from old cache
|
||||
final int? fileId;
|
||||
final bool? isFavorite;
|
||||
final CiString? ownerId;
|
||||
final String? trashbinFilename;
|
||||
final String? trashbinOriginalLocation;
|
||||
|
@ -564,6 +575,7 @@ class FileRepo {
|
|||
OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
bool? favorite,
|
||||
}) =>
|
||||
dataSrc.updateProperty(
|
||||
account,
|
||||
|
@ -571,6 +583,7 @@ class FileRepo {
|
|||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
favorite: favorite,
|
||||
);
|
||||
|
||||
/// See [FileDataSource.copy]
|
||||
|
@ -631,6 +644,7 @@ abstract class FileDataSource {
|
|||
OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
bool? favorite,
|
||||
});
|
||||
|
||||
/// Copy [f] to [destination]
|
||||
|
|
|
@ -42,6 +42,7 @@ class FileWebdavDataSource implements FileDataSource {
|
|||
getcontentlength: 1,
|
||||
hasPreview: 1,
|
||||
fileid: 1,
|
||||
favorite: 1,
|
||||
ownerId: 1,
|
||||
trashbinFilename: 1,
|
||||
trashbinOriginalLocation: 1,
|
||||
|
@ -147,6 +148,7 @@ class FileWebdavDataSource implements FileDataSource {
|
|||
OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
bool? favorite,
|
||||
}) async {
|
||||
_log.info("[updateProperty] ${f.path}");
|
||||
if (metadata?.obj != null && metadata!.obj!.fileEtag != f.etag) {
|
||||
|
@ -160,6 +162,7 @@ class FileWebdavDataSource implements FileDataSource {
|
|||
if (overrideDateTime?.obj != null)
|
||||
"app:override-date-time":
|
||||
overrideDateTime!.obj!.toUtc().toIso8601String(),
|
||||
if (favorite != null) "oc:favorite": favorite ? 1 : 0,
|
||||
};
|
||||
final removeProps = [
|
||||
if (OrNull.isSetNull(metadata)) "app:metadata",
|
||||
|
@ -170,6 +173,7 @@ class FileWebdavDataSource implements FileDataSource {
|
|||
path: f.path,
|
||||
namespaces: {
|
||||
"com.nkming.nc_photos": "app",
|
||||
"http://owncloud.org/ns": "oc",
|
||||
},
|
||||
set: setProps.isNotEmpty ? setProps : null,
|
||||
remove: removeProps.isNotEmpty ? removeProps : null,
|
||||
|
@ -372,6 +376,7 @@ class FileAppDbDataSource implements FileDataSource {
|
|||
OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
bool? favorite,
|
||||
}) {
|
||||
_log.info("[updateProperty] ${f.path}");
|
||||
return appDb.use((db) async {
|
||||
|
@ -383,6 +388,7 @@ class FileAppDbDataSource implements FileDataSource {
|
|||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
isFavorite: favorite,
|
||||
);
|
||||
final fileStore = transaction.objectStore(AppDb.file2StoreName);
|
||||
await fileStore.put(AppDbFile2Entry.fromFile(account, newFile).toJson(),
|
||||
|
@ -500,6 +506,7 @@ class FileCachedDataSource implements FileDataSource {
|
|||
OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
bool? favorite,
|
||||
}) async {
|
||||
await _remoteSrc
|
||||
.updateProperty(
|
||||
|
@ -508,6 +515,7 @@ class FileCachedDataSource implements FileDataSource {
|
|||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
favorite: favorite,
|
||||
)
|
||||
.then((_) => _appDbSrc.updateProperty(
|
||||
account,
|
||||
|
@ -515,6 +523,7 @@ class FileCachedDataSource implements FileDataSource {
|
|||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
favorite: favorite,
|
||||
));
|
||||
|
||||
// generate a new random token
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:logging/logging.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/string_extension.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
@ -10,6 +11,9 @@ import 'package:xml/xml.dart';
|
|||
class WebdavResponseParser {
|
||||
List<File> parseFiles(XmlDocument xml) => _parse<File>(xml, _toFile);
|
||||
|
||||
List<Favorite> parseFavorites(XmlDocument xml) =>
|
||||
_parse<Favorite>(xml, _toFavorite);
|
||||
|
||||
Map<String, String> get namespaces => _namespaces;
|
||||
|
||||
List<T> _parse<T>(XmlDocument xml, T Function(XmlElement) mapper) {
|
||||
|
@ -67,6 +71,7 @@ class WebdavResponseParser {
|
|||
int? usedBytes;
|
||||
bool? hasPreview;
|
||||
int? fileId;
|
||||
bool? isFavorite;
|
||||
CiString? ownerId;
|
||||
Metadata? metadata;
|
||||
bool? isArchived;
|
||||
|
@ -103,6 +108,7 @@ class WebdavResponseParser {
|
|||
usedBytes = propParser.usedBytes;
|
||||
hasPreview = propParser.hasPreview;
|
||||
fileId = propParser.fileId;
|
||||
isFavorite = propParser.isFavorite;
|
||||
ownerId = propParser.ownerId;
|
||||
metadata = propParser.metadata;
|
||||
isArchived = propParser.isArchived;
|
||||
|
@ -123,6 +129,7 @@ class WebdavResponseParser {
|
|||
usedBytes: usedBytes,
|
||||
hasPreview: hasPreview,
|
||||
fileId: fileId,
|
||||
isFavorite: isFavorite,
|
||||
ownerId: ownerId,
|
||||
metadata: metadata,
|
||||
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) {
|
||||
final rawPath = Uri.decodeComponent(href.innerText).trimLeftAny("/");
|
||||
final pos = rawPath.indexOf("remote.php");
|
||||
|
@ -186,6 +227,9 @@ class _FilePropParser {
|
|||
} else if (child.matchQualifiedName("fileid",
|
||||
prefix: "http://owncloud.org/ns", namespaces: namespaces)) {
|
||||
_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",
|
||||
prefix: "http://owncloud.org/ns", namespaces: namespaces)) {
|
||||
_ownerId = child.innerText.toCi();
|
||||
|
@ -234,6 +278,7 @@ class _FilePropParser {
|
|||
bool? get isCollection => _isCollection;
|
||||
bool? get hasPreview => _hasPreview;
|
||||
int? get fileId => _fileId;
|
||||
bool? get isFavorite => _isFavorite;
|
||||
CiString? get ownerId => _ownerId;
|
||||
Metadata? get metadata => _metadata;
|
||||
bool? get isArchived => _isArchived;
|
||||
|
@ -255,6 +300,7 @@ class _FilePropParser {
|
|||
bool? _isCollection;
|
||||
bool? _hasPreview;
|
||||
int? _fileId;
|
||||
bool? _isFavorite;
|
||||
CiString? _ownerId;
|
||||
Metadata? _metadata;
|
||||
bool? _isArchived;
|
||||
|
@ -264,6 +310,32 @@ class _FilePropParser {
|
|||
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 {
|
||||
bool matchQualifiedName(
|
||||
String local, {
|
||||
|
|
|
@ -70,6 +70,7 @@ class FilePropertyUpdatedEvent {
|
|||
static const propMetadata = 0x01;
|
||||
static const propIsArchived = 0x02;
|
||||
static const propOverrideDateTime = 0x04;
|
||||
static const propFavorite = 0x08;
|
||||
}
|
||||
|
||||
class FileRemovedEvent {
|
||||
|
@ -108,6 +109,15 @@ class ShareRemovedEvent {
|
|||
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 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": {
|
||||
"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": {
|
||||
|
|
|
@ -71,6 +71,13 @@
|
|||
"convertAlbumTooltip",
|
||||
"convertAlbumConfirmationDialogContent",
|
||||
"convertAlbumSuccessNotification",
|
||||
"collectionFavoritesLabel",
|
||||
"favoriteTooltip",
|
||||
"favoriteSuccessNotification",
|
||||
"favoriteFailureNotification",
|
||||
"unfavoriteTooltip",
|
||||
"unfavoriteSuccessNotification",
|
||||
"unfavoriteFailureNotification",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -160,6 +167,13 @@
|
|||
"convertAlbumTooltip",
|
||||
"convertAlbumConfirmationDialogContent",
|
||||
"convertAlbumSuccessNotification",
|
||||
"collectionFavoritesLabel",
|
||||
"favoriteTooltip",
|
||||
"favoriteSuccessNotification",
|
||||
"favoriteFailureNotification",
|
||||
"unfavoriteTooltip",
|
||||
"unfavoriteSuccessNotification",
|
||||
"unfavoriteFailureNotification",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -304,6 +318,13 @@
|
|||
"convertAlbumTooltip",
|
||||
"convertAlbumConfirmationDialogContent",
|
||||
"convertAlbumSuccessNotification",
|
||||
"collectionFavoritesLabel",
|
||||
"favoriteTooltip",
|
||||
"favoriteSuccessNotification",
|
||||
"favoriteFailureNotification",
|
||||
"unfavoriteTooltip",
|
||||
"unfavoriteSuccessNotification",
|
||||
"unfavoriteFailureNotification",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -331,6 +352,13 @@
|
|||
"convertAlbumTooltip",
|
||||
"convertAlbumConfirmationDialogContent",
|
||||
"convertAlbumSuccessNotification",
|
||||
"collectionFavoritesLabel",
|
||||
"favoriteTooltip",
|
||||
"favoriteSuccessNotification",
|
||||
"favoriteFailureNotification",
|
||||
"unfavoriteTooltip",
|
||||
"unfavoriteSuccessNotification",
|
||||
"unfavoriteFailureNotification",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -342,7 +370,14 @@
|
|||
"createCollectionDialogFolderDescription",
|
||||
"convertAlbumTooltip",
|
||||
"convertAlbumConfirmationDialogContent",
|
||||
"convertAlbumSuccessNotification"
|
||||
"convertAlbumSuccessNotification",
|
||||
"collectionFavoritesLabel",
|
||||
"favoriteTooltip",
|
||||
"favoriteSuccessNotification",
|
||||
"favoriteFailureNotification",
|
||||
"unfavoriteTooltip",
|
||||
"unfavoriteSuccessNotification",
|
||||
"unfavoriteFailureNotification"
|
||||
],
|
||||
|
||||
"fr": [
|
||||
|
@ -466,6 +501,13 @@
|
|||
"convertAlbumTooltip",
|
||||
"convertAlbumConfirmationDialogContent",
|
||||
"convertAlbumSuccessNotification",
|
||||
"collectionFavoritesLabel",
|
||||
"favoriteTooltip",
|
||||
"favoriteSuccessNotification",
|
||||
"favoriteFailureNotification",
|
||||
"unfavoriteTooltip",
|
||||
"unfavoriteSuccessNotification",
|
||||
"unfavoriteFailureNotification",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -477,7 +519,14 @@
|
|||
"createCollectionDialogFolderDescription",
|
||||
"convertAlbumTooltip",
|
||||
"convertAlbumConfirmationDialogContent",
|
||||
"convertAlbumSuccessNotification"
|
||||
"convertAlbumSuccessNotification",
|
||||
"collectionFavoritesLabel",
|
||||
"favoriteTooltip",
|
||||
"favoriteSuccessNotification",
|
||||
"favoriteFailureNotification",
|
||||
"unfavoriteTooltip",
|
||||
"unfavoriteSuccessNotification",
|
||||
"unfavoriteFailureNotification"
|
||||
],
|
||||
|
||||
"ru": [
|
||||
|
@ -574,6 +623,13 @@
|
|||
"convertAlbumTooltip",
|
||||
"convertAlbumConfirmationDialogContent",
|
||||
"convertAlbumSuccessNotification",
|
||||
"collectionFavoritesLabel",
|
||||
"favoriteTooltip",
|
||||
"favoriteSuccessNotification",
|
||||
"favoriteFailureNotification",
|
||||
"unfavoriteTooltip",
|
||||
"unfavoriteSuccessNotification",
|
||||
"unfavoriteFailureNotification",
|
||||
"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/face.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/data_source.dart';
|
||||
import 'package:nc_photos/entity/person.dart';
|
||||
|
@ -154,6 +156,7 @@ void _initDiContainer() {
|
|||
personRepo: const PersonRepo(PersonRemoteDataSource()),
|
||||
shareRepo: ShareRepo(ShareRemoteDataSource()),
|
||||
shareeRepo: ShareeRepo(ShareeRemoteDataSource()),
|
||||
favoriteRepo: const FavoriteRepo(FavoriteRemoteDataSource()),
|
||||
appDb: AppDb(),
|
||||
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<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
bool? favorite,
|
||||
}) async {
|
||||
if (metadata == null && isArchived == null && overrideDateTime == null) {
|
||||
if (metadata == null &&
|
||||
isArchived == null &&
|
||||
overrideDateTime == null &&
|
||||
favorite == null) {
|
||||
// ?
|
||||
_log.warning("[call] Nothing to update");
|
||||
return;
|
||||
|
@ -32,6 +36,7 @@ class UpdateProperty {
|
|||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
favorite: favorite,
|
||||
);
|
||||
|
||||
int properties = 0;
|
||||
|
@ -44,6 +49,9 @@ class UpdateProperty {
|
|||
if (overrideDateTime != null) {
|
||||
properties |= FilePropertyUpdatedEvent.propOverrideDateTime;
|
||||
}
|
||||
if (favorite != null) {
|
||||
properties |= FilePropertyUpdatedEvent.propFavorite;
|
||||
}
|
||||
assert(properties != 0);
|
||||
KiwiContainer()
|
||||
.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/dynamic_album_browser.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/new_album_dialog.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) {
|
||||
return _ButtonListItem(
|
||||
icon: Icons.person_outlined,
|
||||
|
@ -441,6 +455,7 @@ class _HomeAlbumsState extends State<HomeAlbums>
|
|||
final sortedAlbums =
|
||||
album_util.sorted(items.map((e) => e.album).toList(), sort);
|
||||
itemStreamListItems = [
|
||||
_buildFavoriteItem(context),
|
||||
if (AccountPref.of(widget.account).isEnableFaceRecognitionAppOr())
|
||||
_buildPersonItem(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_util.dart' as file_util;
|
||||
import 'package:nc_photos/event/event.dart';
|
||||
import 'package:nc_photos/exception.dart';
|
||||
import 'package:nc_photos/exception_util.dart' as exception_util;
|
||||
import 'package:nc_photos/iterable_extension.dart';
|
||||
import 'package:nc_photos/k.dart' as k;
|
||||
|
@ -26,6 +27,7 @@ import 'package:nc_photos/primitive.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/use_case/sync_favorite.dart';
|
||||
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/archive_selection_handler.dart';
|
||||
|
@ -369,6 +371,7 @@ class _HomePhotosState extends State<HomePhotos>
|
|||
state is ScanAccountDirBlocLoading) {
|
||||
_transformItems(state.files);
|
||||
if (state is ScanAccountDirBlocSuccess) {
|
||||
_syncFavorite();
|
||||
_tryStartMetadataTask();
|
||||
}
|
||||
} 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
|
||||
void _transformItems(List<File> 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);
|
||||
|
||||
var _backingFiles = <File>[];
|
||||
|
@ -760,6 +793,7 @@ class _ImageListItem extends _FileListItem {
|
|||
account: account,
|
||||
previewUrl: previewUrl,
|
||||
isGif: file.contentType == "image/gif",
|
||||
isFavorite: file.isFavorite == true,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -780,6 +814,7 @@ class _VideoListItem extends _FileListItem {
|
|||
return PhotoListVideo(
|
||||
account: account,
|
||||
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/connect.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/people_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 ??= _handleAlbumPickerRoute(settings);
|
||||
route ??= _handleSmartAlbumBrowserRoute(settings);
|
||||
route ??= _handleFavoriteBrowserRoute(settings);
|
||||
return route;
|
||||
}
|
||||
|
||||
|
@ -471,6 +473,20 @@ class _MyAppState extends State<MyApp> implements SnackBarHandler {
|
|||
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>();
|
||||
|
||||
late AppEventListener<ThemeChangedEvent> _themeChangedListener;
|
||||
|
|
|
@ -15,6 +15,7 @@ class PhotoListImage extends StatelessWidget {
|
|||
required this.previewUrl,
|
||||
this.padding = const EdgeInsets.all(2),
|
||||
this.isGif = false,
|
||||
this.isFavorite = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -74,6 +75,18 @@ class PhotoListImage extends StatelessWidget {
|
|||
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 bool isGif;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final bool isFavorite;
|
||||
}
|
||||
|
||||
class PhotoListVideo extends StatelessWidget {
|
||||
|
@ -91,6 +105,7 @@ class PhotoListVideo extends StatelessWidget {
|
|||
Key? key,
|
||||
required this.account,
|
||||
required this.previewUrl,
|
||||
this.isFavorite = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -139,6 +154,18 @@ class PhotoListVideo extends StatelessWidget {
|
|||
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 String previewUrl;
|
||||
final bool isFavorite;
|
||||
}
|
||||
|
||||
class PhotoListLabel extends StatelessWidget {
|
||||
|
|
|
@ -5,17 +5,21 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/app_localizations.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/download_handler.dart';
|
||||
import 'package:nc_photos/entity/album.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/k.dart' as k;
|
||||
import 'package:nc_photos/notified_action.dart';
|
||||
import 'package:nc_photos/pref.dart';
|
||||
import 'package:nc_photos/share_handler.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/disposable.dart';
|
||||
import 'package:nc_photos/widget/handler/remove_selection_handler.dart';
|
||||
|
@ -125,6 +129,9 @@ class _ViewerState extends State<Viewer>
|
|||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context) {
|
||||
final index =
|
||||
_isViewerLoaded ? _viewerController.currentPage : widget.startIndex;
|
||||
final file = widget.streamFiles[index];
|
||||
return Wrap(
|
||||
children: [
|
||||
AnimatedVisibility(
|
||||
|
@ -151,12 +158,25 @@ class _ViewerState extends State<Viewer>
|
|||
shadowColor: Colors.transparent,
|
||||
foregroundColor: Colors.white.withOpacity(.87),
|
||||
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(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
tooltip: L10n.global().detailsTooltip,
|
||||
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() {
|
||||
if (!_isDetailPaneActive) {
|
||||
setState(() {
|
||||
|
@ -614,4 +700,7 @@ class _PageState {
|
|||
ScrollController scrollController;
|
||||
double? itemHeight;
|
||||
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
|
||||
Future<void> updateProperty(Account account, File file,
|
||||
{OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime}) {
|
||||
Future<void> updateProperty(
|
||||
Account account,
|
||||
File file, {
|
||||
OrNull<Metadata>? metadata,
|
||||
OrNull<bool>? isArchived,
|
||||
OrNull<DateTime>? overrideDateTime,
|
||||
bool? favorite,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue