983 lines
31 KiB
Dart

import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import '../controllers/session_controller.dart';
import '../controllers/upload_controller.dart';
import '../core/app_theme.dart';
import '../models/drive_models.dart';
import '../services/api_client.dart';
import '../services/token_store.dart';
import 'gallery_screen.dart';
import 'upload_history_screen.dart';
class DriveScreen extends StatefulWidget {
const DriveScreen({super.key});
@override
State<DriveScreen> createState() => _DriveScreenState();
}
class _DriveScreenState extends State<DriveScreen> {
final List<DriveFolder> _path = [];
List<DriveFolder> _folders = [];
List<DriveFile> _files = [];
bool _loading = true;
String? _error;
String? _token;
UploadController? _uploadController;
int _completedVersion = 0;
int _loadGeneration = 0;
bool _uploadRefreshScheduled = false;
String? get _folderId => _path.isEmpty ? null : _path.last.id;
@override
void initState() {
super.initState();
const TokenStore().read().then((value) {
if (mounted) setState(() => _token = value);
});
_load();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
context.read<UploadController>().selectOrganization(
context.read<SessionController>().organizationId,
);
final controller = context.read<UploadController>();
if (_uploadController == controller) return;
_uploadController?.removeListener(_onUploadChanged);
_uploadController = controller..addListener(_onUploadChanged);
_completedVersion = controller.completedVersion;
}
void _onUploadChanged() {
final version = _uploadController?.completedVersion ?? 0;
if (version == _completedVersion || _uploadRefreshScheduled) return;
_completedVersion = version;
_uploadRefreshScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_uploadRefreshScheduled = false;
if (mounted) _load();
});
}
@override
void dispose() {
_uploadController?.removeListener(_onUploadChanged);
super.dispose();
}
Future<void> _load() async {
if (!mounted) return;
final generation = ++_loadGeneration;
setState(() {
_loading = true;
_error = null;
});
try {
final api = context.read<ApiClient>();
final values = await Future.wait([
api.folders(_folderId),
api.files(_folderId),
]);
if (!mounted || generation != _loadGeneration) return;
setState(() {
_folders = values[0] as List<DriveFolder>;
_files = values[1] as List<DriveFile>;
});
} catch (error) {
if (mounted && generation == _loadGeneration) {
setState(() => _error = context.read<ApiClient>().messageFrom(error));
}
} finally {
if (mounted && generation == _loadGeneration) {
setState(() => _loading = false);
}
}
}
Future<void> _createFolder() async {
final name = await showDialog<String>(
context: context,
builder: (_) => const _CreateFolderDialog(),
);
if (name == null || name.isEmpty || !mounted) return;
try {
await context.read<ApiClient>().createFolder(name, _folderId);
await _load();
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.read<ApiClient>().messageFrom(error))),
);
}
}
}
Future<void> _deleteFile(DriveFile file) async {
if (!await _confirm('Delete ${file.name}?')) return;
if (!mounted) return;
try {
await context.read<ApiClient>().deleteFile(file.id);
await _load();
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.read<ApiClient>().messageFrom(error))),
);
}
}
}
Future<void> _deleteFolder(DriveFolder folder) async {
if (!await _confirm('Delete the empty folder “${folder.name}”?')) return;
if (!mounted) return;
try {
await context.read<ApiClient>().deleteFolder(folder.id);
await _load();
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.read<ApiClient>().messageFrom(error))),
);
}
}
}
Future<bool> _confirm(String text) async =>
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirm deletion'),
content: Text(text),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Keep'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete'),
),
],
),
) ??
false;
Future<void> _openGallery() async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => GalleryScreen(folderId: _folderId)),
);
if (mounted) _load();
}
Future<void> _pickFiles() async {
final result = await FilePicker.pickFiles(
allowMultiple: true,
type: FileType.any,
withData: false,
);
if (result == null || !mounted) return;
final support = await getApplicationSupportDirectory();
final queue = Directory(
'${support.path}${Platform.pathSeparator}upload_queue',
);
await queue.create(recursive: true);
var queued = 0;
for (var index = 0; index < result.files.length; index++) {
final selected = result.files[index];
if (selected.path == null) continue;
final original = selected.name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
final stagedName =
'${DateTime.now().microsecondsSinceEpoch}_$index-$original';
await File(
selected.path!,
).copy('${queue.path}${Platform.pathSeparator}$stagedName');
if (!mounted) return;
await context.read<UploadController>().enqueue(
filename: stagedName,
displayName: original,
folderId: _folderId,
);
queued++;
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$queued file(s) added to the upload queue.')),
);
}
}
void _showUploadSources() {
showModalBottomSheet(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Photos and videos'),
subtitle: const Text(
'Browse the gallery inside Metatron-Drive',
),
onTap: () {
Navigator.pop(sheetContext);
_openGallery();
},
),
ListTile(
leading: const Icon(Icons.attach_file_rounded),
title: const Text('Any file'),
subtitle: const Text(
'Documents, audio, archives, PDFs, and more',
),
onTap: () {
Navigator.pop(sheetContext);
_pickFiles();
},
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final session = context.watch<SessionController>();
final user = session.user!;
final membership = session.membership;
return PopScope(
canPop: _path.isEmpty,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _path.isNotEmpty) {
setState(() => _path.removeLast());
_load();
}
},
child: Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_path.isEmpty
? membership?.organization.name ?? 'Metatron-Drive'
: _path.last.name,
style: const TextStyle(fontWeight: FontWeight.w800),
),
Text(
'Hello, ${user.name.split(' ').first}${membership?.organization.code ?? ''}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
color: Colors.black54,
),
),
],
),
actions: [
PopupMenuButton<String>(
tooltip: 'Switch organization',
icon: const Icon(Icons.business_outlined),
onSelected: session.selectOrganization,
itemBuilder: (_) => user.approvedMemberships
.map(
(item) => PopupMenuItem(
value: item.organization.id,
child: Row(
children: [
if (item.organization.id == session.organizationId)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(Icons.check, size: 18),
),
Text(item.organization.name),
],
),
),
)
.toList(),
),
IconButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UploadHistoryScreen()),
),
tooltip: 'Upload history',
icon: const Icon(Icons.history_rounded),
),
IconButton(
onPressed: _load,
tooltip: 'Refresh',
icon: const Icon(Icons.refresh_rounded),
),
IconButton(
onPressed: () =>
_showProfile(user.name, user.username, user.email),
tooltip: 'Profile',
icon: CircleAvatar(
radius: 16,
backgroundColor: AppTheme.navy,
child: Text(
user.name.substring(0, 1).toUpperCase(),
style: const TextStyle(color: Colors.white),
),
),
),
const SizedBox(width: 8),
],
),
body: RefreshIndicator(
onRefresh: _load,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _DriveHeader(
path: _path,
onRoot: () {
setState(_path.clear);
_load();
},
onCreate: _createFolder,
onFiles: _pickFiles,
),
),
if (_loading)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (_error != null)
SliverFillRemaining(
child: _EmptyState(
icon: Icons.cloud_off_rounded,
title: 'Could not load your drive',
subtitle: _error!,
action: _load,
),
)
else if (_folders.isEmpty && _files.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: _EmptyState(
icon: Icons.cloud_upload_outlined,
title: 'This folder is empty',
subtitle:
'Choose media from your gallery or create a folder.',
action: _openGallery,
),
)
else ...[
if (_folders.isNotEmpty)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(20, 12, 20, 8),
child: Text(
'Folders',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid.builder(
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 220,
childAspectRatio: 1.55,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
),
itemCount: _folders.length,
itemBuilder: (context, index) {
final folder = _folders[index];
return _FolderCard(
folder: folder,
onOpen: () {
setState(() => _path.add(folder));
_load();
},
onDelete: () => _deleteFolder(folder),
);
},
),
),
if (_files.isNotEmpty)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(20, 20, 20, 8),
child: Text(
'Files',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 120),
sliver: SliverGrid.builder(
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 190,
childAspectRatio: .82,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
),
itemCount: _files.length,
itemBuilder: (context, index) => _FileCard(
file: _files[index],
token: _token,
api: context.read<ApiClient>(),
organizationId: session.organizationId,
onDelete: () => _deleteFile(_files[index]),
),
),
),
],
],
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _showUploadSources,
icon: const Icon(Icons.cloud_upload_outlined),
label: const Text('Upload'),
),
bottomSheet: const _UploadStrip(),
),
);
}
void _showProfile(String name, String username, String email) {
showModalBottomSheet(
context: context,
showDragHandle: true,
builder: (sheetContext) => Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 34,
backgroundColor: AppTheme.navy,
child: Text(
name.substring(0, 1).toUpperCase(),
style: const TextStyle(fontSize: 26, color: Colors.white),
),
),
const SizedBox(height: 12),
Text(
name,
style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
),
Text(
'@$username$email',
style: const TextStyle(color: Colors.black54),
),
const SizedBox(height: 22),
OutlinedButton.icon(
onPressed: () {
Navigator.pop(sheetContext);
_joinOrganization();
},
icon: const Icon(Icons.group_add_outlined),
label: const Text('Join another organization'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () {
Navigator.pop(sheetContext);
context.read<SessionController>().logout();
},
icon: const Icon(Icons.logout),
label: const Text('Sign out'),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () {
Navigator.pop(sheetContext);
_confirmAccountDeletion();
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
icon: const Icon(Icons.person_remove_outlined),
label: const Text('Delete database account'),
),
],
),
),
);
}
Future<void> _joinOrganization() async {
final code = TextEditingController();
final submitted = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Join an organization'),
content: TextField(
controller: code,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(labelText: 'Organization code'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Submit request'),
),
],
),
);
if (submitted != true || !mounted) {
code.dispose();
return;
}
try {
final message = await context.read<ApiClient>().joinOrganization(
code.text.trim().toUpperCase(),
);
if (mounted) {
await context.read<SessionController>().refreshProfile();
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.read<ApiClient>().messageFrom(error))),
);
}
} finally {
code.dispose();
}
}
Future<void> _confirmAccountDeletion() async {
final password = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete account data?'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Your profile, folders, and file records will be removed from the database. Existing Google Drive files will be retained.',
),
const SizedBox(height: 16),
TextField(
controller: password,
obscureText: true,
decoration: const InputDecoration(labelText: 'Confirm password'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: FilledButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Delete database data'),
),
],
),
);
if (confirmed != true || !mounted) {
password.dispose();
return;
}
try {
await context.read<SessionController>().deleteAccount(password.text);
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.read<ApiClient>().messageFrom(error))),
);
}
} finally {
password.dispose();
}
}
}
class _DriveHeader extends StatelessWidget {
const _DriveHeader({
required this.path,
required this.onRoot,
required this.onCreate,
required this.onFiles,
});
final List<DriveFolder> path;
final VoidCallback onRoot;
final VoidCallback onCreate;
final VoidCallback onFiles;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 6),
child: Column(
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Row(
children: [
const CircleAvatar(
backgroundColor: Color(0xFFE8EEFF),
child: Icon(Icons.lock_outline_rounded, color: AppTheme.blue),
),
const SizedBox(width: 14),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Private workspace',
style: TextStyle(fontWeight: FontWeight.w700),
),
Text(
'Original quality • protected access',
style: TextStyle(fontSize: 12, color: Colors.black54),
),
],
),
),
IconButton(
onPressed: onFiles,
tooltip: 'Upload any file',
icon: const Icon(Icons.attach_file_rounded),
),
IconButton(
onPressed: onCreate,
tooltip: 'Create folder',
icon: const Icon(Icons.create_new_folder_outlined),
),
],
),
),
),
if (path.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: onRoot,
icon: const Icon(Icons.home_outlined, size: 18),
label: Text(
'Metatron-Drive / ${path.map((item) => item.name).join(' / ')}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
);
}
class _FolderCard extends StatelessWidget {
const _FolderCard({
required this.folder,
required this.onOpen,
required this.onDelete,
});
final DriveFolder folder;
final VoidCallback onOpen;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) => Card(
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onOpen,
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
const Icon(
Icons.folder_rounded,
color: Color(0xFFFFB547),
size: 38,
),
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
folder.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700),
),
Text(
'${folder.childCount} folders • ${folder.fileCount} files',
style: const TextStyle(fontSize: 11, color: Colors.black54),
),
],
),
),
PopupMenuButton(
onSelected: (_) => onDelete(),
itemBuilder: (_) => const [
PopupMenuItem(
value: 'delete',
child: Text('Delete empty folder'),
),
],
),
],
),
),
),
);
}
class _FileCard extends StatelessWidget {
const _FileCard({
required this.file,
required this.token,
required this.api,
required this.organizationId,
required this.onDelete,
});
final DriveFile file;
final String? token;
final ApiClient api;
final String? organizationId;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) => Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ColoredBox(
color: const Color(0xFFF0F3F9),
child: file.isImage && token != null
? Image.network(
api.contentUrl(file.id),
headers: {
'Authorization': 'Bearer $token',
..._organizationHeader(organizationId),
},
fit: BoxFit.cover,
errorBuilder: (_, _, _) =>
const Icon(Icons.broken_image_outlined, size: 42),
)
: Icon(
file.isVideo
? Icons.play_circle_outline_rounded
: Icons.insert_drive_file_outlined,
size: 48,
color: AppTheme.blue,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text(
_size(file.sizeBytes),
style: const TextStyle(
fontSize: 11,
color: Colors.black54,
),
),
],
),
),
PopupMenuButton(
onSelected: (_) => onDelete(),
itemBuilder: (_) => const [
PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
],
),
),
],
),
);
static String _size(int bytes) => bytes < 1024 * 1024
? '${(bytes / 1024).toStringAsFixed(1)} KB'
: '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
}
Map<String, String> _organizationHeader(String? organizationId) =>
organizationId == null ? const {} : {'X-Organization-Id': organizationId};
class _UploadStrip extends StatelessWidget {
const _UploadStrip();
@override
Widget build(BuildContext context) => Consumer<UploadController>(
builder: (context, uploads, _) {
final active = uploads.uploads
.where(
(item) => !const {
TaskStatus.complete,
TaskStatus.failed,
TaskStatus.canceled,
}.contains(item.status),
)
.toList();
if (active.isEmpty) return const SizedBox.shrink();
final item = active.first;
return Material(
elevation: 10,
color: AppTheme.navy,
child: InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UploadHistoryScreen()),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 12, 10),
child: Row(
children: [
const Icon(Icons.cloud_upload_outlined, color: Colors.white),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${active.length} upload${active.length == 1 ? '' : 's'} active',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: item.progress,
minHeight: 4,
backgroundColor: Colors.white24,
color: AppTheme.mint,
),
],
),
),
IconButton(
onPressed: () => uploads.cancel(item.taskId),
icon: const Icon(Icons.close, color: Colors.white70),
),
],
),
),
),
),
);
},
);
}
class _CreateFolderDialog extends StatefulWidget {
const _CreateFolderDialog();
@override
State<_CreateFolderDialog> createState() => _CreateFolderDialogState();
}
class _CreateFolderDialogState extends State<_CreateFolderDialog> {
String _name = '';
bool _submitted = false;
void _submit() {
final value = _name.trim();
if (_submitted || value.isEmpty) return;
_submitted = true;
Navigator.of(context).pop(value);
}
@override
Widget build(BuildContext context) => AlertDialog(
title: const Text('New folder'),
content: TextFormField(
autofocus: true,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Folder name'),
onChanged: (value) => _name = value,
onFieldSubmitted: (_) => _submit(),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(onPressed: _submit, child: const Text('Create')),
],
);
}
class _EmptyState extends StatelessWidget {
const _EmptyState({
required this.icon,
required this.title,
required this.subtitle,
required this.action,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback action;
@override
Widget build(BuildContext context) => Center(
child: Padding(
padding: const EdgeInsets.all(36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 62, color: AppTheme.blue),
const SizedBox(height: 16),
Text(
title,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
Text(
subtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.black54),
),
const SizedBox(height: 18),
OutlinedButton(onPressed: action, child: const Text('Try again')),
],
),
),
);
}