536 lines
18 KiB
Dart
536 lines
18 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:auto_route/auto_route.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:immich_mobile/domain/models/file_vault.model.dart';
|
|
import 'package:immich_mobile/infrastructure/repositories/file_vault.repository.dart';
|
|
import 'package:immich_mobile/presentation/utils/file_vault_labels.dart';
|
|
import 'package:openapi/api.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
@RoutePage()
|
|
class FileVaultPage extends ConsumerStatefulWidget {
|
|
const FileVaultPage({super.key, this.incomingPaths = const []});
|
|
|
|
final List<String> incomingPaths;
|
|
|
|
@override
|
|
ConsumerState<FileVaultPage> createState() => _FileVaultPageState();
|
|
}
|
|
|
|
class _FileVaultPageState extends ConsumerState<FileVaultPage> {
|
|
VaultListing? _listing;
|
|
String? _folderId;
|
|
bool _trash = false;
|
|
bool _busy = false;
|
|
int _offset = 0;
|
|
int _generation = 0;
|
|
String? _error;
|
|
String? _progress;
|
|
late List<String> _incoming;
|
|
FileVaultLabels get _labels => FileVaultLabels(Localizations.localeOf(context).languageCode);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_incoming = [...widget.incomingPaths];
|
|
ref.listenManual(fileVaultRevisionProvider, (_, _) => unawaited(_load()));
|
|
unawaited(Future.microtask(_load));
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
final generation = ++_generation;
|
|
try {
|
|
final listing = await ref
|
|
.read(fileVaultRepositoryProvider)
|
|
.list(parentId: _folderId, trash: _trash, offset: _offset);
|
|
if (mounted && generation == _generation) {
|
|
setState(() {
|
|
_listing = listing;
|
|
_error = null;
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (mounted && generation == _generation) {
|
|
_showError(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showError(Object error) {
|
|
final status = error is ApiException ? error.code : 0;
|
|
setState(
|
|
() => _error = _labels.text(
|
|
status == 409
|
|
? 'conflict'
|
|
: status == 404
|
|
? 'unavailable'
|
|
: status == 413 || status == 507
|
|
? 'quota'
|
|
: status == 400
|
|
? 'invalid'
|
|
: 'error',
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _run(Future<void> Function() action) async {
|
|
if (_busy) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_busy = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
await action();
|
|
if (mounted) {
|
|
await _load();
|
|
}
|
|
} catch (error) {
|
|
if (mounted) {
|
|
_showError(error);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_busy = false;
|
|
_progress = null;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
void _navigate(String? folderId, {bool trash = false, int offset = 0}) {
|
|
if (_busy) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_folderId = folderId;
|
|
_trash = trash;
|
|
_offset = offset;
|
|
_listing = null;
|
|
});
|
|
unawaited(_load());
|
|
}
|
|
|
|
String _size(int bytes) {
|
|
if (bytes < 1024) {
|
|
return '$bytes ${_labels.text('byte_unit')}';
|
|
}
|
|
final unit = bytes >= 1024 * 1024 * 1024
|
|
? 'GiB'
|
|
: bytes >= 1024 * 1024
|
|
? 'MiB'
|
|
: 'KiB';
|
|
final divisor = unit == 'GiB'
|
|
? 1024 * 1024 * 1024
|
|
: unit == 'MiB'
|
|
? 1024 * 1024
|
|
: 1024;
|
|
return '${(bytes / divisor).toStringAsFixed(1)} $unit';
|
|
}
|
|
|
|
Future<String?> _askName(String title, {String initial = ''}) async {
|
|
final controller = TextEditingController(text: initial);
|
|
try {
|
|
return await showDialog<String>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(title),
|
|
content: TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
maxLength: 240,
|
|
decoration: InputDecoration(labelText: _labels.text('name')),
|
|
onSubmitted: (value) {
|
|
if (value.trim().isNotEmpty) {
|
|
Navigator.pop(dialogContext, value.trim());
|
|
}
|
|
},
|
|
),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(_labels.text('cancel'))),
|
|
FilledButton(
|
|
onPressed: () {
|
|
if (controller.text.trim().isNotEmpty) {
|
|
Navigator.pop(dialogContext, controller.text.trim());
|
|
}
|
|
},
|
|
child: Text(_labels.text('confirm')),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
} finally {
|
|
controller.dispose();
|
|
}
|
|
}
|
|
|
|
Future<bool> _confirm(VaultEntry entry, bool permanent) async =>
|
|
await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(_labels.text(permanent ? 'purge' : 'trash')),
|
|
content: Text('${entry.name}\n\n${_labels.text(permanent ? 'purge_confirm' : 'trash_confirm')}'),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: Text(_labels.text('cancel'))),
|
|
FilledButton(onPressed: () => Navigator.pop(dialogContext, true), child: Text(_labels.text('confirm'))),
|
|
],
|
|
),
|
|
) ??
|
|
false;
|
|
|
|
Future<void> _createFolder() async {
|
|
final name = await _askName(_labels.text('new_folder'));
|
|
if (name != null && mounted) {
|
|
await _run(() => ref.read(fileVaultRepositoryProvider).createFolder(name, _folderId));
|
|
}
|
|
}
|
|
|
|
Future<void> _pickFiles() async {
|
|
await _run(() async {
|
|
final repository = ref.read(fileVaultRepositoryProvider);
|
|
final files = await repository.platform.pickFiles();
|
|
try {
|
|
for (var index = 0; index < files.length; index++) {
|
|
final file = files[index];
|
|
if (!mounted) {
|
|
break;
|
|
}
|
|
await repository.upload(file.path, file.name, _folderId, (bytes, total) {
|
|
if (mounted) {
|
|
setState(
|
|
() => _progress =
|
|
'${index + 1}/${files.length} · ${file.name} · ${total > 0 ? bytes * 100 ~/ total : 0}%',
|
|
);
|
|
}
|
|
});
|
|
}
|
|
} finally {
|
|
for (final file in files) {
|
|
final temporary = File(file.path);
|
|
if (temporary.existsSync()) {
|
|
await temporary.delete();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _uploadIncoming() async {
|
|
await _run(() async {
|
|
final repository = ref.read(fileVaultRepositoryProvider);
|
|
while (_incoming.isNotEmpty) {
|
|
final path = _incoming.first;
|
|
final name = p.basename(path);
|
|
await repository.upload(path, name, _folderId, (bytes, total) {
|
|
if (mounted) {
|
|
setState(() => _progress = '$name · ${total > 0 ? bytes * 100 ~/ total : 0}%');
|
|
}
|
|
});
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() => _incoming.removeAt(0));
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _move(VaultEntry entry) async {
|
|
await _run(() async {
|
|
final repository = ref.read(fileVaultRepositoryProvider);
|
|
final folders = await repository.folders();
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
bool allowed(VaultFolder folder) {
|
|
final seen = <String>{};
|
|
VaultFolder? current = folder;
|
|
while (current != null && seen.add(current.id)) {
|
|
if (current.id == entry.id) {
|
|
return false;
|
|
}
|
|
final parentId = current.parentId;
|
|
current = folders.where((item) => item.id == parentId).firstOrNull;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
String path(VaultFolder folder) {
|
|
final parts = [folder.name];
|
|
final seen = {folder.id};
|
|
var parentId = folder.parentId;
|
|
while (parentId != null && seen.add(parentId)) {
|
|
final parent = folders.where((item) => item.id == parentId).firstOrNull;
|
|
if (parent == null) {
|
|
break;
|
|
}
|
|
parts.insert(0, parent.name);
|
|
parentId = parent.parentId;
|
|
}
|
|
return parts.join(' / ');
|
|
}
|
|
|
|
final destination = await showDialog<String>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(_labels.text('move_to')),
|
|
content: SizedBox(
|
|
width: double.maxFinite,
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.home_outlined),
|
|
title: Text(_labels.text('root')),
|
|
onTap: () => Navigator.pop(dialogContext, 'root'),
|
|
),
|
|
for (final folder in folders.where(allowed))
|
|
ListTile(
|
|
leading: const Icon(Icons.folder_outlined),
|
|
title: Text(path(folder)),
|
|
onTap: () => Navigator.pop(dialogContext, folder.id),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(_labels.text('cancel')))],
|
|
),
|
|
);
|
|
if (destination != null) {
|
|
await repository.move(entry.id, destination == 'root' ? null : destination);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _download(VaultEntry entry, {bool open = false}) async {
|
|
await _run(() async {
|
|
final repository = ref.read(fileVaultRepositoryProvider);
|
|
final file = await repository.download(entry, (bytes, total) {
|
|
if (mounted) {
|
|
setState(() => _progress = '${entry.name} · ${total > 0 ? bytes * 100 ~/ total : 0}%');
|
|
}
|
|
});
|
|
if (!mounted) {
|
|
if (file.existsSync()) {
|
|
await file.delete();
|
|
}
|
|
return;
|
|
}
|
|
if (open) {
|
|
await repository.platform.openFile(file.path, p.basename(file.path));
|
|
} else {
|
|
try {
|
|
await repository.platform.saveFile(file.path, p.basename(file.path));
|
|
} finally {
|
|
if (file.existsSync()) {
|
|
await file.delete();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _action(String action, VaultEntry entry) async {
|
|
if (action == 'move') {
|
|
await _move(entry);
|
|
return;
|
|
}
|
|
if (action == 'download' || action == 'open') {
|
|
await _download(entry, open: action == 'open');
|
|
return;
|
|
}
|
|
if (action == 'rename') {
|
|
final name = await _askName(_labels.text('rename'), initial: entry.name);
|
|
if (name != null && mounted) {
|
|
await _run(() => ref.read(fileVaultRepositoryProvider).rename(entry.id, name));
|
|
}
|
|
return;
|
|
}
|
|
if (action == 'trash' || action == 'purge') {
|
|
if (!await _confirm(entry, action == 'purge') || !mounted) {
|
|
return;
|
|
}
|
|
}
|
|
await _run(() async {
|
|
final repository = ref.read(fileVaultRepositoryProvider);
|
|
if (action == 'trash') {
|
|
await repository.trash(entry.id);
|
|
}
|
|
if (action == 'purge') {
|
|
await repository.purge(entry.id);
|
|
}
|
|
if (action == 'restore') {
|
|
await repository.restore(entry.id);
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final listing = _listing;
|
|
final labels = _labels;
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(labels.text(_trash ? 'trash' : 'title')),
|
|
actions: [
|
|
IconButton(tooltip: labels.text('refresh'), onPressed: _busy ? null : _load, icon: const Icon(Icons.refresh)),
|
|
if (!_trash)
|
|
IconButton(
|
|
tooltip: labels.text('new_folder'),
|
|
onPressed: _busy ? null : _createFolder,
|
|
icon: const Icon(Icons.create_new_folder_outlined),
|
|
),
|
|
IconButton(
|
|
tooltip: labels.text(_trash ? 'root' : 'trash'),
|
|
onPressed: _busy ? null : () => _navigate(null, trash: !_trash),
|
|
icon: Icon(_trash ? Icons.folder_outlined : Icons.delete_outline),
|
|
),
|
|
],
|
|
),
|
|
floatingActionButton: _trash
|
|
? null
|
|
: FloatingActionButton.extended(
|
|
onPressed: _busy ? null : _pickFiles,
|
|
icon: const Icon(Icons.upload_file),
|
|
label: Text(labels.text('upload_files')),
|
|
),
|
|
body: Column(
|
|
children: [
|
|
if (!_trash)
|
|
SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: Row(
|
|
children: [
|
|
TextButton(onPressed: _busy ? null : () => _navigate(null), child: Text(labels.text('root'))),
|
|
for (final folder in listing?.breadcrumbs ?? <VaultFolder>[])
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.chevron_right, size: 16),
|
|
TextButton(onPressed: _busy ? null : () => _navigate(folder.id), child: Text(folder.name)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (listing != null)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
'${labels.text('used')}: ${_size(listing.usedBytes)} · ${labels.text('limit')}: ${_size(listing.maxFileBytes)}',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
),
|
|
),
|
|
if (_error != null)
|
|
MaterialBanner(
|
|
content: Text(_error!),
|
|
actions: [TextButton(onPressed: () => setState(() => _error = null), child: Text(labels.text('close')))],
|
|
),
|
|
if (_busy) const LinearProgressIndicator(),
|
|
if (_progress != null)
|
|
Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Text(_progress!, maxLines: 2, overflow: TextOverflow.ellipsis),
|
|
),
|
|
if (_incoming.isNotEmpty && !_trash)
|
|
Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text('${labels.text('incoming')}: ${_incoming.length}'),
|
|
Text(_incoming.map(p.basename).join(', '), maxLines: 3, overflow: TextOverflow.ellipsis),
|
|
const SizedBox(height: 8),
|
|
FilledButton(onPressed: _busy ? null : _uploadIncoming, child: Text(labels.text('upload_here'))),
|
|
TextButton(
|
|
onPressed: _busy ? null : () => setState(() => _incoming.clear()),
|
|
child: Text(labels.text('cancel')),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: listing == null
|
|
? Center(
|
|
child: _error != null
|
|
? FilledButton(onPressed: _load, child: Text(labels.text('refresh')))
|
|
: const CircularProgressIndicator(),
|
|
)
|
|
: RefreshIndicator(
|
|
onRefresh: _load,
|
|
child: ListView(
|
|
children: [
|
|
if (listing.entries.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.all(40),
|
|
child: Center(child: Text(labels.text(_trash ? 'empty_trash' : 'empty'))),
|
|
),
|
|
for (final entry in listing.entries)
|
|
ListTile(
|
|
leading: Icon(entry.isFolder ? Icons.folder_outlined : Icons.description_outlined),
|
|
title: Text(entry.name),
|
|
subtitle: entry.isFolder ? null : Text(_size(entry.size)),
|
|
onTap: _busy
|
|
? null
|
|
: entry.isFolder && !_trash
|
|
? () => _navigate(entry.id)
|
|
: entry.isFolder || _trash
|
|
? null
|
|
: () => _download(entry, open: true),
|
|
trailing: PopupMenuButton<String>(
|
|
enabled: !_busy,
|
|
onSelected: (action) => _action(action, entry),
|
|
itemBuilder: (_) => [
|
|
if (!_trash && !entry.isFolder)
|
|
PopupMenuItem(value: 'open', child: Text(labels.text('open'))),
|
|
if (!_trash) PopupMenuItem(value: 'download', child: Text(labels.text('download'))),
|
|
PopupMenuItem(value: 'rename', child: Text(labels.text('rename'))),
|
|
if (!_trash) PopupMenuItem(value: 'move', child: Text(labels.text('move'))),
|
|
if (!_trash) PopupMenuItem(value: 'trash', child: Text(labels.text('trash'))),
|
|
if (_trash) PopupMenuItem(value: 'restore', child: Text(labels.text('restore'))),
|
|
if (_trash) PopupMenuItem(value: 'purge', child: Text(labels.text('purge'))),
|
|
],
|
|
),
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
if (_offset > 0)
|
|
TextButton(
|
|
onPressed: _busy
|
|
? null
|
|
: () => _navigate(_folderId, trash: _trash, offset: _offset - 100),
|
|
child: Text(labels.text('previous')),
|
|
),
|
|
if (listing.hasMore)
|
|
TextButton(
|
|
onPressed: _busy
|
|
? null
|
|
: () => _navigate(_folderId, trash: _trash, offset: _offset + 100),
|
|
child: Text(labels.text('next')),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 96),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|