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 createState() => _DriveScreenState(); } class _DriveScreenState extends State { final List _path = []; List _folders = []; List _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().selectOrganization( context.read().organizationId, ); final controller = context.read(); 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 _load() async { if (!mounted) return; final generation = ++_loadGeneration; setState(() { _loading = true; _error = null; }); try { final api = context.read(); final values = await Future.wait([ api.folders(_folderId), api.files(_folderId), ]); if (!mounted || generation != _loadGeneration) return; setState(() { _folders = values[0] as List; _files = values[1] as List; }); } catch (error) { if (mounted && generation == _loadGeneration) { setState(() => _error = context.read().messageFrom(error)); } } finally { if (mounted && generation == _loadGeneration) { setState(() => _loading = false); } } } Future _createFolder() async { final name = await showDialog( context: context, builder: (_) => const _CreateFolderDialog(), ); if (name == null || name.isEmpty || !mounted) return; try { await context.read().createFolder(name, _folderId); await _load(); } catch (error) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.read().messageFrom(error))), ); } } } Future _deleteFile(DriveFile file) async { if (!await _confirm('Delete ${file.name}?')) return; if (!mounted) return; try { await context.read().deleteFile(file.id); await _load(); } catch (error) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.read().messageFrom(error))), ); } } } Future _deleteFolder(DriveFolder folder) async { if (!await _confirm('Delete the empty folder “${folder.name}”?')) return; if (!mounted) return; try { await context.read().deleteFolder(folder.id); await _load(); } catch (error) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.read().messageFrom(error))), ); } } } Future _confirm(String text) async => await showDialog( 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 _openGallery() async { await Navigator.of(context).push( MaterialPageRoute(builder: (_) => GalleryScreen(folderId: _folderId)), ); if (mounted) _load(); } Future _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().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(); 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( 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(), 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().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 _joinOrganization() async { final code = TextEditingController(); final submitted = await showDialog( 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().joinOrganization( code.text.trim().toUpperCase(), ); if (mounted) { await context.read().refreshProfile(); if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } } } catch (error) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.read().messageFrom(error))), ); } } finally { code.dispose(); } } Future _confirmAccountDeletion() async { final password = TextEditingController(); final confirmed = await showDialog( 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().deleteAccount(password.text); } catch (error) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.read().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 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 _organizationHeader(String? organizationId) => organizationId == null ? const {} : {'X-Organization-Id': organizationId}; class _UploadStrip extends StatelessWidget { const _UploadStrip(); @override Widget build(BuildContext context) => Consumer( 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')), ], ), ), ); }