another_tus_client 3.0.0 copy "another_tus_client: ^3.0.0" to clipboard
another_tus_client: ^3.0.0 copied to clipboard

A tus client for dart allowing resumable uploads using the tus protocol.

example/lib/main.dart

import 'package:another_tus_client/another_tus_client.dart';
import 'package:universal_io/io.dart';
import 'package:cross_file/cross_file.dart' show XFile;
import 'package:device_info_plus/device_info_plus.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'TUS Client Upload Demo',
      theme: ThemeData(
        primarySwatch: Colors.teal,
        useMaterial3: true,
      ),
      // Add this to force LTR for the entire app
      builder: (context, child) {
        return Directionality(
          textDirection: TextDirection.ltr,
          child: child!,
        );
      },
      home: UploadPage(),
    );
  }
}

class UploadPage extends StatefulWidget {
  @override
  _UploadPageState createState() => _UploadPageState();
}

class _UploadPageState extends State<UploadPage> {
  double _progress = 0;
  Duration _estimate = Duration();
  XFile? _file;
  TusClient? _client;
  Uri? _fileUrl;
  bool _isLoading = false;
  bool _isSettingsOpen = false;

  // Endpoint settings
  final TextEditingController _endpointController = TextEditingController();
  
  // FilePicker settings
  bool _withData = true;
  bool _withReadStream = true;
  
  // Headers and metadata
  List<MapEntry<String, String>> _headers = [MapEntry('', '')];
  List<MapEntry<String, String>> _metadata = [MapEntry('', '')];

  // Persistent controllers for Headers
  final List<TextEditingController> _headerKeyControllers = [];
  final List<TextEditingController> _headerValueControllers = [];

  // Persistent controllers for Metadata
  final List<TextEditingController> _metadataKeyControllers = [];
  final List<TextEditingController> _metadataValueControllers = [];

  @override
  void initState() {
    super.initState();
    // Initialize controllers with the initial values.
    _initializeHeaderControllers();
    _initializeMetadataControllers();
    _loadSettings();
  }

  void _initializeHeaderControllers() {
    _headerKeyControllers.clear();
    _headerValueControllers.clear();
    for (var entry in _headers) {
      _headerKeyControllers.add(TextEditingController(text: entry.key));
      _headerValueControllers.add(TextEditingController(text: entry.value));
    }
  }

  void _initializeMetadataControllers() {
    _metadataKeyControllers.clear();
    _metadataValueControllers.clear();
    for (var entry in _metadata) {
      _metadataKeyControllers.add(TextEditingController(text: entry.key));
      _metadataValueControllers.add(TextEditingController(text: entry.value));
    }
  }

  Future<void> _loadSettings() async {
    setState(() => _isLoading = true);
    
    try {
      final prefs = await SharedPreferences.getInstance();
      
      setState(() {
        _endpointController.text =
            prefs.getString('endpoint') ?? 'http://localhost:8080/files/';
        _withData = prefs.getBool('withData') ?? true;
        _withReadStream = prefs.getBool('withReadStream') ?? true;
        
        // Load headers
        final headersJson = prefs.getString('headers');
        if (headersJson != null) {
          final Map<String, dynamic> headersMap = jsonDecode(headersJson);
          _headers = headersMap.entries
              .map((e) => MapEntry(e.key, e.value.toString()))
              .toList();
          if (_headers.isEmpty) _headers = [MapEntry('', '')];
        }
        
        // Load metadata
        final metadataJson = prefs.getString('metadata');
        if (metadataJson != null) {
          final Map<String, dynamic> metadataMap = jsonDecode(metadataJson);
          _metadata = metadataMap.entries
              .map((e) => MapEntry(e.key, e.value.toString()))
              .toList();
          if (_metadata.isEmpty) _metadata = [MapEntry('', '')];
        }
        // Reinitialize controllers after settings load
        _initializeHeaderControllers();
        _initializeMetadataControllers();
      });
    } catch (e) {
      print('Error loading settings: $e');
    } finally {
      setState(() => _isLoading = false);
    }
  }

  Future<void> _saveSettings() async {
    try {
      final prefs = await SharedPreferences.getInstance();
      
      await prefs.setString('endpoint', _endpointController.text);
      await prefs.setBool('withData', _withData);
      await prefs.setBool('withReadStream', _withReadStream);
      
      // Save headers (removing empty ones)
      final Map<String, String> headersMap = {};
      for (var entry in _headers) {
        if (entry.key.isNotEmpty && entry.value.isNotEmpty) {
          headersMap[entry.key] = entry.value;
        }
      }
      await prefs.setString('headers', jsonEncode(headersMap));
      
      // Save metadata (removing empty ones)
      final Map<String, String> metadataMap = {};
      for (var entry in _metadata) {
        if (entry.key.isNotEmpty && entry.value.isNotEmpty) {
          metadataMap[entry.key] = entry.value;
        }
      }
      await prefs.setString('metadata', jsonEncode(metadataMap));
      
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Settings saved')),
      );
    } catch (e) {
      print('Error saving settings: $e');
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Error saving settings: $e')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('TUS Client Upload Demo'),
        actions: [
          IconButton(
            icon: Icon(_isSettingsOpen ? Icons.close : Icons.settings),
            onPressed: () {
              setState(() {
                _isSettingsOpen = !_isSettingsOpen;
              });
            },
          ),
        ],
      ),
      body: _isLoading
          ? Center(child: CircularProgressIndicator())
          : _isSettingsOpen
              ? _buildSettingsPanel()
              : _buildUploadPanel(),
    );
  }

  Widget _buildSettingsPanel() {
    return SingleChildScrollView(
      padding: EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Text('Upload Settings',
              style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
          SizedBox(height: 16),
          // Endpoint settings
          Text('Endpoint URL', style: TextStyle(fontWeight: FontWeight.bold)),
          Directionality(
            textDirection: TextDirection.ltr,
            child: TextField(
              controller: _endpointController,
              decoration: InputDecoration(
                hintText: 'https://your-tus-server.com/files/',
                border: OutlineInputBorder(),
              ),
              textDirection: TextDirection.ltr,
              textAlign: TextAlign.left,
            ),
          ),
          SizedBox(height: 16),
          // FilePicker settings
          Text('File Picker Settings',
              style: TextStyle(fontWeight: FontWeight.bold)),
          CheckboxListTile(
            title: Text('With Data (load file into memory)'),
            subtitle:
                Text('Required for some platforms, but uses more memory'),
            value: _withData,
            onChanged: (value) {
              setState(() {
                _withData = value ?? true;
              });
            },
          ),
          CheckboxListTile(
            title: Text('With Read Stream (stream file data)'),
            subtitle: Text('More efficient for large files on web'),
            value: _withReadStream,
            onChanged: (value) {
              setState(() {
                _withReadStream = value ?? true;
              });
            },
          ),
          SizedBox(height: 16),
          // Headers
          Text('Custom Headers', style: TextStyle(fontWeight: FontWeight.bold)),
          ..._buildKeyValueList(_headers, _headerKeyControllers,
              _headerValueControllers, (newHeaders) {
            setState(() {
              _headers = newHeaders;
            });
          }),
          SizedBox(height: 8),
          OutlinedButton.icon(
            icon: Icon(Icons.add),
            label: Text('Add Header'),
            onPressed: () {
              setState(() {
                _headers.add(MapEntry('', ''));
                _headerKeyControllers.add(TextEditingController());
                _headerValueControllers.add(TextEditingController());
              });
            },
          ),
          SizedBox(height: 16),
          // Metadata
          Text('Metadata', style: TextStyle(fontWeight: FontWeight.bold)),
          ..._buildKeyValueList(_metadata, _metadataKeyControllers,
              _metadataValueControllers, (newMetadata) {
            setState(() {
              _metadata = newMetadata;
            });
          }),
          SizedBox(height: 8),
          OutlinedButton.icon(
            icon: Icon(Icons.add),
            label: Text('Add Metadata'),
            onPressed: () {
              setState(() {
                _metadata.add(MapEntry('', ''));
                _metadataKeyControllers.add(TextEditingController());
                _metadataValueControllers.add(TextEditingController());
              });
            },
          ),
          SizedBox(height: 24),
          // Save button
          ElevatedButton.icon(
            icon: Icon(Icons.save),
            label: Text('Save Settings'),
            onPressed: _saveSettings,
            style: ElevatedButton.styleFrom(
              padding: EdgeInsets.symmetric(vertical: 12),
            ),
          ),
        ],
      ),
    );
  }

  List<Widget> _buildKeyValueList(
    List<MapEntry<String, String>> items,
    List<TextEditingController> keyControllers,
    List<TextEditingController> valueControllers,
    Function(List<MapEntry<String, String>>) onUpdate,
  ) {
    return items.asMap().entries.map((entry) {
      final index = entry.key;
      return Padding(
        padding: EdgeInsets.only(bottom: 8),
        child: Row(
          children: [
            Expanded(
              child: Directionality(
                textDirection: TextDirection.ltr,
                child: TextField(
                  controller: keyControllers[index],
                  decoration: InputDecoration(
                    hintText: 'Key',
                    border: OutlineInputBorder(),
                    contentPadding:
                        EdgeInsets.symmetric(horizontal: 12, vertical: 8),
                  ),
                  textDirection: TextDirection.ltr,
                  textAlign: TextAlign.left,
                  onChanged: (value) {
                    items[index] = MapEntry(value, items[index].value);
                    onUpdate(items);
                  },
                ),
              ),
            ),
            SizedBox(width: 8),
            Expanded(
              child: Directionality(
                textDirection: TextDirection.ltr,
                child: TextField(
                  controller: valueControllers[index],
                  decoration: InputDecoration(
                    hintText: 'Value',
                    border: OutlineInputBorder(),
                    contentPadding:
                        EdgeInsets.symmetric(horizontal: 12, vertical: 8),
                  ),
                  textDirection: TextDirection.ltr,
                  textAlign: TextAlign.left,
                  onChanged: (value) {
                    items[index] = MapEntry(items[index].key, value);
                    onUpdate(items);
                  },
                ),
              ),
            ),
            IconButton(
              icon: Icon(Icons.delete, color: Colors.red),
              onPressed: () {
                if (items.length > 1) {
                  items.removeAt(index);
                  keyControllers.removeAt(index);
                  valueControllers.removeAt(index);
                  onUpdate(items);
                }
              },
            ),
          ],
        ),
      );
    }).toList();
  }

  Widget _buildUploadPanel() {
    return SingleChildScrollView(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          SizedBox(height: 12),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
            child: Text(
              "This demo uses TUS client to upload a file",
              style: TextStyle(fontSize: 18),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(6),
            child: Card(
              color: Colors.teal,
              child: InkWell(
                onTap: () async {
                  if (!kIsWeb && !await ensurePermissions()) {
                    return;
                  }

                  _file = await _getXFile();
                  setState(() {
                    _progress = 0;
                    _fileUrl = null;
                  });
                },
                child: Container(
                  padding: EdgeInsets.all(20),
                  child: Column(
                    children: <Widget>[
                      Icon(Icons.cloud_upload, color: Colors.white, size: 60),
                      Text(
                        "Upload a file",
                        style: TextStyle(fontSize: 25, color: Colors.white),
                      ),
                      if (_file != null)
                        Padding(
                          padding: const EdgeInsets.only(top: 8.0),
                          child: Text(
                            "Selected: ${_file!.name}",
                            style: TextStyle(fontSize: 14, color: Colors.white),
                          ),
                        ),
                    ],
                  ),
                ),
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8),
            child: Row(
              children: <Widget>[
                Expanded(
                  child: ElevatedButton(
                    onPressed: _file == null
                        ? null
                        : () async {
                            // Create a storage implementation based on platform
                            final store = await _createStore();

                            // Create a client
                            print("Create a client");
                            _client = TusClient(
                              _file!,
                              store: store,
                              maxChunkSize: 512 * 1024 * 10,
                            );

                            print("Starting upload");
                            
                            // Convert header entries to map
                            final headersMap = <String, String>{};
                            for (var entry in _headers) {
                              if (entry.key.isNotEmpty) {
                                headersMap[entry.key] = entry.value;
                              }
                            }
                            
                            // Convert metadata entries to map
                            final metadataMap = <String, String>{};
                            for (var entry in _metadata) {
                              if (entry.key.isNotEmpty) {
                                metadataMap[entry.key] = entry.value;
                              }
                            }
                            
                            await _client!.upload(
                              onStart:
                                  (TusClient client, Duration? estimation) {
                                print(estimation);
                              },
                              onComplete: () async {
                                print("Completed!");
                                if (!kIsWeb) {
                                  final tempDir =
                                      await getTemporaryDirectory();
                                  final tempDirectory = Directory(
                                      '${tempDir.path}/${_file?.name}_uploads');
                                  if (tempDirectory.existsSync()) {
                                    tempDirectory.deleteSync(recursive: true);
                                  }
                                }
                                setState(() => _fileUrl = _client!.uploadUrl);
                              },
                              onProgress: (progress, estimate) {
                                print("Progress: $progress");
                                print('Estimate: $estimate');
                                setState(() {
                                  _progress = progress;
                                  _estimate = estimate;
                                });
                              },
                              uri: Uri.parse(_endpointController.text),
                              metadata: metadataMap,
                              headers: headersMap,
                              measureUploadSpeed: !kIsWeb,
                            );
                          },
                    child: Text("Upload"),
                  ),
                ),
                SizedBox(width: 8),
                Expanded(
                  child: ElevatedButton(
                    onPressed: _progress == 0 || _client == null
                        ? null
                        : () async {
                            await _client!.pauseUpload();
                          },
                    child: Text("Pause"),
                  ),
                ),
              ],
            ),
          ),
          Stack(
            children: <Widget>[
              Container(
                margin: const EdgeInsets.all(8),
                padding: const EdgeInsets.all(1),
                color: Colors.grey,
                width: double.infinity,
                child: Text(" "),
              ),
              FractionallySizedBox(
                widthFactor: _progress / 100,
                child: Container(
                  margin: const EdgeInsets.all(8),
                  padding: const EdgeInsets.all(1),
                  color: Colors.green,
                  child: Text(" "),
                ),
              ),
              Container(
                margin: const EdgeInsets.all(8),
                padding: const EdgeInsets.all(1),
                width: double.infinity,
                child: Text(
                    "Progress: ${_progress.toStringAsFixed(1)}%, estimated time: ${_printDuration(_estimate)}"),
              ),
            ],
          ),
          if (_progress > 0)
            ElevatedButton(
              onPressed: _client == null ? null : () async {
                final result = await _client!.cancelUpload();

                if (result) {
                  setState(() {
                    _progress = 0;
                    _estimate = Duration();
                  });
                }
              },
              child: Text("Cancel"),
            ),
          GestureDetector(
            onTap: _progress != 100
                ? null
                : () async {
                    if (_fileUrl != null) {
                      await launchUrl(_fileUrl!);
                    }
                  },
            child: Container(
              color: _progress == 100 ? Colors.green : Colors.grey,
              padding: const EdgeInsets.all(8.0),
              margin: const EdgeInsets.all(8.0),
              child: Text(
                  _progress == 100 ? "Link to view:\n $_fileUrl" : "-"),
            ),
          ),
          // Display current settings summary
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Card(
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text('Current Settings:',
                        style: TextStyle(fontWeight: FontWeight.bold)),
                    Text('Endpoint: ${_endpointController.text}'),
                    Text(
                        'File picker: withData: $_withData, withReadStream: $_withReadStream'),
                    Text(
                        'Headers: ${_headers.where((e) => e.key.isNotEmpty).length} defined'),
                    Text(
                        'Metadata: ${_metadata.where((e) => e.key.isNotEmpty).length} defined'),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }

  String _printDuration(Duration duration) {
    String twoDigits(int n) => n.toString().padLeft(2, '0');
    final twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
    final twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
    return '$twoDigitMinutes:$twoDigitSeconds';
  }

  /// Create a platform-appropriate store
  Future<TusStore> _createStore() async {
    if (kIsWeb) {
      // Use IndexedDB for web
      return TusIndexedDBStore();
    } else {
      // Use file system for mobile/desktop
      final tempDir = await getTemporaryDirectory();
      final tempDirectory = Directory('${tempDir.path}/${_file?.name}_uploads');
      if (!tempDirectory.existsSync()) {
        tempDirectory.createSync(recursive: true);
      }
      return TusFileStore(tempDirectory);
    }
  }

  /// Get a file for upload using file picker
  Future<XFile?> _getXFile() async {
    try {
      print("Starting file selection...");

      if (!kIsWeb && !await ensurePermissions()) {
        print("Permissions not granted");
        return null;
      }

      print("Opening file picker...");
      print("Settings: withData: $_withData, withReadStream: $_withReadStream");
      
      final result = await FilePicker.platform.pickFiles(
        withData: _withData,
        withReadStream: _withReadStream,
      );

      if (result == null) {
        print("No file selected");
        return null;
      }

      print("File selected: ${result.files.first.name}");
      print("File size: ${result.files.first.size} bytes");
      print("File path: ${result.files.first.path}");
      print("Has file data: ${result.files.first.bytes != null}");
      print("Has read stream: ${result.files.first.readStream != null}");

      final platformFile = result.files.first;

      print("Creating XFile from PlatformFile...");
      if (kIsWeb) {
        print("Running on Web platform, creating StreamXFileWeb");
        final xFile = XFileFactory.fromPlatformFile(platformFile);
        final length = await xFile.length();
        print("Created XFile: ${xFile.name}, length: $length");
        return xFile;
      } else {
        print("Running on native platform");
        if (platformFile.path != null) {
          print("Using file path: ${platformFile.path}");
          final xFile = XFile(platformFile.path!);
          final length = await xFile.length();
          print("Created XFile: ${xFile.name}, length: $length");

          return xFile;
        } else {
          print("File path is null, trying to use bytes");
          if (platformFile.bytes != null) {
            final xFile = XFile.fromData(
              platformFile.bytes!,
              name: platformFile.name,
            );
            final length = await xFile.length();
            print("Created XFile from bytes: ${xFile.name}, length: $length");
            return xFile;
          } else {
            print("No path or bytes available for the file");
            return null;
          }
        }
      }
    } catch (e, stackTrace) {
      print("Error selecting file: $e");
      print("Stack trace: $stackTrace");
      return null;
    }
  }

  Future<bool> ensurePermissions() async {
    var enableStorage = true;

    if (Platform.isAndroid) {
      final devicePlugin = DeviceInfoPlugin();
      final androidDeviceInfo = await devicePlugin.androidInfo;
      _androidSdkVersion = androidDeviceInfo.version.sdkInt;
      enableStorage = _androidSdkVersion < 33;
    }

    final storage = enableStorage
        ? await Permission.storage.status
        : PermissionStatus.granted;
    final photos = Platform.isIOS
        ? await Permission.photos.status
        : PermissionStatus.granted;

    if (!storage.isGranted) {
      await Permission.storage.request();
    }

    if (Platform.isIOS && !photos.isGranted) {
      await Permission.photos.request();
    }

    return (enableStorage ? storage.isGranted : true) &&
        (Platform.isIOS ? photos.isGranted : true);
  }

  int _androidSdkVersion = 0;
  
  @override
  void dispose() {
    _endpointController.dispose();
    // Dispose header controllers
    for (var controller in _headerKeyControllers) {
      controller.dispose();
    }
    for (var controller in _headerValueControllers) {
      controller.dispose();
    }
    // Dispose metadata controllers
    for (var controller in _metadataKeyControllers) {
      controller.dispose();
    }
    for (var controller in _metadataValueControllers) {
      controller.dispose();
    }
    super.dispose();
  }
}
1
likes
0
points
525
downloads

Publisher

verified publisherbamboodigital.ca

Weekly Downloads

A tus client for dart allowing resumable uploads using the tus protocol.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

cross_file, file_picker, flutter, http, speed_test_dart, universal_io, web

More

Packages that depend on another_tus_client