transfer_manager 2.3.0 copy "transfer_manager: ^2.3.0" to clipboard
transfer_manager: ^2.3.0 copied to clipboard

A protocol-aware, crash-safe transfer engine for Dart and Flutter.

example/lib/main.dart

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';
}
0
likes
160
points
314
downloads

Documentation

API reference

Publisher

verified publisherpinz.dev

Weekly Downloads

A protocol-aware, crash-safe transfer engine for Dart and Flutter.

Repository (GitHub)
View/report issues

License

BSD-3-Clause (license)

Dependencies

crypto

More

Packages that depend on transfer_manager