Files
Immich/mobile/lib/presentation/pages/file_vault.page.dart
T

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