mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-02-08 18:28:53 +01:00
Handle files in trash bin
This commit is contained in:
parent
de9c6e15ef
commit
2812e1336e
12 changed files with 1230 additions and 10 deletions
|
@ -3,6 +3,7 @@ import 'package:logging/logging.dart';
|
||||||
import 'package:nc_photos/account.dart';
|
import 'package:nc_photos/account.dart';
|
||||||
import 'package:nc_photos/api/api.dart';
|
import 'package:nc_photos/api/api.dart';
|
||||||
import 'package:nc_photos/entity/file.dart';
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
import 'package:nc_photos/exception.dart';
|
import 'package:nc_photos/exception.dart';
|
||||||
|
|
||||||
/// Return the preview image URL for [file]. See [getFilePreviewUrlRelative]
|
/// Return the preview image URL for [file]. See [getFilePreviewUrlRelative]
|
||||||
|
@ -15,13 +16,14 @@ String getFilePreviewUrl(
|
||||||
bool? a,
|
bool? a,
|
||||||
}) {
|
}) {
|
||||||
return "${account.url}/"
|
return "${account.url}/"
|
||||||
"${getFilePreviewUrlRelative(file, width: width, height: height, mode: mode, a: a)}";
|
"${getFilePreviewUrlRelative(account, file, width: width, height: height, mode: mode, a: a)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the relative preview image URL for [file]. If [a] == true, the
|
/// Return the relative preview image URL for [file]. If [a] == true, the
|
||||||
/// preview will maintain the original aspect ratio, otherwise it will be
|
/// preview will maintain the original aspect ratio, otherwise it will be
|
||||||
/// cropped
|
/// cropped
|
||||||
String getFilePreviewUrlRelative(
|
String getFilePreviewUrlRelative(
|
||||||
|
Account account,
|
||||||
File file, {
|
File file, {
|
||||||
required int width,
|
required int width,
|
||||||
required int height,
|
required int height,
|
||||||
|
@ -29,12 +31,18 @@ String getFilePreviewUrlRelative(
|
||||||
bool? a,
|
bool? a,
|
||||||
}) {
|
}) {
|
||||||
String url;
|
String url;
|
||||||
|
if (file_util.isTrash(account, file)) {
|
||||||
|
// trashbin does not support preview.png endpoint
|
||||||
|
url = "index.php/apps/files_trashbin/preview?fileId=${file.fileId}";
|
||||||
|
} else {
|
||||||
if (file.fileId != null) {
|
if (file.fileId != null) {
|
||||||
url = "index.php/core/preview?fileId=${file.fileId}";
|
url = "index.php/core/preview?fileId=${file.fileId}";
|
||||||
} else {
|
} else {
|
||||||
final filePath = Uri.encodeQueryComponent(file.strippedPath);
|
final filePath = Uri.encodeQueryComponent(file.strippedPath);
|
||||||
url = "index.php/core/preview.png?file=$filePath";
|
url = "index.php/core/preview.png?file=$filePath";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
url = "$url&x=$width&y=$height";
|
url = "$url&x=$width&y=$height";
|
||||||
if (mode != null) {
|
if (mode != null) {
|
||||||
url = "$url&mode=$mode";
|
url = "$url&mode=$mode";
|
||||||
|
@ -56,6 +64,9 @@ String getFileUrlRelative(File file) {
|
||||||
String getWebdavRootUrlRelative(Account account) =>
|
String getWebdavRootUrlRelative(Account account) =>
|
||||||
"remote.php/dav/files/${account.username}";
|
"remote.php/dav/files/${account.username}";
|
||||||
|
|
||||||
|
String getTrashbinPath(Account account) =>
|
||||||
|
"remote.php/dav/trashbin/${account.username}/trash";
|
||||||
|
|
||||||
/// Query the app password for [account]
|
/// Query the app password for [account]
|
||||||
Future<String> exchangePassword(Account account) async {
|
Future<String> exchangePassword(Account account) async {
|
||||||
final response = await Api(account).request(
|
final response = await Api(account).request(
|
||||||
|
|
187
lib/bloc/ls_trashbin.dart
Normal file
187
lib/bloc/ls_trashbin.dart
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
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/entity/file.dart';
|
||||||
|
import 'package:nc_photos/entity/file/data_source.dart';
|
||||||
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
|
import 'package:nc_photos/event/event.dart';
|
||||||
|
import 'package:nc_photos/throttler.dart';
|
||||||
|
import 'package:nc_photos/use_case/ls_trashbin.dart';
|
||||||
|
|
||||||
|
abstract class LsTrashbinBlocEvent {
|
||||||
|
const LsTrashbinBlocEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class LsTrashbinBlocQuery extends LsTrashbinBlocEvent {
|
||||||
|
const LsTrashbinBlocQuery(this.account);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"account: $account, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An external event has happened and may affect the state of this bloc
|
||||||
|
class _LsTrashbinBlocExternalEvent extends LsTrashbinBlocEvent {
|
||||||
|
const _LsTrashbinBlocExternalEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class LsTrashbinBlocState {
|
||||||
|
const LsTrashbinBlocState(this.account, this.items);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"account: $account, "
|
||||||
|
"items: List {length: ${items.length}}, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account? account;
|
||||||
|
final List<File> items;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LsTrashbinBlocInit extends LsTrashbinBlocState {
|
||||||
|
LsTrashbinBlocInit() : super(null, const []);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LsTrashbinBlocLoading extends LsTrashbinBlocState {
|
||||||
|
const LsTrashbinBlocLoading(Account? account, List<File> items)
|
||||||
|
: super(account, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LsTrashbinBlocSuccess extends LsTrashbinBlocState {
|
||||||
|
const LsTrashbinBlocSuccess(Account? account, List<File> items)
|
||||||
|
: super(account, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LsTrashbinBlocFailure extends LsTrashbinBlocState {
|
||||||
|
const LsTrashbinBlocFailure(
|
||||||
|
Account? account, List<File> items, this.exception)
|
||||||
|
: super(account, items);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"super: ${super.toString()}, "
|
||||||
|
"exception: $exception, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final dynamic exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state of this bloc is inconsistent. This typically means that the data
|
||||||
|
/// may have been changed externally
|
||||||
|
class LsTrashbinBlocInconsistent extends LsTrashbinBlocState {
|
||||||
|
const LsTrashbinBlocInconsistent(Account? account, List<File> items)
|
||||||
|
: super(account, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LsTrashbinBloc extends Bloc<LsTrashbinBlocEvent, LsTrashbinBlocState> {
|
||||||
|
LsTrashbinBloc() : super(LsTrashbinBlocInit()) {
|
||||||
|
_fileRemovedEventListener =
|
||||||
|
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
|
||||||
|
_fileTrashbinRestoredEventListener =
|
||||||
|
AppEventListener<FileTrashbinRestoredEvent>(
|
||||||
|
_onFileTrashbinRestoredEvent);
|
||||||
|
_fileRemovedEventListener.begin();
|
||||||
|
_fileTrashbinRestoredEventListener.begin();
|
||||||
|
|
||||||
|
_refreshThrottler = Throttler(
|
||||||
|
onTriggered: (_) {
|
||||||
|
add(_LsTrashbinBlocExternalEvent());
|
||||||
|
},
|
||||||
|
logTag: "LsTrashbinBloc.refresh",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static LsTrashbinBloc of(Account account) {
|
||||||
|
final id = "${account.scheme}://${account.username}@${account.address}";
|
||||||
|
try {
|
||||||
|
_log.fine("[of] Resolving bloc for '$id'");
|
||||||
|
return KiwiContainer().resolve<LsTrashbinBloc>("LsTrashbinBloc($id)");
|
||||||
|
} catch (_) {
|
||||||
|
// no created instance for this account, make a new one
|
||||||
|
_log.info("[of] New bloc instance for account: $account");
|
||||||
|
final bloc = LsTrashbinBloc();
|
||||||
|
KiwiContainer()
|
||||||
|
.registerInstance<LsTrashbinBloc>(bloc, name: "LsTrashbinBloc($id)");
|
||||||
|
return bloc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
mapEventToState(LsTrashbinBlocEvent event) async* {
|
||||||
|
_log.info("[mapEventToState] $event");
|
||||||
|
if (event is LsTrashbinBlocQuery) {
|
||||||
|
yield* _onEventQuery(event);
|
||||||
|
} else if (event is _LsTrashbinBlocExternalEvent) {
|
||||||
|
yield* _onExternalEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<LsTrashbinBlocState> _onEventQuery(LsTrashbinBlocQuery ev) async* {
|
||||||
|
try {
|
||||||
|
yield LsTrashbinBlocLoading(ev.account, state.items);
|
||||||
|
yield LsTrashbinBlocSuccess(ev.account, await _query(ev));
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[_onEventQuery] Exception while request", e);
|
||||||
|
yield LsTrashbinBlocFailure(ev.account, state.items, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<LsTrashbinBlocState> _onExternalEvent(
|
||||||
|
_LsTrashbinBlocExternalEvent ev) async* {
|
||||||
|
yield LsTrashbinBlocInconsistent(state.account, state.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFileRemovedEvent(FileRemovedEvent ev) {
|
||||||
|
if (state is LsTrashbinBlocInit) {
|
||||||
|
// no data in this bloc, ignore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file_util.isTrash(ev.account, ev.file)) {
|
||||||
|
_refreshThrottler.trigger(
|
||||||
|
maxResponceTime: const Duration(seconds: 3),
|
||||||
|
maxPendingCount: 10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFileTrashbinRestoredEvent(FileTrashbinRestoredEvent ev) {
|
||||||
|
if (state is LsTrashbinBlocInit) {
|
||||||
|
// no data in this bloc, ignore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_refreshThrottler.trigger(
|
||||||
|
maxResponceTime: const Duration(seconds: 3),
|
||||||
|
maxPendingCount: 10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<File>> _query(LsTrashbinBlocQuery ev) {
|
||||||
|
// caching contents in trashbin doesn't sounds useful
|
||||||
|
final fileRepo = FileRepo(FileWebdavDataSource());
|
||||||
|
return LsTrashbin(fileRepo)(ev.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final AppEventListener<FileRemovedEvent> _fileRemovedEventListener;
|
||||||
|
late final AppEventListener<FileTrashbinRestoredEvent>
|
||||||
|
_fileTrashbinRestoredEventListener;
|
||||||
|
|
||||||
|
late Throttler _refreshThrottler;
|
||||||
|
|
||||||
|
static final _log = Logger("bloc.ls_trashbin.LsTrashbinBloc");
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import 'package:logging/logging.dart';
|
||||||
import 'package:nc_photos/account.dart';
|
import 'package:nc_photos/account.dart';
|
||||||
import 'package:nc_photos/entity/file.dart';
|
import 'package:nc_photos/entity/file.dart';
|
||||||
import 'package:nc_photos/entity/file/data_source.dart';
|
import 'package:nc_photos/entity/file/data_source.dart';
|
||||||
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
import 'package:nc_photos/event/event.dart';
|
import 'package:nc_photos/event/event.dart';
|
||||||
import 'package:nc_photos/iterable_extension.dart';
|
import 'package:nc_photos/iterable_extension.dart';
|
||||||
import 'package:nc_photos/throttler.dart';
|
import 'package:nc_photos/throttler.dart';
|
||||||
|
@ -118,8 +119,12 @@ class ScanDirBloc extends Bloc<ScanDirBlocEvent, ScanDirBlocState> {
|
||||||
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
|
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
|
||||||
_filePropertyUpdatedEventListener =
|
_filePropertyUpdatedEventListener =
|
||||||
AppEventListener<FilePropertyUpdatedEvent>(_onFilePropertyUpdatedEvent);
|
AppEventListener<FilePropertyUpdatedEvent>(_onFilePropertyUpdatedEvent);
|
||||||
|
_fileTrashbinRestoredEventListener =
|
||||||
|
AppEventListener<FileTrashbinRestoredEvent>(
|
||||||
|
_onFileTrashbinRestoredEvent);
|
||||||
_fileRemovedEventListener.begin();
|
_fileRemovedEventListener.begin();
|
||||||
_filePropertyUpdatedEventListener.begin();
|
_filePropertyUpdatedEventListener.begin();
|
||||||
|
_fileTrashbinRestoredEventListener.begin();
|
||||||
|
|
||||||
_refreshThrottler = Throttler(
|
_refreshThrottler = Throttler(
|
||||||
onTriggered: (_) {
|
onTriggered: (_) {
|
||||||
|
@ -171,6 +176,7 @@ class ScanDirBloc extends Bloc<ScanDirBlocEvent, ScanDirBlocState> {
|
||||||
close() {
|
close() {
|
||||||
_fileRemovedEventListener.end();
|
_fileRemovedEventListener.end();
|
||||||
_filePropertyUpdatedEventListener.end();
|
_filePropertyUpdatedEventListener.end();
|
||||||
|
_fileTrashbinRestoredEventListener.end();
|
||||||
_refreshThrottler.clear();
|
_refreshThrottler.clear();
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
|
@ -217,11 +223,13 @@ class ScanDirBloc extends Bloc<ScanDirBlocEvent, ScanDirBlocState> {
|
||||||
// no data in this bloc, ignore
|
// no data in this bloc, ignore
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!file_util.isTrash(ev.account, ev.file)) {
|
||||||
_refreshThrottler.trigger(
|
_refreshThrottler.trigger(
|
||||||
maxResponceTime: const Duration(seconds: 3),
|
maxResponceTime: const Duration(seconds: 3),
|
||||||
maxPendingCount: 10,
|
maxPendingCount: 10,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onFilePropertyUpdatedEvent(FilePropertyUpdatedEvent ev) {
|
void _onFilePropertyUpdatedEvent(FilePropertyUpdatedEvent ev) {
|
||||||
if (!ev.hasAnyProperties([
|
if (!ev.hasAnyProperties([
|
||||||
|
@ -253,6 +261,17 @@ class ScanDirBloc extends Bloc<ScanDirBlocEvent, ScanDirBlocState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onFileTrashbinRestoredEvent(FileTrashbinRestoredEvent ev) {
|
||||||
|
if (state is ScanDirBlocInit) {
|
||||||
|
// no data in this bloc, ignore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_refreshThrottler.trigger(
|
||||||
|
maxResponceTime: const Duration(seconds: 3),
|
||||||
|
maxPendingCount: 10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Stream<ScanDirBlocState> _queryOffline(
|
Stream<ScanDirBlocState> _queryOffline(
|
||||||
ScanDirBlocQueryBase ev, ScanDirBlocState Function() getState) =>
|
ScanDirBlocQueryBase ev, ScanDirBlocState Function() getState) =>
|
||||||
_queryWithFileDataSource(ev, getState, FileAppDbDataSource());
|
_queryWithFileDataSource(ev, getState, FileAppDbDataSource());
|
||||||
|
@ -287,6 +306,8 @@ class ScanDirBloc extends Bloc<ScanDirBlocEvent, ScanDirBlocState> {
|
||||||
late AppEventListener<FileRemovedEvent> _fileRemovedEventListener;
|
late AppEventListener<FileRemovedEvent> _fileRemovedEventListener;
|
||||||
late AppEventListener<FilePropertyUpdatedEvent>
|
late AppEventListener<FilePropertyUpdatedEvent>
|
||||||
_filePropertyUpdatedEventListener;
|
_filePropertyUpdatedEventListener;
|
||||||
|
late final AppEventListener<FileTrashbinRestoredEvent>
|
||||||
|
_fileTrashbinRestoredEventListener;
|
||||||
|
|
||||||
late Throttler _refreshThrottler;
|
late Throttler _refreshThrottler;
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||||
import 'package:nc_photos/entity/file.dart';
|
import 'package:nc_photos/entity/file.dart';
|
||||||
import 'package:nc_photos/platform/k.dart' as platform_k;
|
import 'package:nc_photos/platform/k.dart' as platform_k;
|
||||||
|
|
||||||
|
@ -13,6 +15,9 @@ bool isSupportedVideoFormat(File file) =>
|
||||||
bool isMetadataSupportedFormat(File file) =>
|
bool isMetadataSupportedFormat(File file) =>
|
||||||
_metadataSupportedFormatMimes.contains(file.contentType);
|
_metadataSupportedFormatMimes.contains(file.contentType);
|
||||||
|
|
||||||
|
bool isTrash(Account account, File file) =>
|
||||||
|
file.path.startsWith(api_util.getTrashbinPath(account));
|
||||||
|
|
||||||
/// For a path "remote.php/dav/files/foo/bar.jpg", return foo
|
/// For a path "remote.php/dav/files/foo/bar.jpg", return foo
|
||||||
String getUserDirName(File file) {
|
String getUserDirName(File file) {
|
||||||
if (file.path.startsWith("remote.php/dav/files/")) {
|
if (file.path.startsWith("remote.php/dav/files/")) {
|
||||||
|
|
|
@ -68,6 +68,13 @@ class FileRemovedEvent {
|
||||||
final File file;
|
final File file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FileTrashbinRestoredEvent {
|
||||||
|
FileTrashbinRestoredEvent(this.account, this.file);
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final File file;
|
||||||
|
}
|
||||||
|
|
||||||
class ThemeChangedEvent {}
|
class ThemeChangedEvent {}
|
||||||
|
|
||||||
class LanguageChangedEvent {}
|
class LanguageChangedEvent {}
|
||||||
|
|
|
@ -581,6 +581,60 @@
|
||||||
"@listNoResultsText": {
|
"@listNoResultsText": {
|
||||||
"description": "When there's nothing in a list"
|
"description": "When there's nothing in a list"
|
||||||
},
|
},
|
||||||
|
"albumTrashLabel": "Trash",
|
||||||
|
"@albumTrashLabel": {
|
||||||
|
"description": "Deleted photos"
|
||||||
|
},
|
||||||
|
"restoreTooltip": "Restore",
|
||||||
|
"@restoreTooltip": {
|
||||||
|
"description": "Restore selected items from trashbin"
|
||||||
|
},
|
||||||
|
"restoreSelectedProcessingNotification": "{count, plural, =1{Restoring 1 item} other{Restoring {count} items}}",
|
||||||
|
"@restoreSelectedProcessingNotification": {
|
||||||
|
"description": "Restoring selected items from trashbin",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"restoreSelectedSuccessNotification": "All items restored successfully",
|
||||||
|
"@restoreSelectedSuccessNotification": {
|
||||||
|
"description": "Restored all selected items from trashbin successfully"
|
||||||
|
},
|
||||||
|
"restoreSelectedFailureNotification": "{count, plural, =1{Failed restoring 1 item} other{Failed restoring {count} items}}",
|
||||||
|
"@restoreSelectedFailureNotification": {
|
||||||
|
"description": "Cannot restore some of the selected items from trashbin",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"restoreProcessingNotification": "Restoring item",
|
||||||
|
"@restoreProcessingNotification": {
|
||||||
|
"description": "Restoring the opened item from trashbin"
|
||||||
|
},
|
||||||
|
"restoreSuccessNotification": "Restored item successfully",
|
||||||
|
"@restoreSuccessNotification": {
|
||||||
|
"description": "Restored the opened item from trashbin successfully"
|
||||||
|
},
|
||||||
|
"restoreFailureNotification": "Failed restoring item",
|
||||||
|
"@restoreFailureNotification": {
|
||||||
|
"description": "Cannot restore the opened item from trashbin"
|
||||||
|
},
|
||||||
|
"deletePermanentlyTooltip": "Delete permanently",
|
||||||
|
"@deletePermanentlyTooltip": {
|
||||||
|
"description": "Permanently delete selected items from trashbin"
|
||||||
|
},
|
||||||
|
"deletePermanentlyConfirmationDialogTitle": "Delete permanently",
|
||||||
|
"@deletePermanentlyConfirmationDialogTitle": {
|
||||||
|
"description": "Make sure the user wants to delete the items"
|
||||||
|
},
|
||||||
|
"deletePermanentlyConfirmationDialogContent": "Selected items will be deleted permanently from the server.\n\nThis action is nonreversible",
|
||||||
|
"@deletePermanentlyConfirmationDialogContent": {
|
||||||
|
"description": "Make sure the user wants to delete the items"
|
||||||
|
},
|
||||||
|
|
||||||
"changelogTitle": "Changelog",
|
"changelogTitle": "Changelog",
|
||||||
"@changelogTitle": {
|
"@changelogTitle": {
|
||||||
|
|
13
lib/use_case/ls_trashbin.dart
Normal file
13
lib/use_case/ls_trashbin.dart
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/use_case/ls.dart';
|
||||||
|
|
||||||
|
class LsTrashbin {
|
||||||
|
LsTrashbin(this.fileRepo);
|
||||||
|
|
||||||
|
Future<List<File>> call(Account account) =>
|
||||||
|
Ls(fileRepo)(account, File(path: api_util.getTrashbinPath(account)));
|
||||||
|
|
||||||
|
final FileRepo fileRepo;
|
||||||
|
}
|
21
lib/use_case/restore_trashbin.dart
Normal file
21
lib/use_case/restore_trashbin.dart
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import 'package:event_bus/event_bus.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/event/event.dart';
|
||||||
|
import 'package:nc_photos/use_case/move.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
|
class RestoreTrashbin {
|
||||||
|
RestoreTrashbin(this.fileRepo);
|
||||||
|
|
||||||
|
Future<void> call(Account account, File file) async {
|
||||||
|
await Move(fileRepo).call(account, file,
|
||||||
|
"remote.php/dav/trashbin/${account.username}/restore/${path.basename(file.path)}");
|
||||||
|
KiwiContainer()
|
||||||
|
.resolve<EventBus>()
|
||||||
|
.fire(FileTrashbinRestoredEvent(account, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
final FileRepo fileRepo;
|
||||||
|
}
|
|
@ -29,6 +29,7 @@ import 'package:nc_photos/widget/home_app_bar.dart';
|
||||||
import 'package:nc_photos/widget/new_album_dialog.dart';
|
import 'package:nc_photos/widget/new_album_dialog.dart';
|
||||||
import 'package:nc_photos/widget/page_visibility_mixin.dart';
|
import 'package:nc_photos/widget/page_visibility_mixin.dart';
|
||||||
import 'package:nc_photos/widget/selection_app_bar.dart';
|
import 'package:nc_photos/widget/selection_app_bar.dart';
|
||||||
|
import 'package:nc_photos/widget/trashbin_browser.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
class HomeAlbums extends StatefulWidget {
|
class HomeAlbums extends StatefulWidget {
|
||||||
|
@ -107,7 +108,7 @@ class _HomeAlbumsState extends State<HomeAlbums>
|
||||||
sliver: SliverStaggeredGrid.extentBuilder(
|
sliver: SliverStaggeredGrid.extentBuilder(
|
||||||
maxCrossAxisExtent: 256,
|
maxCrossAxisExtent: 256,
|
||||||
mainAxisSpacing: 8,
|
mainAxisSpacing: 8,
|
||||||
itemCount: _items.length + (_isSelectionMode ? 0 : 2),
|
itemCount: _items.length + (_isSelectionMode ? 0 : 3),
|
||||||
itemBuilder: _buildItem,
|
itemBuilder: _buildItem,
|
||||||
staggeredTileBuilder: (index) {
|
staggeredTileBuilder: (index) {
|
||||||
return const StaggeredTile.count(1, 1);
|
return const StaggeredTile.count(1, 1);
|
||||||
|
@ -185,6 +186,8 @@ class _HomeAlbumsState extends State<HomeAlbums>
|
||||||
return _buildAlbumItem(context, index);
|
return _buildAlbumItem(context, index);
|
||||||
} else if (index == _items.length) {
|
} else if (index == _items.length) {
|
||||||
return _buildArchiveItem(context);
|
return _buildArchiveItem(context);
|
||||||
|
} else if (index == _items.length + 1) {
|
||||||
|
return _buildTrashbinItem(context);
|
||||||
} else {
|
} else {
|
||||||
return _buildNewAlbumItem(context);
|
return _buildNewAlbumItem(context);
|
||||||
}
|
}
|
||||||
|
@ -223,6 +226,28 @@ class _HomeAlbumsState extends State<HomeAlbums>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildTrashbinItem(BuildContext context) {
|
||||||
|
return AlbumGridItem(
|
||||||
|
cover: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
color: AppTheme.getListItemBackgroundColor(context),
|
||||||
|
constraints: const BoxConstraints.expand(),
|
||||||
|
child: Icon(
|
||||||
|
Icons.delete,
|
||||||
|
color: Colors.white.withOpacity(.8),
|
||||||
|
size: 88,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: L10n.of(context).albumTrashLabel,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pushNamed(TrashbinBrowser.routeName,
|
||||||
|
arguments: TrashbinBrowserArguments(widget.account));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildNewAlbumItem(BuildContext context) {
|
Widget _buildNewAlbumItem(BuildContext context) {
|
||||||
return AlbumGridItem(
|
return AlbumGridItem(
|
||||||
cover: ClipRRect(
|
cover: ClipRRect(
|
||||||
|
|
|
@ -20,6 +20,8 @@ import 'package:nc_photos/widget/settings.dart';
|
||||||
import 'package:nc_photos/widget/setup.dart';
|
import 'package:nc_photos/widget/setup.dart';
|
||||||
import 'package:nc_photos/widget/sign_in.dart';
|
import 'package:nc_photos/widget/sign_in.dart';
|
||||||
import 'package:nc_photos/widget/splash.dart';
|
import 'package:nc_photos/widget/splash.dart';
|
||||||
|
import 'package:nc_photos/widget/trashbin_browser.dart';
|
||||||
|
import 'package:nc_photos/widget/trashbin_viewer.dart';
|
||||||
import 'package:nc_photos/widget/viewer.dart';
|
import 'package:nc_photos/widget/viewer.dart';
|
||||||
|
|
||||||
class MyApp extends StatefulWidget {
|
class MyApp extends StatefulWidget {
|
||||||
|
@ -103,6 +105,8 @@ class _MyAppState extends State<MyApp> implements SnackBarHandler {
|
||||||
route ??= _handleDynamicAlbumBrowserRoute(settings);
|
route ??= _handleDynamicAlbumBrowserRoute(settings);
|
||||||
route ??= _handleAlbumDirPickerRoute(settings);
|
route ??= _handleAlbumDirPickerRoute(settings);
|
||||||
route ??= _handleAlbumImporterRoute(settings);
|
route ??= _handleAlbumImporterRoute(settings);
|
||||||
|
route ??= _handleTrashbinBrowserRoute(settings);
|
||||||
|
route ??= _handleTrashbinViewerRoute(settings);
|
||||||
return route;
|
return route;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -253,6 +257,34 @@ class _MyAppState extends State<MyApp> implements SnackBarHandler {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Route<dynamic>? _handleTrashbinBrowserRoute(RouteSettings settings) {
|
||||||
|
try {
|
||||||
|
if (settings.name == TrashbinBrowser.routeName &&
|
||||||
|
settings.arguments != null) {
|
||||||
|
final args = settings.arguments as TrashbinBrowserArguments;
|
||||||
|
return TrashbinBrowser.buildRoute(args);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe(
|
||||||
|
"[_handleTrashbinBrowserRoute] Failed while handling route", e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Route<dynamic>? _handleTrashbinViewerRoute(RouteSettings settings) {
|
||||||
|
try {
|
||||||
|
if (settings.name == TrashbinViewer.routeName &&
|
||||||
|
settings.arguments != null) {
|
||||||
|
final args = settings.arguments as TrashbinViewerArguments;
|
||||||
|
return TrashbinViewer.buildRoute(args);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe(
|
||||||
|
"[_handleTrashbinViewerRoute] Failed while handling route", e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||||
|
|
||||||
late AppEventListener<ThemeChangedEvent> _themeChangedListener;
|
late AppEventListener<ThemeChangedEvent> _themeChangedListener;
|
||||||
|
|
472
lib/widget/trashbin_browser.dart
Normal file
472
lib/widget/trashbin_browser.dart
Normal file
|
@ -0,0 +1,472 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
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:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||||
|
import 'package:nc_photos/app_localizations.dart';
|
||||||
|
import 'package:nc_photos/bloc/ls_trashbin.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/entity/file/data_source.dart';
|
||||||
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
|
import 'package:nc_photos/exception_util.dart' as exception_util;
|
||||||
|
import 'package:nc_photos/iterable_extension.dart';
|
||||||
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
import 'package:nc_photos/pref.dart';
|
||||||
|
import 'package:nc_photos/snack_bar_manager.dart';
|
||||||
|
import 'package:nc_photos/theme.dart';
|
||||||
|
import 'package:nc_photos/use_case/remove.dart';
|
||||||
|
import 'package:nc_photos/use_case/restore_trashbin.dart';
|
||||||
|
import 'package:nc_photos/widget/photo_list_item.dart';
|
||||||
|
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/trashbin_viewer.dart';
|
||||||
|
import 'package:nc_photos/widget/zoom_menu_button.dart';
|
||||||
|
|
||||||
|
class TrashbinBrowserArguments {
|
||||||
|
TrashbinBrowserArguments(this.account);
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TrashbinBrowser extends StatefulWidget {
|
||||||
|
static const routeName = "/trashbin-browser";
|
||||||
|
|
||||||
|
static Route buildRoute(TrashbinBrowserArguments args) => MaterialPageRoute(
|
||||||
|
builder: (context) => TrashbinBrowser.fromArgs(args),
|
||||||
|
);
|
||||||
|
|
||||||
|
TrashbinBrowser({
|
||||||
|
Key? key,
|
||||||
|
required this.account,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
TrashbinBrowser.fromArgs(TrashbinBrowserArguments args, {Key? key})
|
||||||
|
: this(
|
||||||
|
key: key,
|
||||||
|
account: args.account,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
createState() => _TrashbinBrowserState();
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrashbinBrowserState extends State<TrashbinBrowser>
|
||||||
|
with SelectableItemStreamListMixin<TrashbinBrowser> {
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
_initBloc();
|
||||||
|
_thumbZoomLevel = Pref.inst().getAlbumBrowserZoomLevelOr(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return AppTheme(
|
||||||
|
child: Scaffold(
|
||||||
|
body: BlocListener<LsTrashbinBloc, LsTrashbinBlocState>(
|
||||||
|
bloc: _bloc,
|
||||||
|
listener: (context, state) => _onStateChange(context, state),
|
||||||
|
child: BlocBuilder<LsTrashbinBloc, LsTrashbinBlocState>(
|
||||||
|
bloc: _bloc,
|
||||||
|
builder: (context, state) => _buildContent(context, state),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initBloc() {
|
||||||
|
_bloc = LsTrashbinBloc.of(widget.account);
|
||||||
|
if (_bloc.state is LsTrashbinBlocInit) {
|
||||||
|
_log.info("[_initBloc] Initialize bloc");
|
||||||
|
_reqQuery();
|
||||||
|
} else {
|
||||||
|
// process the current state
|
||||||
|
WidgetsBinding.instance!.addPostFrameCallback((_) {
|
||||||
|
setState(() {
|
||||||
|
_onStateChange(context, _bloc.state);
|
||||||
|
});
|
||||||
|
_reqQuery();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context, LsTrashbinBlocState state) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
buildItemStreamListOuter(
|
||||||
|
context,
|
||||||
|
child: Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
accentColor: AppTheme.getOverscrollIndicatorColor(context),
|
||||||
|
),
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
sliver: buildItemStreamList(
|
||||||
|
maxCrossAxisExtent: _thumbSize.toDouble(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state is LsTrashbinBlocLoading)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: const LinearProgressIndicator(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context) {
|
||||||
|
if (isSelectionMode) {
|
||||||
|
return _buildSelectionAppBar(context);
|
||||||
|
} else {
|
||||||
|
return _buildNormalAppBar(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectionAppBar(BuildContext context) {
|
||||||
|
return SelectionAppBar(
|
||||||
|
count: selectedListItems.length,
|
||||||
|
onClosePressed: () {
|
||||||
|
setState(() {
|
||||||
|
clearSelectedItems();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.restore_outlined),
|
||||||
|
tooltip: L10n.of(context).restoreTooltip,
|
||||||
|
onPressed: () {
|
||||||
|
_onSelectionAppBarRestorePressed();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
PopupMenuButton<_AppBarMenuOption>(
|
||||||
|
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: _AppBarMenuOption.delete,
|
||||||
|
child: Text(L10n.of(context).deletePermanentlyTooltip),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onSelected: (option) {
|
||||||
|
switch (option) {
|
||||||
|
case _AppBarMenuOption.delete:
|
||||||
|
_onSelectionAppBarDeletePressed(context);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
_log.shout("[_buildSelectionAppBar] Unknown option: $option");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNormalAppBar(BuildContext context) {
|
||||||
|
return SliverAppBar(
|
||||||
|
title: Text(L10n.of(context).albumTrashLabel),
|
||||||
|
floating: true,
|
||||||
|
actions: [
|
||||||
|
ZoomMenuButton(
|
||||||
|
initialZoom: _thumbZoomLevel,
|
||||||
|
minZoom: 0,
|
||||||
|
maxZoom: 2,
|
||||||
|
onZoomChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_thumbZoomLevel = value.round();
|
||||||
|
});
|
||||||
|
Pref.inst().setAlbumBrowserZoomLevel(_thumbZoomLevel);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onStateChange(BuildContext context, LsTrashbinBlocState state) {
|
||||||
|
if (state is LsTrashbinBlocInit) {
|
||||||
|
itemStreamListItems = [];
|
||||||
|
} else if (state is LsTrashbinBlocSuccess ||
|
||||||
|
state is LsTrashbinBlocLoading) {
|
||||||
|
_transformItems(state.items);
|
||||||
|
} else if (state is LsTrashbinBlocFailure) {
|
||||||
|
_transformItems(state.items);
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(exception_util.toUserString(state.exception, context)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
} else if (state is LsTrashbinBlocInconsistent) {
|
||||||
|
_reqQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onItemTap(int index) {
|
||||||
|
Navigator.pushNamed(context, TrashbinViewer.routeName,
|
||||||
|
arguments:
|
||||||
|
TrashbinViewerArguments(widget.account, _backingFiles, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSelectionAppBarRestorePressed() async {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(L10n.of(context)
|
||||||
|
.restoreSelectedProcessingNotification(selectedListItems.length)),
|
||||||
|
duration: k.snackBarDurationShort,
|
||||||
|
));
|
||||||
|
final selectedFiles = selectedListItems
|
||||||
|
.whereType<_FileListItem>()
|
||||||
|
.map((e) => e.file)
|
||||||
|
.toList();
|
||||||
|
setState(() {
|
||||||
|
clearSelectedItems();
|
||||||
|
});
|
||||||
|
final fileRepo = FileRepo(FileWebdavDataSource());
|
||||||
|
final failures = <File>[];
|
||||||
|
for (final f in selectedFiles) {
|
||||||
|
try {
|
||||||
|
await RestoreTrashbin(fileRepo)(widget.account, f);
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
_log.shout(
|
||||||
|
"[_onSelectionAppBarRestorePressed] Failed while restoring file" +
|
||||||
|
(kDebugMode ? ": ${f.path}" : ""),
|
||||||
|
e,
|
||||||
|
stacktrace);
|
||||||
|
failures.add(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.isEmpty) {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(L10n.of(context).restoreSelectedSuccessNotification),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(L10n.of(context)
|
||||||
|
.restoreSelectedFailureNotification(failures.length)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSelectionAppBarDeletePressed(BuildContext context) async {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: Text(L10n.of(context).deletePermanentlyConfirmationDialogTitle),
|
||||||
|
content:
|
||||||
|
Text(L10n.of(context).deletePermanentlyConfirmationDialogContent),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_deleteSelected(context);
|
||||||
|
},
|
||||||
|
child: Text(L10n.of(context).confirmButtonLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _transformItems(List<File> files) {
|
||||||
|
_backingFiles = files
|
||||||
|
.where((element) => file_util.isSupportedFormat(element))
|
||||||
|
.sorted((a, b) {
|
||||||
|
if (a.trashbinDeletionTime == null && b.trashbinDeletionTime == null) {
|
||||||
|
// ?
|
||||||
|
return 0;
|
||||||
|
} else if (a.trashbinDeletionTime == null) {
|
||||||
|
return -1;
|
||||||
|
} else if (b.trashbinDeletionTime == null) {
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
return b.trashbinDeletionTime!.compareTo(a.trashbinDeletionTime!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
itemStreamListItems = () sync* {
|
||||||
|
for (int i = 0; i < _backingFiles.length; ++i) {
|
||||||
|
final f = _backingFiles[i];
|
||||||
|
|
||||||
|
final previewUrl = api_util.getFilePreviewUrl(widget.account, f,
|
||||||
|
width: _thumbSize, height: _thumbSize);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteSelected(BuildContext context) async {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(L10n.of(context)
|
||||||
|
.deleteSelectedProcessingNotification(selectedListItems.length)),
|
||||||
|
duration: k.snackBarDurationShort,
|
||||||
|
));
|
||||||
|
final selectedFiles = selectedListItems
|
||||||
|
.whereType<_FileListItem>()
|
||||||
|
.map((e) => e.file)
|
||||||
|
.toList();
|
||||||
|
setState(() {
|
||||||
|
clearSelectedItems();
|
||||||
|
});
|
||||||
|
final fileRepo = FileRepo(FileCachedDataSource());
|
||||||
|
final failures = <File>[];
|
||||||
|
for (final f in selectedFiles) {
|
||||||
|
try {
|
||||||
|
await Remove(fileRepo, null)(widget.account, f);
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
_log.shout(
|
||||||
|
"[_deleteSelected] Failed while removing file" +
|
||||||
|
(kDebugMode ? ": ${f.path}" : ""),
|
||||||
|
e,
|
||||||
|
stacktrace);
|
||||||
|
failures.add(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.isEmpty) {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(L10n.of(context).deleteSelectedSuccessNotification),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(L10n.of(context)
|
||||||
|
.deleteSelectedFailureNotification(failures.length)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reqQuery() {
|
||||||
|
_bloc.add(LsTrashbinBlocQuery(widget.account));
|
||||||
|
}
|
||||||
|
|
||||||
|
int get _thumbSize {
|
||||||
|
switch (_thumbZoomLevel) {
|
||||||
|
case 1:
|
||||||
|
return 176;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
return 256;
|
||||||
|
|
||||||
|
case 0:
|
||||||
|
default:
|
||||||
|
return 112;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
late LsTrashbinBloc _bloc;
|
||||||
|
|
||||||
|
var _backingFiles = <File>[];
|
||||||
|
|
||||||
|
var _thumbZoomLevel = 0;
|
||||||
|
|
||||||
|
static final _log = Logger("widget.trashbin_browser._TrashbinBrowserState");
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _ListItem implements SelectableItem {
|
||||||
|
_ListItem({
|
||||||
|
VoidCallback? onTap,
|
||||||
|
}) : _onTap = onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
get onTap => _onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
get isSelectable => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
get staggeredTile => const StaggeredTile.count(1, 1);
|
||||||
|
|
||||||
|
final VoidCallback? _onTap;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _FileListItem extends _ListItem {
|
||||||
|
_FileListItem({
|
||||||
|
required this.file,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
}) : super(onTap: onTap);
|
||||||
|
|
||||||
|
@override
|
||||||
|
operator ==(Object other) {
|
||||||
|
return other is _FileListItem && file.path == other.file.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
get hashCode => file.path.hashCode;
|
||||||
|
|
||||||
|
final File file;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImageListItem extends _FileListItem {
|
||||||
|
_ImageListItem({
|
||||||
|
required File file,
|
||||||
|
required this.account,
|
||||||
|
required this.previewUrl,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
}) : super(file: file, onTap: onTap);
|
||||||
|
|
||||||
|
@override
|
||||||
|
buildWidget(BuildContext context) {
|
||||||
|
return PhotoListImage(
|
||||||
|
account: account,
|
||||||
|
previewUrl: previewUrl,
|
||||||
|
isGif: file.contentType == "image/gif",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final String previewUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoListItem extends _FileListItem {
|
||||||
|
_VideoListItem({
|
||||||
|
required File file,
|
||||||
|
required this.account,
|
||||||
|
required this.previewUrl,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
}) : super(file: file, onTap: onTap);
|
||||||
|
|
||||||
|
@override
|
||||||
|
buildWidget(BuildContext context) {
|
||||||
|
return PhotoListVideo(
|
||||||
|
account: account,
|
||||||
|
previewUrl: previewUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final String previewUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _AppBarMenuOption {
|
||||||
|
delete,
|
||||||
|
}
|
372
lib/widget/trashbin_viewer.dart
Normal file
372
lib/widget/trashbin_viewer.dart
Normal file
|
@ -0,0 +1,372 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/app_localizations.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/entity/file/data_source.dart';
|
||||||
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
|
import 'package:nc_photos/exception_util.dart' as exception_util;
|
||||||
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
import 'package:nc_photos/snack_bar_manager.dart';
|
||||||
|
import 'package:nc_photos/theme.dart';
|
||||||
|
import 'package:nc_photos/use_case/remove.dart';
|
||||||
|
import 'package:nc_photos/use_case/restore_trashbin.dart';
|
||||||
|
import 'package:nc_photos/widget/horizontal_page_viewer.dart';
|
||||||
|
import 'package:nc_photos/widget/image_viewer.dart';
|
||||||
|
import 'package:nc_photos/widget/video_viewer.dart';
|
||||||
|
|
||||||
|
class TrashbinViewerArguments {
|
||||||
|
TrashbinViewerArguments(this.account, this.streamFiles, this.startIndex);
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final List<File> streamFiles;
|
||||||
|
final int startIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TrashbinViewer extends StatefulWidget {
|
||||||
|
static const routeName = "/trashbin-viewer";
|
||||||
|
|
||||||
|
static Route buildRoute(TrashbinViewerArguments args) => MaterialPageRoute(
|
||||||
|
builder: (context) => TrashbinViewer.fromArgs(args),
|
||||||
|
);
|
||||||
|
|
||||||
|
TrashbinViewer({
|
||||||
|
Key? key,
|
||||||
|
required this.account,
|
||||||
|
required this.streamFiles,
|
||||||
|
required this.startIndex,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
TrashbinViewer.fromArgs(TrashbinViewerArguments args, {Key? key})
|
||||||
|
: this(
|
||||||
|
key: key,
|
||||||
|
account: args.account,
|
||||||
|
streamFiles: args.streamFiles,
|
||||||
|
startIndex: args.startIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
createState() => _TrashbinViewerState();
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final List<File> streamFiles;
|
||||||
|
final int startIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrashbinViewerState extends State<TrashbinViewer> {
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return AppTheme(
|
||||||
|
child: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: _buildContent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isShowVideoControl = !_isShowVideoControl;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Container(color: Colors.black),
|
||||||
|
if (!_isViewerLoaded ||
|
||||||
|
!_pageStates[_viewerController.currentPage]!.hasLoaded)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: const CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
HorizontalPageViewer(
|
||||||
|
pageCount: widget.streamFiles.length,
|
||||||
|
pageBuilder: _buildPage,
|
||||||
|
initialPage: widget.startIndex,
|
||||||
|
controller: _viewerController,
|
||||||
|
viewportFraction: _viewportFraction,
|
||||||
|
canSwitchPage: _canSwitchPage,
|
||||||
|
),
|
||||||
|
_buildAppBar(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context) {
|
||||||
|
return Wrap(
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
// + status bar height
|
||||||
|
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: const Alignment(0, -1),
|
||||||
|
end: const Alignment(0, 1),
|
||||||
|
colors: [
|
||||||
|
Color.fromARGB(192, 0, 0, 0),
|
||||||
|
Color.fromARGB(0, 0, 0, 0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
iconTheme: Theme.of(context).iconTheme.copyWith(
|
||||||
|
color: Colors.white.withOpacity(.87),
|
||||||
|
),
|
||||||
|
actionsIconTheme: Theme.of(context).iconTheme.copyWith(
|
||||||
|
color: Colors.white.withOpacity(.87),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.restore_outlined),
|
||||||
|
tooltip: L10n.of(context).restoreTooltip,
|
||||||
|
onPressed: _onRestorePressed,
|
||||||
|
),
|
||||||
|
PopupMenuButton<_AppBarMenuOption>(
|
||||||
|
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: _AppBarMenuOption.delete,
|
||||||
|
child: Text(L10n.of(context).deletePermanentlyTooltip),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onSelected: (option) {
|
||||||
|
switch (option) {
|
||||||
|
case _AppBarMenuOption.delete:
|
||||||
|
_onDeletePressed(context);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
_log.shout("[_buildAppBar] Unknown option: $option");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onRestorePressed() async {
|
||||||
|
final file = widget.streamFiles[_viewerController.currentPage];
|
||||||
|
_log.info("[_onRestorePressed] Restoring file: ${file.path}");
|
||||||
|
var controller = SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(L10n.of(context).restoreProcessingNotification),
|
||||||
|
duration: k.snackBarDurationShort,
|
||||||
|
));
|
||||||
|
controller?.closed.whenComplete(() {
|
||||||
|
controller = null;
|
||||||
|
});
|
||||||
|
final fileRepo = FileRepo(FileCachedDataSource());
|
||||||
|
try {
|
||||||
|
await RestoreTrashbin(fileRepo)(widget.account, file);
|
||||||
|
controller?.close();
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(L10n.of(context).restoreSuccessNotification),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
_log.shout(
|
||||||
|
"Failed while restore trashbin" +
|
||||||
|
(kDebugMode ? ": ${file.path}" : ""),
|
||||||
|
e,
|
||||||
|
stacktrace);
|
||||||
|
controller?.close();
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text("${L10n.of(context).restoreFailureNotification}: "
|
||||||
|
"${exception_util.toUserString(e, context)}"),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onDeletePressed(BuildContext context) async {
|
||||||
|
final file = widget.streamFiles[_viewerController.currentPage];
|
||||||
|
_log.info("[_onDeletePressed] Deleting file permanently: ${file.path}");
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: Text(L10n.of(context).deletePermanentlyConfirmationDialogTitle),
|
||||||
|
content:
|
||||||
|
Text(L10n.of(context).deletePermanentlyConfirmationDialogContent),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_delete(context);
|
||||||
|
},
|
||||||
|
child: Text(L10n.of(context).confirmButtonLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPage(BuildContext context, int index) {
|
||||||
|
if (_pageStates[index] == null) {
|
||||||
|
_pageStates[index] = _PageState();
|
||||||
|
}
|
||||||
|
return FractionallySizedBox(
|
||||||
|
widthFactor: 1 / _viewportFraction,
|
||||||
|
child: _buildItemView(context, index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildItemView(BuildContext context, int index) {
|
||||||
|
final file = widget.streamFiles[index];
|
||||||
|
if (file_util.isSupportedImageFormat(file)) {
|
||||||
|
return _buildImageView(context, index);
|
||||||
|
} else if (file_util.isSupportedVideoFormat(file)) {
|
||||||
|
return _buildVideoView(context, index);
|
||||||
|
} else {
|
||||||
|
_log.shout("[_buildItemView] Unknown file format: ${file.contentType}");
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImageView(BuildContext context, int index) {
|
||||||
|
return ImageViewer(
|
||||||
|
account: widget.account,
|
||||||
|
file: widget.streamFiles[index],
|
||||||
|
canZoom: true,
|
||||||
|
onLoaded: () => _onImageLoaded(index),
|
||||||
|
onZoomStarted: () {
|
||||||
|
setState(() {
|
||||||
|
_isZoomed = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onZoomEnded: () {
|
||||||
|
setState(() {
|
||||||
|
_isZoomed = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildVideoView(BuildContext context, int index) {
|
||||||
|
return VideoViewer(
|
||||||
|
account: widget.account,
|
||||||
|
file: widget.streamFiles[index],
|
||||||
|
onLoaded: () => _onVideoLoaded(index),
|
||||||
|
onPlay: _onVideoPlay,
|
||||||
|
onPause: _onVideoPause,
|
||||||
|
isControlVisible: _isShowVideoControl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onImageLoaded(int index) {
|
||||||
|
// currently pageview doesn't pre-load pages, we do it manually
|
||||||
|
// don't pre-load if user already navigated away
|
||||||
|
if (_viewerController.currentPage == index &&
|
||||||
|
!_pageStates[index]!.hasLoaded) {
|
||||||
|
_log.info("[_onImageLoaded] Pre-loading nearby images");
|
||||||
|
if (index > 0) {
|
||||||
|
final prevFile = widget.streamFiles[index - 1];
|
||||||
|
if (file_util.isSupportedImageFormat(prevFile)) {
|
||||||
|
ImageViewer.preloadImage(widget.account, prevFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (index + 1 < widget.streamFiles.length) {
|
||||||
|
final nextFile = widget.streamFiles[index + 1];
|
||||||
|
if (file_util.isSupportedImageFormat(nextFile)) {
|
||||||
|
ImageViewer.preloadImage(widget.account, nextFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_pageStates[index]!.hasLoaded = true;
|
||||||
|
_isViewerLoaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onVideoLoaded(int index) {
|
||||||
|
if (_viewerController.currentPage == index &&
|
||||||
|
!_pageStates[index]!.hasLoaded) {
|
||||||
|
setState(() {
|
||||||
|
_pageStates[index]!.hasLoaded = true;
|
||||||
|
_isViewerLoaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onVideoPlay() {
|
||||||
|
setState(() {
|
||||||
|
_isShowVideoControl = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onVideoPause() {
|
||||||
|
setState(() {
|
||||||
|
_isShowVideoControl = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete(BuildContext context) async {
|
||||||
|
final file = widget.streamFiles[_viewerController.currentPage];
|
||||||
|
_log.info("[_delete] Removing file: ${file.path}");
|
||||||
|
var controller = SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(L10n.of(context).deleteProcessingNotification),
|
||||||
|
duration: k.snackBarDurationShort,
|
||||||
|
));
|
||||||
|
controller?.closed.whenComplete(() {
|
||||||
|
controller = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final fileRepo = FileRepo(FileCachedDataSource());
|
||||||
|
await Remove(fileRepo, null)(widget.account, file);
|
||||||
|
controller?.close();
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(L10n.of(context).deleteSuccessNotification),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
_log.shout(
|
||||||
|
"[_delete] Failed while remove" +
|
||||||
|
(kDebugMode ? ": ${file.path}" : ""),
|
||||||
|
e,
|
||||||
|
stacktrace);
|
||||||
|
controller?.close();
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text("${L10n.of(context).deleteFailureNotification}: "
|
||||||
|
"${exception_util.toUserString(e, context)}"),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _canSwitchPage => !_isZoomed;
|
||||||
|
|
||||||
|
var _isShowVideoControl = true;
|
||||||
|
var _isZoomed = false;
|
||||||
|
|
||||||
|
final _viewerController = HorizontalPageViewerController();
|
||||||
|
bool _isViewerLoaded = false;
|
||||||
|
final _pageStates = <int, _PageState>{};
|
||||||
|
|
||||||
|
static final _log = Logger("widget.trashbin_viewer._TrashbinViewerState");
|
||||||
|
|
||||||
|
static const _viewportFraction = 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PageState {
|
||||||
|
bool hasLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _AppBarMenuOption {
|
||||||
|
delete,
|
||||||
|
}
|
Loading…
Reference in a new issue