transfer_manager 2.3.0
transfer_manager: ^2.3.0 copied to clipboard
A protocol-aware, crash-safe transfer engine for Dart and Flutter.
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:transfer_manager_flutter/transfer_manager_flutter.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const TransferManagerExampleApp());
}
class TransferManagerExampleApp extends StatelessWidget {
const TransferManagerExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Transfer Manager',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff155eef)),
useMaterial3: true,
),
home: const TransferHomePage(),
);
}
}
class TransferHomePage extends StatefulWidget {
const TransferHomePage({super.key});
@override
State<TransferHomePage> createState() => _TransferHomePageState();
}
class _TransferHomePageState extends State<TransferHomePage> {
static const _downloads = [
DownloadOption(
title: 'Small sample',
subtitle: 'A quick 1 MB download',
fileName: 'cloudflare-1mb.bin',
bytes: 1000000,
icon: Icons.description_outlined,
),
DownloadOption(
title: 'Medium sample',
subtitle: 'A 10 MB background download',
fileName: 'cloudflare-10mb.bin',
bytes: 10000000,
icon: Icons.archive_outlined,
),
DownloadOption(
title: 'Large sample',
subtitle: 'A 50 MB background download',
fileName: 'cloudflare-50mb.bin',
bytes: 50000000,
icon: Icons.movie_outlined,
),
];
int _selectedTabIndex = 0;
final Map<String, TransferTaskView> _taskViews = {};
final List<StreamSubscription<TransferEvent>> _subscriptions = [];
FlutterTransferManager? _manager;
bool _initializing = true;
bool _requestingNotifications = false;
bool _notificationsEnabled = false;
String? _error;
final TextEditingController _uploadUrlController = TextEditingController(
text: 'https://httpbin.org/post',
);
final TextEditingController _tusUrlController = TextEditingController(
text: 'https://tusd.tusdemo.net/files/',
);
final List<TransferPickedFile> _pickedFiles = [];
bool _isPicking = false;
@override
void initState() {
super.initState();
unawaited(_initialize());
}
Future<void> _initialize() async {
try {
if (!Platform.isAndroid && !Platform.isIOS) {
throw UnsupportedError('This example supports Android and iOS.');
}
final manager = await FlutterTransferManager.create(
configuration: const TransferConfiguration(
maxConcurrentTasks: 3,
maxConcurrentDownloads: 3,
maxConcurrentUploads: 3,
),
);
for (final task in await manager.tasks()) {
_attachTask(task);
}
final notificationsEnabled = await manager.notificationsEnabled();
if (!mounted) return;
setState(() {
_manager = manager;
_notificationsEnabled = notificationsEnabled;
_initializing = false;
});
} catch (error) {
if (!mounted) return;
setState(() {
_error = error.toString();
_initializing = false;
});
}
}
Future<bool> _notificationsAreEnabled() {
return _manager!.notificationsEnabled();
}
Future<bool> _requestNotificationPermission() {
return _manager!.requestNotificationPermission();
}
Future<void> _enableNotifications() async {
if (_requestingNotifications) return;
setState(() => _requestingNotifications = true);
try {
final enabled = await _requestNotificationPermission();
if (!mounted) return;
setState(() => _notificationsEnabled = enabled);
if (!enabled) {
_showMessage(
'Notifications are disabled. Transfers still work, but completion '
'alerts will not be shown.',
);
}
} catch (error) {
if (mounted) _showMessage('Could not request permission: $error');
} finally {
if (mounted) setState(() => _requestingNotifications = false);
}
}
Future<void> _startDownload(DownloadOption option) async {
final manager = _manager;
if (manager == null) return;
if (!_notificationsEnabled) {
await _enableNotifications();
}
try {
final visibleDestination = Platform.isAndroid
? 'Downloads/${option.fileName}'
: 'Files → On My iPhone/iPad → Transfer Manager → downloads'
' → ${option.fileName}';
final task = await manager.download(
Uri.https('speed.cloudflare.com', '/__down', {
'bytes': option.bytes.toString(),
}),
fileName: option.fileName,
showNotification: true,
openFromNotification: NotificationOpenType.reveal,
showLiveActivity: Platform.isIOS,
liveActivityStyle: LiveActivityStyle.system,
);
_attachTask(task, title: option.title, subtitle: visibleDestination);
if (mounted) {
setState(() {});
_showMessage('${option.title} queued');
}
} catch (error) {
if (mounted) _showMessage('Could not queue download: $error');
}
}
Future<void> _pickSingleFile() async {
final manager = _manager;
if (manager == null) return;
setState(() => _isPicking = true);
try {
final picked = await manager.pickFile();
if (!mounted) return;
if (picked != null) {
setState(() {
_pickedFiles
..clear()
..add(picked);
});
_showMessage('Selected: ${picked.name} (${_formatBytes(picked.size)})');
} else {
_showMessage('File selection cancelled');
}
} catch (e) {
if (mounted) _showMessage('Pick file failed: $e');
} finally {
if (mounted) setState(() => _isPicking = false);
}
}
Future<void> _pickMultipleFiles() async {
final manager = _manager;
if (manager == null) return;
setState(() => _isPicking = true);
try {
final files = await manager.pickFiles();
if (!mounted) return;
if (files.isNotEmpty) {
setState(() {
_pickedFiles
..clear()
..addAll(files);
});
_showMessage('Selected ${files.length} file(s)');
} else {
_showMessage('No files selected');
}
} catch (e) {
if (mounted) _showMessage('Pick files failed: $e');
} finally {
if (mounted) setState(() => _isPicking = false);
}
}
Future<void> _pickPhoto() async {
final manager = _manager;
if (manager == null) return;
setState(() => _isPicking = true);
try {
final picked = await manager.pickImage();
if (!mounted) return;
if (picked != null) {
setState(() {
_pickedFiles
..clear()
..add(picked);
});
_showMessage(
'Selected photo: ${picked.name} (${_formatBytes(picked.size)})',
);
} else {
_showMessage('Photo selection cancelled');
}
} catch (e) {
if (mounted) _showMessage('Pick photo failed: $e');
} finally {
if (mounted) setState(() => _isPicking = false);
}
}
Future<void> _pickVideo() async {
final manager = _manager;
if (manager == null) return;
setState(() => _isPicking = true);
try {
final picked = await manager.pickVideo();
if (!mounted) return;
if (picked != null) {
setState(() {
_pickedFiles
..clear()
..add(picked);
});
_showMessage(
'Selected video: ${picked.name} (${_formatBytes(picked.size)})',
);
} else {
_showMessage('Video selection cancelled');
}
} catch (e) {
if (mounted) _showMessage('Pick video failed: $e');
} finally {
if (mounted) setState(() => _isPicking = false);
}
}
Future<void> _pickMultipleMedia() async {
final manager = _manager;
if (manager == null) return;
setState(() => _isPicking = true);
try {
final files = await manager.pickMultipleMedia();
if (!mounted) return;
if (files.isNotEmpty) {
setState(() {
_pickedFiles
..clear()
..addAll(files);
});
_showMessage('Selected ${files.length} media item(s)');
} else {
_showMessage('No media selected');
}
} catch (e) {
if (mounted) _showMessage('Pick media failed: $e');
} finally {
if (mounted) setState(() => _isPicking = false);
}
}
Future<void> _pickAndUploadMediaImmediately({required bool tus}) async {
final manager = _manager;
if (manager == null) return;
final urlText = tus
? _tusUrlController.text.trim()
: _uploadUrlController.text.trim();
final uri = Uri.tryParse(urlText);
if (uri == null || !uri.hasScheme) {
_showMessage('Please enter a valid HTTP/HTTPS URL');
return;
}
if (!_notificationsEnabled) {
await _enableNotifications();
}
try {
final task = tus
? await manager.pickAndUploadMediaTus(
uri,
showNotification: true,
showLiveActivity: Platform.isIOS,
)
: await manager.pickAndUploadMedia(
uri,
showNotification: true,
showLiveActivity: Platform.isIOS,
);
if (task == null) {
_showMessage('No media selected');
return;
}
_attachTask(
task,
title: '${tus ? "[TUS] " : ""}Picked media upload',
subtitle: uri.toString(),
);
if (mounted) {
setState(() {});
_showMessage('Media upload started');
}
} catch (e) {
if (mounted) _showMessage('Pick & upload media failed: $e');
}
}
Future<void> _pickAndUploadMultipleMediaImmediately({
required bool tus,
}) async {
final manager = _manager;
if (manager == null) return;
final urlText = tus
? _tusUrlController.text.trim()
: _uploadUrlController.text.trim();
final uri = Uri.tryParse(urlText);
if (uri == null || !uri.hasScheme) {
_showMessage('Please enter a valid HTTP/HTTPS URL');
return;
}
if (!_notificationsEnabled) {
await _enableNotifications();
}
try {
final tasks = tus
? await manager.pickAndUploadMultipleMediaTus(
uri,
showNotification: true,
showLiveActivity: Platform.isIOS,
)
: await manager.pickAndUploadMultipleMedia(
uri,
showNotification: true,
showLiveActivity: Platform.isIOS,
);
if (tasks.isEmpty) {
_showMessage('No media selected');
return;
}
for (final task in tasks) {
final req = task.request;
final name = req is TusUploadRequest
? File(req.sourcePath).uri.pathSegments.lastOrNull ?? 'Media'
: req is UploadRequest
? File(req.sourcePath).uri.pathSegments.lastOrNull ?? 'Media'
: 'Media';
_attachTask(
task,
title: '${tus ? "[TUS] " : ""}$name',
subtitle: uri.toString(),
);
}
if (mounted) {
setState(() {});
_showMessage('Started ${tasks.length} media upload(s)');
}
} catch (e) {
if (mounted) _showMessage('Pick & upload multiple media failed: $e');
}
}
Future<void> _pickAndUploadMultipleFilesImmediately({
required bool tus,
}) async {
final manager = _manager;
if (manager == null) return;
final urlText = tus
? _tusUrlController.text.trim()
: _uploadUrlController.text.trim();
final uri = Uri.tryParse(urlText);
if (uri == null || !uri.hasScheme) {
_showMessage('Please enter a valid HTTP/HTTPS URL');
return;
}
if (!_notificationsEnabled) {
await _enableNotifications();
}
try {
final tasks = tus
? await manager.pickAndUploadMultipleFilesTus(
uri,
showNotification: true,
showLiveActivity: Platform.isIOS,
)
: await manager.pickAndUploadMultipleFiles(
uri,
showNotification: true,
showLiveActivity: Platform.isIOS,
);
if (tasks.isEmpty) {
_showMessage('No files selected');
return;
}
for (final task in tasks) {
final req = task.request;
final name = req is TusUploadRequest
? File(req.sourcePath).uri.pathSegments.lastOrNull ?? 'File'
: req is UploadRequest
? File(req.sourcePath).uri.pathSegments.lastOrNull ?? 'File'
: 'File';
_attachTask(
task,
title: '${tus ? "[TUS] " : ""}$name',
subtitle: uri.toString(),
);
}
if (mounted) {
setState(() {});
_showMessage('Started ${tasks.length} file upload(s)');
}
} catch (e) {
if (mounted) _showMessage('Pick & upload multiple files failed: $e');
}
}
Future<void> _createSampleFile() async {
try {
final tempDir = Directory(
'${Directory.systemTemp.path}/tm_sample_uploads',
);
await tempDir.create(recursive: true);
final timestamp = DateTime.now().millisecondsSinceEpoch;
final file = File('${tempDir.path}/sample_$timestamp.txt');
final content =
'transfer_manager sample upload payload\n'
'Created: ${DateTime.now().toIso8601String()}\n'
'${'Background upload verification payload line.\n' * 300}';
await file.writeAsString(content);
final size = await file.length();
final picked = TransferPickedFile(
path: file.path,
name: 'sample_$timestamp.txt',
size: size,
);
if (!mounted) return;
setState(() {
_pickedFiles.add(picked);
});
_showMessage(
'Generated sample file: ${picked.name} (${_formatBytes(size)})',
);
} catch (e) {
if (mounted) _showMessage('Could not create sample file: $e');
}
}
Future<void> _uploadPickedFiles({required bool tus}) async {
final manager = _manager;
if (manager == null || _pickedFiles.isEmpty) return;
final urlText = tus
? _tusUrlController.text.trim()
: _uploadUrlController.text.trim();
final uri = Uri.tryParse(urlText);
if (uri == null || !uri.hasScheme) {
_showMessage('Please enter a valid HTTP/HTTPS URL');
return;
}
if (!_notificationsEnabled) {
await _enableNotifications();
}
for (final file in _pickedFiles) {
try {
final task = tus
? await manager.uploadTus(
uri,
sourcePath: file.path,
metadata: {'filename': file.name},
showNotification: true,
showLiveActivity: Platform.isIOS,
sourcePolicy: UploadSourcePolicy.copyToManagedStorage,
)
: await manager.upload(
uri,
sourcePath: file.path,
showNotification: true,
showLiveActivity: Platform.isIOS,
sourcePolicy: UploadSourcePolicy.copyToManagedStorage,
);
_attachTask(
task,
title: '${tus ? "[TUS] " : ""}${file.name}',
subtitle: file.path,
);
if (mounted) {
setState(() {});
_showMessage('Queued upload: ${file.name}');
}
} catch (e) {
if (mounted) _showMessage('Failed to upload ${file.name}: $e');
}
}
}
Future<void> _pickAndUploadImmediately({required bool tus}) async {
final manager = _manager;
if (manager == null) return;
final urlText = tus
? _tusUrlController.text.trim()
: _uploadUrlController.text.trim();
final uri = Uri.tryParse(urlText);
if (uri == null || !uri.hasScheme) {
_showMessage('Please enter a valid HTTP/HTTPS URL');
return;
}
if (!_notificationsEnabled) {
await _enableNotifications();
}
try {
final task = tus
? await manager.pickAndUploadTus(
uri,
showNotification: true,
showLiveActivity: Platform.isIOS,
)
: await manager.pickAndUpload(
uri,
showNotification: true,
showLiveActivity: Platform.isIOS,
);
if (task == null) {
_showMessage('No file selected');
return;
}
_attachTask(
task,
title: '${tus ? "[TUS] " : ""}Picked file upload',
subtitle: uri.toString(),
);
if (mounted) {
setState(() {});
_showMessage('Upload started');
}
} catch (e) {
if (mounted) _showMessage('Pick & upload failed: $e');
}
}
void _attachTask(TransferTask task, {String? title, String? subtitle}) {
_taskViews[task.id] = TransferTaskView(
task: task,
title: title,
subtitle: subtitle,
);
_subscriptions.add(
task.events.listen((_) {
if (mounted) setState(() {});
}),
);
}
void _showMessage(String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}
@override
void dispose() {
_uploadUrlController.dispose();
_tusUrlController.dispose();
for (final subscription in _subscriptions) {
unawaited(subscription.cancel());
}
final manager = _manager;
if (manager != null) unawaited(manager.close());
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
_selectedTabIndex == 0
? 'Background downloads'
: 'Background uploads',
),
),
body: SafeArea(
child: _initializing
? const Center(child: CircularProgressIndicator())
: _error != null
? _ErrorView(message: _error!)
: RefreshIndicator(
onRefresh: _refreshNotificationStatus,
child: _selectedTabIndex == 0
? _buildDownloadsTab()
: _buildUploadsTab(),
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedTabIndex,
onDestinationSelected: (index) =>
setState(() => _selectedTabIndex = index),
destinations: const [
NavigationDestination(
icon: Icon(Icons.download_outlined),
selectedIcon: Icon(Icons.download),
label: 'Downloads',
),
NavigationDestination(
icon: Icon(Icons.upload_file_outlined),
selectedIcon: Icon(Icons.upload_file),
label: 'Uploads',
),
],
),
);
}
Widget _buildDownloadsTab() {
final downloadTasks = _taskViews.values
.where((v) => v.task.request is DownloadRequest)
.toList()
.reversed;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
Text('Choose a file', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 6),
Text(
'These downloads use WorkManager on Android and a '
'background URLSession on iOS. You can leave the app '
'while a transfer is running. iOS also shows progress '
'as a Live Activity.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
_NotificationCard(
enabled: _notificationsEnabled,
requesting: _requestingNotifications,
onEnable: _enableNotifications,
),
const SizedBox(height: 16),
for (final option in _downloads) ...[
_DownloadOptionCard(
option: option,
onDownload: () => _startDownload(option),
),
const SizedBox(height: 10),
],
const SizedBox(height: 14),
Text('Transfers', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
if (downloadTasks.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(20),
child: Text('No downloads yet.'),
),
)
else
for (final view in downloadTasks)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _TransferCard(view: view),
),
const SizedBox(height: 8),
Text(
Platform.isAndroid
? 'Files are saved in the system Downloads folder.'
: 'Files are available at:\n'
'Files → On My iPhone/iPad → '
'Transfer Manager → downloads',
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
Widget _buildUploadsTab() {
final uploadTasks = _taskViews.values
.where(
(v) =>
v.task.request is UploadRequest ||
v.task.request is TusUploadRequest,
)
.toList()
.reversed;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
Text('Upload Files', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 6),
Text(
'Pick files from your device and upload them in the background. '
'Supports standard multipart HTTP and TUS resumable protocols.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
_NotificationCard(
enabled: _notificationsEnabled,
requesting: _requestingNotifications,
onEnable: _enableNotifications,
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'1. Select or generate files & media',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton.tonalIcon(
onPressed: _isPicking ? null : _pickSingleFile,
icon: const Icon(Icons.file_open_outlined),
label: const Text('Pick File'),
),
FilledButton.tonalIcon(
onPressed: _isPicking ? null : _pickMultipleFiles,
icon: const Icon(Icons.file_copy_outlined),
label: const Text('Pick Multiple Files'),
),
FilledButton.tonalIcon(
onPressed: _isPicking ? null : _pickPhoto,
icon: const Icon(Icons.photo_outlined),
label: const Text('Pick Photo'),
),
FilledButton.tonalIcon(
onPressed: _isPicking ? null : _pickVideo,
icon: const Icon(Icons.videocam_outlined),
label: const Text('Pick Video'),
),
FilledButton.tonalIcon(
onPressed: _isPicking ? null : _pickMultipleMedia,
icon: const Icon(Icons.perm_media_outlined),
label: const Text('Pick Photos & Videos'),
),
OutlinedButton.icon(
onPressed: _createSampleFile,
icon: const Icon(Icons.note_add_outlined),
label: const Text('Generate Sample File'),
),
],
),
if (_isPicking) ...[
const SizedBox(height: 12),
const LinearProgressIndicator(),
],
const SizedBox(height: 14),
if (_pickedFiles.isEmpty)
Text(
'No files or media selected yet. Pick a file/photo/video or generate a sample file.',
style: Theme.of(context).textTheme.bodySmall,
)
else ...[
Text(
'Selected items (${_pickedFiles.length}):',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 6),
for (final file in _pickedFiles)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(
file.mimeType?.startsWith('image/') == true
? Icons.image_outlined
: file.mimeType?.startsWith('video/') == true
? Icons.video_file_outlined
: Icons.insert_drive_file_outlined,
),
title: Text(file.name),
subtitle: Text(
'${_formatBytes(file.size)}${file.mimeType != null ? " • ${file.mimeType}" : ""} • ${file.path}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
setState(() {
_pickedFiles.remove(file);
});
},
),
),
],
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'2. Configure Target Endpoint & Upload',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
TextField(
controller: _uploadUrlController,
decoration: const InputDecoration(
labelText: 'Standard Upload URL (HTTP POST)',
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton.icon(
onPressed: _pickedFiles.isEmpty
? null
: () => _uploadPickedFiles(tus: false),
icon: const Icon(Icons.upload),
label: Text(
_pickedFiles.isEmpty
? 'Upload Selected'
: 'Upload Selected (${_pickedFiles.length})',
),
),
OutlinedButton.icon(
onPressed: () => _pickAndUploadImmediately(tus: false),
icon: const Icon(Icons.touch_app),
label: const Text('Pick File & Upload'),
),
OutlinedButton.icon(
onPressed: () =>
_pickAndUploadMultipleFilesImmediately(tus: false),
icon: const Icon(Icons.file_copy_outlined),
label: const Text('Pick Files & Upload'),
),
OutlinedButton.icon(
onPressed: () =>
_pickAndUploadMediaImmediately(tus: false),
icon: const Icon(Icons.photo_outlined),
label: const Text('Pick Photo/Video & Upload'),
),
OutlinedButton.icon(
onPressed: () =>
_pickAndUploadMultipleMediaImmediately(tus: false),
icon: const Icon(Icons.perm_media_outlined),
label: const Text('Pick Photos/Videos & Upload'),
),
],
),
const Divider(height: 28),
TextField(
controller: _tusUrlController,
decoration: const InputDecoration(
labelText: 'TUS Resumable Upload URL',
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton.icon(
onPressed: _pickedFiles.isEmpty
? null
: () => _uploadPickedFiles(tus: true),
icon: const Icon(Icons.cloud_upload_outlined),
label: Text(
_pickedFiles.isEmpty
? 'TUS Upload Selected'
: 'TUS Upload Selected (${_pickedFiles.length})',
),
),
OutlinedButton.icon(
onPressed: () => _pickAndUploadImmediately(tus: true),
icon: const Icon(Icons.touch_app_outlined),
label: const Text('Pick File & TUS Upload'),
),
OutlinedButton.icon(
onPressed: () =>
_pickAndUploadMultipleFilesImmediately(tus: true),
icon: const Icon(Icons.file_copy_outlined),
label: const Text('Pick Files & TUS Upload'),
),
OutlinedButton.icon(
onPressed: () =>
_pickAndUploadMediaImmediately(tus: true),
icon: const Icon(Icons.photo_outlined),
label: const Text('Pick Photo/Video & TUS Upload'),
),
OutlinedButton.icon(
onPressed: () =>
_pickAndUploadMultipleMediaImmediately(tus: true),
icon: const Icon(Icons.perm_media_outlined),
label: const Text('Pick Photos/Videos & TUS Upload'),
),
],
),
],
),
),
),
const SizedBox(height: 16),
Text('Upload Transfers', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
if (uploadTasks.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(20),
child: Text('No uploads yet.'),
),
)
else
for (final view in uploadTasks)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _TransferCard(view: view),
),
],
);
}
Future<void> _refreshNotificationStatus() async {
final enabled = await _notificationsAreEnabled();
if (mounted) setState(() => _notificationsEnabled = enabled);
}
}
class DownloadOption {
const DownloadOption({
required this.title,
required this.subtitle,
required this.fileName,
required this.bytes,
required this.icon,
});
final String title;
final String subtitle;
final String fileName;
final int bytes;
final IconData icon;
}
class TransferTaskView {
const TransferTaskView({required this.task, this.title, this.subtitle});
final TransferTask task;
final String? title;
final String? subtitle;
}
class _NotificationCard extends StatelessWidget {
const _NotificationCard({
required this.enabled,
required this.requesting,
required this.onEnable,
});
final bool enabled;
final bool requesting;
final VoidCallback onEnable;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Card(
color: enabled ? colors.secondaryContainer : colors.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
enabled
? Icons.notifications_active_outlined
: Icons.notifications_off_outlined,
),
const SizedBox(width: 12),
Expanded(
child: Text(
enabled
? 'Download notifications are enabled.'
: Platform.isAndroid
? 'Enable notifications for progress and completion.'
: 'Enable notifications for completion alerts.',
),
),
if (!enabled)
TextButton(
onPressed: requesting ? null : onEnable,
child: requesting
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Enable'),
),
],
),
),
);
}
}
class _DownloadOptionCard extends StatelessWidget {
const _DownloadOptionCard({required this.option, required this.onDownload});
final DownloadOption option;
final VoidCallback onDownload;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(child: Icon(option.icon)),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.title,
style: Theme.of(context).textTheme.titleMedium,
),
Text(option.subtitle),
],
),
),
FilledButton.icon(
onPressed: onDownload,
icon: const Icon(Icons.download),
label: const Text('Download'),
),
],
),
),
);
}
}
class _TransferCard extends StatelessWidget {
const _TransferCard({required this.view});
final TransferTaskView view;
@override
Widget build(BuildContext context) {
final task = view.task;
final progress = task.progress;
final fraction = progress.fraction;
final isUpload =
task.request is UploadRequest || task.request is TusUploadRequest;
final defaultTitle = switch (task.request) {
DownloadRequest r => r.destination.fileName,
UploadRequest r =>
File(r.sourcePath).uri.pathSegments.lastOrNull ?? 'Restored upload',
TusUploadRequest r =>
File(r.sourcePath).uri.pathSegments.lastOrNull ?? 'Restored TUS upload',
};
final isActive = {
TransferState.created,
TransferState.queued,
TransferState.preparing,
TransferState.running,
TransferState.retryWaiting,
TransferState.verifying,
}.contains(task.state);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
isUpload ? Icons.upload_file : Icons.download_for_offline,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
view.title ?? defaultTitle,
style: Theme.of(context).textTheme.titleMedium,
),
),
_StateChip(state: task.state),
],
),
const SizedBox(height: 12),
LinearProgressIndicator(
value: task.state == TransferState.completed ? 1 : fraction,
),
const SizedBox(height: 8),
Text(
'${_formatBytes(progress.bytesTransferred)}'
'${progress.totalBytes == null ? '' : ' / ${_formatBytes(progress.totalBytes!)}'}',
),
if (task.error != null) ...[
const SizedBox(height: 6),
Text(
task.error.toString(),
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
if (view.subtitle != null) ...[
const SizedBox(height: 6),
Text(
view.subtitle!,
style: Theme.of(context).textTheme.bodySmall,
),
],
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
if (isActive)
TextButton.icon(
onPressed: task.pause,
icon: const Icon(Icons.pause),
label: const Text('Pause'),
),
if (task.state == TransferState.paused)
TextButton.icon(
onPressed: task.resume,
icon: const Icon(Icons.play_arrow),
label: const Text('Resume'),
),
if (task.state == TransferState.failed)
TextButton.icon(
onPressed: task.retry,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
if (task.state == TransferState.completed && !isUpload) ...[
TextButton.icon(
onPressed: task.open,
icon: const Icon(Icons.open_in_new),
label: const Text('Open'),
),
TextButton.icon(
onPressed: task.reveal,
icon: const Icon(Icons.folder_open),
label: const Text('Reveal'),
),
],
if (isActive || task.state == TransferState.paused)
TextButton.icon(
onPressed: task.cancel,
icon: const Icon(Icons.close),
label: const Text('Cancel'),
),
],
),
],
),
),
);
}
}
class _StateChip extends StatelessWidget {
const _StateChip({required this.state});
final TransferState state;
@override
Widget build(BuildContext context) {
final color = switch (state) {
TransferState.completed => Colors.green,
TransferState.failed => Theme.of(context).colorScheme.error,
TransferState.cancelled => Colors.grey,
TransferState.paused => Colors.orange,
_ => Theme.of(context).colorScheme.primary,
};
return Chip(
label: Text(state.name),
side: BorderSide(color: color),
labelStyle: TextStyle(color: color),
visualDensity: VisualDensity.compact,
);
}
}
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
message,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
);
}
}
String _formatBytes(int bytes) {
if (bytes < 1000) return '$bytes B';
if (bytes < 1000000) return '${(bytes / 1000).toStringAsFixed(1)} KB';
return '${(bytes / 1000000).toStringAsFixed(1)} MB';
}