886 lines
28 KiB
Dart
886 lines
28 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();
|
|
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 user = context.watch<SessionController>().user!;
|
|
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 ? 'Metatron-Drive' : _path.last.name,
|
|
style: const TextStyle(fontWeight: FontWeight.w800),
|
|
),
|
|
Text(
|
|
'Hello, ${user.name.split(' ').first}',
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.normal,
|
|
color: Colors.black54,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
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>(),
|
|
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);
|
|
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> _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.onDelete,
|
|
});
|
|
final DriveFile file;
|
|
final String? token;
|
|
final ApiClient api;
|
|
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'},
|
|
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';
|
|
}
|
|
|
|
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')),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|