plux_media_picker 2.0.0 copy "plux_media_picker: ^2.0.0" to clipboard
plux_media_picker: ^2.0.0 copied to clipboard

A Flutter plugin that provides a unified API to pick photos, videos, and files from the device's gallery, camera, and file system.

example/lib/main.dart

import 'dart:async';
import 'dart:io';
import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:plux_media_picker/plux_media_picker.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'plux_media_picker demo',
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final PluxMediaPicker _picker = PluxMediaPicker();

  /// Compression quality shared by [PluxMediaPicker.pickCamera] and
  /// [PluxMediaPicker.pickGallery].
  double _quality = 0.8;

  /// Max number of items [PluxMediaPicker.pickGallery] is allowed to return.
  int _maxLimit = 5;

  /// Extension filter for [PluxMediaPicker.pickFiles]. Empty means "any file".
  final Set<String> _extensions = {};
  static const List<String> _availableExtensions = ['pdf', 'txt', 'jpg', 'png', 'mp4', 'zip'];

  List<PluxFile> _results = const [];
  List<PluxFile> _lostFiles = const [];
  String? _lastAction;
  String? _status;
  String? _error;
  bool _busy = false;

  @override
  void initState() {
    super.initState();
    // On Android the system may destroy the activity while the picker is open.
    // The result is cached natively and can be recovered on the next launch.
    WidgetsBinding.instance.addPostFrameCallback((_) => _recoverLostFiles(silent: true));
  }

  /// Runs a plugin call, keeping the UI in a single place for progress and errors.
  Future<void> _run(String action, Future<void> Function() body) async {
    setState(() {
      _busy = true;
      _lastAction = action;
      _status = null;
      _error = null;
    });

    try {
      await body();
    } on PluxMediaPickerException catch (ex) {
      // Thrown for permission denials, compression/save failures, etc.
      if (mounted) setState(() => _error = '${ex.code.name}: ${ex.description ?? 'no description'}');
    } catch (ex) {
      if (mounted) setState(() => _error = ex.toString());
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

  Future<void> _pickCamera(PluxMediaType mediaType) {
    return _run('pickCamera(${mediaType.name}, quality: ${_quality.toStringAsFixed(1)})', () async {
      final file = await _picker.pickCamera(mediaType: mediaType, quality: _quality);
      if (!mounted) return;
      // null means the user closed the camera without capturing anything.
      setState(() {
        _results = file == null ? const [] : [file];
        _status = file == null ? 'Cancelled by user' : '1 file captured';
      });
    });
  }

  Future<void> _pickGallery() {
    return _run('pickGallery(maxLimit: $_maxLimit, quality: ${_quality.toStringAsFixed(1)})', () async {
      final files = await _picker.pickGallery(maxLimit: _maxLimit, quality: _quality);
      if (!mounted) return;
      // An empty list means nothing was selected.
      setState(() {
        _results = files;
        _status = files.isEmpty ? 'Cancelled by user' : '${files.length} file(s) selected';
      });
    });
  }

  Future<void> _pickFiles() {
    final extensions = _extensions.toList()..sort();
    return _run('pickFiles(allowedExtensions: $extensions, maxLimit: $_maxLimit)', () async {
      final files = await _picker.pickFiles(allowedExtensions: extensions, maxLimit: _maxLimit);
      if (!mounted) return;
      setState(() {
        _results = files;
        _status = files.isEmpty ? 'Cancelled by user' : '${files.length} document(s) picked';
      });
    });
  }

  /// [PluxMediaPicker.copyToFile] — the bytes are copied natively, so nothing crosses into Dart.
  Future<void> _copyToCache(PluxFile file) {
    return _run('copyToFile(${file.name})', () async {
      final directory = await Directory.systemTemp.createTemp('plux_copy');
      final copy = await _picker.copyToFile(file, '${directory.path}/${file.name}');
      if (!mounted) return;
      setState(() => _status = 'Copied to ${copy.path}');
    });
  }

  Future<void> _recoverLostFiles({bool silent = false}) {
    return _run('getLostFiles()', () async {
      final files = await _picker.getLostFiles();
      if (!mounted) return;
      // Always empty on iOS: the OS does not kill the app behind the picker there.
      if (files.isEmpty && silent) return;
      setState(() {
        _lostFiles = files;
        _status = files.isEmpty ? 'No lost files' : '${files.length} file(s) recovered';
      });
    });
  }

  Future<void> _clearCache() {
    return _run('clearCache()', () async {
      final cleared = await _picker.clearCache();
      if (!mounted) return;
      // Paths from previous picks point into that cache, so drop them too.
      setState(() {
        _results = const [];
        _lostFiles = const [];
        _status = cleared ? 'Cache cleared' : 'Cache not cleared';
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('plux_media_picker'),
        bottom: _busy ? const PreferredSize(preferredSize: Size.fromHeight(4), child: LinearProgressIndicator()) : null,
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          _CameraSection(
            quality: _quality,
            onQualityChanged: _busy ? null : (value) => setState(() => _quality = value),
            onPick: _busy ? null : _pickCamera,
          ),
          const SizedBox(height: 12),
          _GallerySection(
            maxLimit: _maxLimit,
            onMaxLimitChanged: _busy ? null : (value) => setState(() => _maxLimit = value),
            onPick: _busy ? null : _pickGallery,
          ),
          const SizedBox(height: 12),
          _FileSection(
            available: _availableExtensions,
            selected: _extensions,
            onToggle: _busy
                ? null
                : (ext) => setState(() => _extensions.contains(ext) ? _extensions.remove(ext) : _extensions.add(ext)),
            onPick: _busy ? null : _pickFiles,
          ),
          const SizedBox(height: 12),
          _MaintenanceSection(
            onRecoverLostFiles: _busy ? null : () => _recoverLostFiles(),
            onClearCache: _busy ? null : _clearCache,
            lostFiles: _lostFiles,
          ),
          const SizedBox(height: 12),
          _ResultSection(
            action: _lastAction,
            status: _status,
            error: _error,
            results: _results,
            onCopy: _busy ? null : _copyToCache,
          ),
          if (_results.isNotEmpty) ...[
            const SizedBox(height: 12),
            FileStreamCard(picker: _picker, files: _results),
          ],
        ],
      ),
    );
  }
}

/// [PluxMediaPicker.pickCamera] — a single photo or video from the system camera.
class _CameraSection extends StatelessWidget {
  const _CameraSection({required this.quality, required this.onQualityChanged, required this.onPick});

  final double quality;
  final ValueChanged<double>? onQualityChanged;
  final void Function(PluxMediaType)? onPick;

  @override
  Widget build(BuildContext context) {
    return _Section(
      title: 'Camera',
      subtitle: 'pickCamera() returns a single PluxFile, or null if the user cancels.',
      children: [
        Row(
          children: [
            const Text('Quality'),
            Expanded(
              child: Slider(
                value: quality,
                min: 0.1,
                divisions: 9,
                label: quality.toStringAsFixed(1),
                onChanged: onQualityChanged,
              ),
            ),
            Text(quality.toStringAsFixed(1)),
          ],
        ),
        Row(
          spacing: 12,
          children: [
            Expanded(
              child: FilledButton.icon(
                onPressed: onPick == null ? null : () => onPick!(PluxMediaType.image),
                icon: const Icon(Icons.photo_camera),
                label: const Text('Photo'),
              ),
            ),
            Expanded(
              child: FilledButton.icon(
                onPressed: onPick == null ? null : () => onPick!(PluxMediaType.video),
                icon: const Icon(Icons.videocam),
                label: const Text('Video'),
              ),
            ),
          ],
        ),
      ],
    );
  }
}

/// [PluxMediaPicker.pickGallery] — multi-select from the system photo library.
class _GallerySection extends StatelessWidget {
  const _GallerySection({required this.maxLimit, required this.onMaxLimitChanged, required this.onPick});

  final int maxLimit;
  final ValueChanged<int>? onMaxLimitChanged;
  final VoidCallback? onPick;

  @override
  Widget build(BuildContext context) {
    return _Section(
      title: 'Gallery',
      subtitle: 'pickGallery() returns a list of PluxFile, empty if nothing was selected.',
      children: [
        Row(
          children: [
            const Text('Max limit'),
            const Spacer(),
            IconButton(
              onPressed: onMaxLimitChanged == null || maxLimit <= 1 ? null : () => onMaxLimitChanged!(maxLimit - 1),
              icon: const Icon(Icons.remove_circle_outline),
            ),
            Text('$maxLimit'),
            IconButton(
              onPressed: onMaxLimitChanged == null || maxLimit >= 20 ? null : () => onMaxLimitChanged!(maxLimit + 1),
              icon: const Icon(Icons.add_circle_outline),
            ),
          ],
        ),
        FilledButton.icon(
          onPressed: onPick,
          icon: const Icon(Icons.photo_library),
          label: const Text('Pick from gallery'),
        ),
      ],
    );
  }
}

/// [PluxMediaPicker.pickFiles] — documents, optionally filtered by extension.
class _FileSection extends StatelessWidget {
  const _FileSection({required this.available, required this.selected, required this.onToggle, required this.onPick});

  final List<String> available;
  final Set<String> selected;
  final ValueChanged<String>? onToggle;
  final VoidCallback? onPick;

  @override
  Widget build(BuildContext context) {
    return _Section(
      title: 'Files',
      subtitle: 'pickFiles() filters by extension and honours maxLimit. No selection allows every type.',
      children: [
        Wrap(
          spacing: 8,
          children: available
              .map(
                (ext) => FilterChip(
                  label: Text(ext),
                  selected: selected.contains(ext),
                  onSelected: onToggle == null ? null : (_) => onToggle!(ext),
                ),
              )
              .toList(),
        ),
        FilledButton.icon(
          onPressed: onPick,
          icon: const Icon(Icons.folder_open),
          label: const Text('Pick a file'),
        ),
      ],
    );
  }
}

/// [PluxMediaPicker.getLostFiles] and [PluxMediaPicker.clearCache].
class _MaintenanceSection extends StatelessWidget {
  const _MaintenanceSection({required this.onRecoverLostFiles, required this.onClearCache, required this.lostFiles});

  final VoidCallback? onRecoverLostFiles;
  final VoidCallback? onClearCache;
  final List<PluxFile> lostFiles;

  @override
  Widget build(BuildContext context) {
    return _Section(
      title: 'Lost files & cache',
      subtitle:
          'getLostFiles() recovers a pick that finished after Android destroyed the activity, '
          'uri included, so it can still be streamed. It always returns an empty list on iOS.',
      children: [
        Row(
          spacing: 12,
          children: [
            Expanded(
              child: OutlinedButton.icon(
                onPressed: onRecoverLostFiles,
                icon: const Icon(Icons.restore),
                label: const Text('Lost files'),
              ),
            ),
            Expanded(
              child: OutlinedButton.icon(
                onPressed: onClearCache,
                icon: const Icon(Icons.delete_sweep),
                label: const Text('Clear cache'),
              ),
            ),
          ],
        ),
        for (final file in lostFiles) _PluxFileTile(file: file),
      ],
    );
  }
}

/// Shows what the last call returned: the status line, the error and every [PluxFile].
class _ResultSection extends StatelessWidget {
  const _ResultSection({
    required this.action,
    required this.status,
    required this.error,
    required this.results,
    required this.onCopy,
  });

  final String? action;
  final String? status;
  final String? error;
  final List<PluxFile> results;
  final void Function(PluxFile)? onCopy;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;

    return _Section(
      title: 'Result',
      subtitle: action ?? 'Nothing called yet.',
      children: [
        if (status != null) Text(status!),
        if (error != null) Text(error!, style: TextStyle(color: colors.error)),
        for (final file in results) _PluxFileTile(file: file, onCopy: onCopy),
      ],
    );
  }
}

class _PluxFileTile extends StatelessWidget {
  const _PluxFileTile({required this.file, this.onCopy});

  final PluxFile file;
  final void Function(PluxFile)? onCopy;

  @override
  Widget build(BuildContext context) {
    final style = Theme.of(context).textTheme.bodySmall;
    final path = file.path;

    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 6),
      child: Row(
        spacing: 12,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // [PluxFile.path] is null for documents — nothing for dart:io to preview then.
          if (path != null && file.type == PluxMediaType.image)
            ClipRRect(
              borderRadius: BorderRadius.circular(8),
              child: Image.file(File(path), width: 64, height: 64, fit: BoxFit.cover),
            ),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(file.name.isEmpty ? '(no name)' : file.name),
                Text('${file.type.name} · ${file.mimeType} · ${formatBytes(file.size)}', style: style),
                if (path != null) Text('path: $path', style: style, maxLines: 2),
                Text('uri: ${file.uri}', style: style, maxLines: 2),
              ],
            ),
          ),
          if (onCopy != null)
            IconButton(
              tooltip: 'copyToFile()',
              onPressed: () => onCopy!(file),
              icon: const Icon(Icons.save_alt),
            ),
        ],
      ),
    );
  }
}

enum _StreamStatus { idle, reading, done, cancelled, error }

/// [PluxMediaPicker.readFileStream] — reads a picked file chunk by chunk instead of
/// loading it into memory at once.
///
/// The stream is opened by [PluxFile.uri], the opaque handle every pick carries, so any result
/// can be read this way - including documents, which have no [PluxFile.path] at all.
class FileStreamCard extends StatefulWidget {
  const FileStreamCard({super.key, required this.picker, required this.files});

  final PluxMediaPicker picker;
  final List<PluxFile> files;

  @override
  State<FileStreamCard> createState() => _FileStreamCardState();
}

class _FileStreamCardState extends State<FileStreamCard> {
  final Stopwatch _stopwatch = Stopwatch();

  static const List<int> _bufferSizes = [64 * 1024, PluxMediaPicker.defaultStreamBufferSize, 4 * 1024 * 1024];

  StreamSubscription<Uint8List>? _subscription;
  PluxFile? _file;
  _StreamStatus _status = _StreamStatus.idle;
  int _bufferSize = PluxMediaPicker.defaultStreamBufferSize;
  int _bytesRead = 0;
  int _chunks = 0;
  String? _error;

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }

  Future<void> _read(PluxFile file) async {
    await _subscription?.cancel();
    _subscription = null;

    setState(() {
      _file = file;
      _status = _StreamStatus.reading;
      _bytesRead = 0;
      _chunks = 0;
      _error = null;
    });

    try {
      final stream = await widget.picker.readFileStream(file.uri, bufferSize: _bufferSize);
      if (!mounted) return;

      _stopwatch
        ..reset()
        ..start();

      _subscription = stream.listen(
        (chunk) {
          if (!mounted) return;
          setState(() {
            _bytesRead += chunk.length;
            _chunks++;
          });
        },
        onError: (Object err) {
          _stopwatch.stop();
          if (!mounted) return;
          setState(() {
            _status = _StreamStatus.error;
            _error = err.toString();
          });
        },
        onDone: () {
          _stopwatch.stop();
          if (!mounted) return;
          setState(() => _status = _StreamStatus.done);
        },
        cancelOnError: true,
      );
    } on PluxMediaPickerException catch (ex) {
      // fileStreamCreationFailed — the native side could not open the uri.
      _stopwatch.stop();
      if (!mounted) return;
      setState(() {
        _status = _StreamStatus.error;
        _error = '${ex.code.name}: ${ex.description ?? 'no description'}';
      });
    }
  }

  /// Cancelling the subscription stops the native read loop as well.
  Future<void> _cancel() async {
    await _subscription?.cancel();
    _subscription = null;
    _stopwatch.stop();
    if (!mounted) return;
    setState(() => _status = _StreamStatus.cancelled);
  }

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    final streamable = widget.files.where((f) => f.uri.isNotEmpty).toList();
    final file = _file;
    final isReading = _status == _StreamStatus.reading;
    final total = file?.size ?? 0;
    final progress = total > 0 ? (_bytesRead / total).clamp(0.0, 1.0) : null;

    return _Section(
      title: 'Stream reading',
      subtitle: 'readFileStream() emits chunks over an event channel. Cancelling stops the native reader.',
      children: [
        Row(
          spacing: 8,
          children: [
            const Text('Chunk'),
            for (final size in _bufferSizes)
              Expanded(
                child: ChoiceChip(
                  label: Text(formatBytes(size), overflow: TextOverflow.ellipsis),
                  selected: _bufferSize == size,
                  onSelected: isReading ? null : (_) => setState(() => _bufferSize = size),
                ),
              ),
          ],
        ),

        if (streamable.isEmpty)
          const Text('The current result has no uri, so there is nothing to stream.')
        else
          for (final f in streamable)
            OutlinedButton.icon(
              onPressed: isReading ? null : () => _read(f),
              icon: const Icon(Icons.download),
              label: Text('Read ${f.name.isEmpty ? f.uri : f.name}', overflow: TextOverflow.ellipsis),
            ),

        if (file != null) ...[
          const Divider(),
          Text('Status: ${_status.name}'),
          LinearProgressIndicator(value: progress),
          Text(
            'Read ${formatBytes(_bytesRead)} of ${formatBytes(total)}'
            '${progress != null ? ' (${(progress * 100).toStringAsFixed(1)}%)' : ''}'
            ' in $_chunks chunk(s)',
          ),
          Text('Elapsed ${_stopwatch.elapsedMilliseconds} ms · ${_formatSpeed()}'),
          // A short read means the stream ended early, e.g. the file changed underneath.
          if (total > 0 && _status == _StreamStatus.done && _bytesRead != total)
            Text('Expected $total bytes, got $_bytesRead', style: TextStyle(color: colors.error)),
          if (_error != null) Text(_error!, style: TextStyle(color: colors.error)),
          OutlinedButton.icon(
            onPressed: isReading ? _cancel : null,
            icon: const Icon(Icons.stop),
            label: const Text('Cancel'),
          ),
        ],
      ],
    );
  }

  String _formatSpeed() {
    final seconds = _stopwatch.elapsedMilliseconds / 1000;
    if (seconds <= 0) return '—';
    return '${formatBytes((_bytesRead / seconds).round())}/s';
  }
}

class _Section extends StatelessWidget {
  const _Section({required this.title, required this.subtitle, required this.children});

  final String title;
  final String subtitle;
  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          spacing: 8,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text(title, style: theme.textTheme.titleMedium),
            Text(subtitle, style: theme.textTheme.bodySmall),
            ...children,
          ],
        ),
      ),
    );
  }
}

String formatBytes(int? bytes) {
  if (bytes == null) return 'unknown size';

  const units = ['B', 'KB', 'MB', 'GB'];
  var value = bytes.toDouble();
  var unit = 0;
  while (value >= 1024 && unit < units.length - 1) {
    value /= 1024;
    unit++;
  }
  return '${value.toStringAsFixed(unit == 0 ? 0 : 1)} ${units[unit]}';
}
0
likes
160
points
20
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter plugin that provides a unified API to pick photos, videos, and files from the device's gallery, camera, and file system.

Repository (GitHub)
View/report issues

Topics

#media #image-picker #file-picker #camera #gallery

License

MIT (license)

Dependencies

flutter, meta

More

Packages that depend on plux_media_picker

Packages that implement plux_media_picker