tus_background_upload 0.1.0
tus_background_upload: ^0.1.0 copied to clipboard
TUS resumable uploads that keep running while the app is backgrounded or the device is locked, over native transports (iOS background URLSession, Android WorkManager via background_downloader).
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:tus_background_upload/tus_background_upload.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// One-time setup for long-running transfers: Android runs uploads as a
// foreground service (lifts the ~9-minute WorkManager cap) behind this
// notification; iOS gets an 8h URLSession resource timeout.
await configureBackgroundUploads(
notificationTitle: 'Uploading',
notificationBody: 'Your file keeps uploading in the background',
);
runApp(const TusExampleApp());
}
class TusExampleApp extends StatelessWidget {
const TusExampleApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'tus_background_upload example',
theme: ThemeData(colorSchemeSeed: Colors.teal),
home: const UploadScreen(),
);
}
class UploadScreen extends StatefulWidget {
const UploadScreen({super.key});
@override
State<UploadScreen> createState() => _UploadScreenState();
}
class _UploadScreenState extends State<UploadScreen> {
// Public tusd demo server — fine for testing; uploads are public and
// deleted after a day.
final TextEditingController _endpointController = TextEditingController(
text: 'https://tusd.tusdemo.net/files/',
);
File? _file;
double _progress = 0;
String _statusText = 'Pick a file to start';
TusUploader? _tusUploader;
Uri? _uploadUrl;
bool get _isPaused => _tusUploader?.status == TusUploadStatus.paused;
bool get _isUploading => _tusUploader?.status == TusUploadStatus.uploading;
@override
void dispose() {
_endpointController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('tus_background_upload')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
TextField(
controller: _endpointController,
decoration: const InputDecoration(
labelText: 'TUS creation endpoint',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: _isUploading || _isPaused ? null : () => unawaited(_pickFile()),
child: Text(_file == null ? 'Pick file' : _file!.uri.pathSegments.last),
),
const SizedBox(height: 16),
LinearProgressIndicator(value: _progress),
const SizedBox(height: 8),
Text(_statusText, textAlign: TextAlign.center),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
FilledButton(
onPressed: _file == null || _isUploading || _isPaused ? null : () => unawaited(_upload()),
child: const Text('Upload'),
),
OutlinedButton(
onPressed: _isUploading
? () => unawaited(_pause())
: _isPaused
? () => unawaited(_resume())
: null,
child: Text(_isPaused ? 'Resume' : 'Pause'),
),
OutlinedButton(
onPressed: _isUploading || _isPaused ? () => unawaited(_cancel()) : null,
child: const Text('Cancel'),
),
],
),
const SizedBox(height: 24),
const Text(
'Tip: start a big upload, background the app or lock the device for '
'10+ minutes — the transfer keeps running (watch the notification) '
'and progress catches up when you return.',
style: TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
],
),
),
);
Future<void> _cancel() async {
await _tusUploader?.cancel();
setState(() {});
}
Future<void> _pause() async {
await _tusUploader?.pause();
setState(() => _statusText = 'Paused — the server keeps the bytes it received');
}
Future<void> _pickFile() async {
final FilePickerResult? filePickerResult = await FilePicker.platform.pickFiles();
final String? filePath = filePickerResult?.files.single.path;
if (filePath == null) {
return;
}
setState(() {
_file = File(filePath);
_progress = 0;
_statusText = 'Ready to upload';
});
}
Future<void> _resume() async {
final File? file = _file;
final Uri? uploadUrl = _uploadUrl;
if (file == null || uploadUrl == null) {
return;
}
setState(() => _statusText = 'Resuming from the server offset…');
await _tusUploader?.resume(
endpoint: uploadUrl,
headers: const <String, String>{},
filePath: file.path,
fileLength: await file.length(),
onProgress: _onProgress,
);
setState(() {});
}
Future<void> _upload() async {
final File? file = _file;
if (file == null) {
return;
}
try {
// Android 13+: the upload notification (and the foreground-service
// promotion that lifts the ~9-minute cap) needs this granted.
await requestBackgroundUploadNotificationPermission();
setState(() => _statusText = 'Creating upload…');
final int fileLength = await file.length();
// Step 1: TUS creation — returns the upload URL. Skip this when your
// backend hands out ready-made upload URLs itself.
_uploadUrl = await createTusUpload(
creationEndpoint: Uri.parse(_endpointController.text),
fileLength: fileLength,
metadata: <String, String>{'filename': file.uri.pathSegments.last},
);
// Step 2: the transfer, on a native background-capable transport.
_tusUploader = TusUploader(transport: BackgroundDownloaderTusTransport());
setState(() => _statusText = 'Uploading…');
await _tusUploader!.upload(
endpoint: _uploadUrl!,
headers: const <String, String>{},
filePath: file.path,
fileLength: fileLength,
onProgress: _onProgress,
);
setState(() => _statusText = 'Done! Uploaded to $_uploadUrl');
} on TusUploadCancelledException {
setState(() {
_progress = 0;
_statusText = 'Cancelled';
});
} catch (error) {
setState(() => _statusText = 'Failed: $error');
}
}
void _onProgress(double progress) {
if (!mounted) {
return;
}
setState(() {
_progress = progress;
_statusText = 'Uploading… ${(progress * 100).toStringAsFixed(0)}%';
});
}
}