Merge branch 'feat-favorites' into dev

This commit is contained in:
Ming Ming 2022-02-08 18:29:45 +08:00
commit 73980ddf3f
31 changed files with 1650 additions and 9 deletions

View file

@ -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");
}

View file

@ -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
View 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");
}

View file

@ -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 =

View file

@ -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
View 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);
}

View 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");
}

View file

@ -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]

View file

@ -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

View file

@ -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, {

View file

@ -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 {}

View 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
View 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);

View file

@ -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": {

View file

@ -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
View 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([], []);
}
}

View file

@ -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(),
));

View 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");
}

View 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");
}

View 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;
}

View 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");
}

View file

@ -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>()

View 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,
}

View file

@ -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),

View file

@ -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,
);
}

View file

@ -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;

View file

@ -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 {

View file

@ -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,13 +158,26 @@ 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;
}

View 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
View 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]);
}

View file

@ -242,10 +242,14 @@ class MockFileRepo implements FileRepo {
}
@override
Future<void> updateProperty(Account account, File file,
{OrNull<Metadata>? metadata,
Future<void> updateProperty(
Account account,
File file, {
OrNull<Metadata>? metadata,
OrNull<bool>? isArchived,
OrNull<DateTime>? overrideDateTime}) {
OrNull<DateTime>? overrideDateTime,
bool? favorite,
}) {
throw UnimplementedError();
}
}