plux_media_picker 2.0.0
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.
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]}';
}