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

Cross-platform file picking, saving, sharing, and opening for Flutter. Image and file pickers, save dialogs, OS share sheet, open-with — one API on mobile, desktop, and web.

example/lib/main.dart

// device_io example — every capability, one file.
//
// ONE file on purpose — pub.dev renders example/lib/main.dart as the
// package's Example tab. Splitting it hides everything past main.dart
// from that page. Do not modularize.
//
// Four tabs, one per API surface:
//   Pick   — picker: gallery images, camera capture, videos,
//            mixed media, generic files. Picked assets are lazy —
//            bytes are read on tap, never at pick time.
//   Share  — sharer: text, single file, multiple files in one
//            sheet, and a ~1MB byte stream.
//   Save   — saver: silent save to downloads, streamed save,
//            and the user-picks-destination system dialog.
//   Open   — opener: open in-memory bytes in the default
//            viewer, and open the last silently-saved path.
//
// Every call returns a sealed Outcome. One renderer
// (_record) exhaustively switches the family and writes the outcome
// into the activity log — the log sits below the TabBarView, global
// across all tabs. All demo bytes are generated in code; nothing
// touches assets or the network.

import 'dart:convert';
import 'dart:typed_data';

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

void main() {
  // Construction is sync; ensureInitialized() is the usual pre-runApp step,
  // and the plugin channels the capabilities use need a binding at call time.
  WidgetsFlutterBinding.ensureInitialized();
  final deviceIO = DeviceIO(
    config: const DeviceIOConfig(downloadsSubfolder: 'DeviceIOExample'),
  );
  runApp(DeviceIOExampleApp(deviceIO: deviceIO));
}

class DeviceIOExampleApp extends StatelessWidget {
  const DeviceIOExampleApp({required this.deviceIO, super.key});

  final DeviceIO deviceIO;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'device_io example',
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
        brightness: Brightness.light,
      ),
      darkTheme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
        brightness: Brightness.dark,
      ),
      home: HomePage(deviceIO: deviceIO),
    );
  }
}

// ── Activity log model ──────────────────────────────────────────────────

/// Visual severity of a log line, mapped one-to-one onto the five outcomes
/// of [Outcome].
enum LogLevel { success, neutral, warning, unsupported, error }

class LogEntry {
  const LogEntry(this.level, this.text);
  final LogLevel level;
  final String text;
}

/// A picked asset plus the byte length lazily read from it. [byteLength] stays
/// null until the user taps the tile — the whole point is to show that
/// [PickedAsset] loads nothing at pick time.
class PickedItem {
  PickedItem(this.asset);
  final PickedAsset asset;
  int? byteLength;
  bool loading = false;
}

class HomePage extends StatefulWidget {
  const HomePage({required this.deviceIO, super.key});

  final DeviceIO deviceIO;

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

class _HomePageState extends State<HomePage> {
  final List<LogEntry> _log = <LogEntry>[];
  final List<PickedItem> _picked = <PickedItem>[];

  /// Path returned by the most recent [FileSaver.save] — feeds
  /// the "open last saved" button. Null on web (no filesystem paths) and
  /// before the first successful save.
  String? _lastSavedPath;
  SaveLocation? _lastLocation;
  String? _pickedDir;

  DeviceIO get _io => widget.deviceIO;

  // ── The pedagogical heart: one exhaustive switch over the sealed family ──

  /// Render any [Outcome] into the activity log. Every device_io call
  /// funnels through here, so the five outcomes are handled in exactly one
  /// place. `PermissionDenied` is matched before its `Failed`
  /// supertype so the actionable "open settings" hint wins.
  void _record<T>(
    String action,
    Outcome<T> result, {
    String Function(T value)? describe,
  }) {
    final entry = switch (result) {
      Success<T>(:final value) => LogEntry(
        LogLevel.success,
        describe != null ? '$action → ${describe(value)}' : '$action → ok',
      ),
      Cancelled<T>() => LogEntry(LogLevel.neutral, '$action → cancelled'),
      PermissionDenied<T>(:final message) => LogEntry(
        LogLevel.warning,
        '$action → permission denied ($message). Open Settings to grant '
        'access.',
      ),
      Unsupported<T>(:final reason) => LogEntry(
        LogLevel.unsupported,
        '$action → unsupported: $reason',
      ),
      Failed<T>(:final message) => LogEntry(
        LogLevel.error,
        '$action → failed: $message',
      ),
    };
    setState(() => _log.insert(0, entry));
  }

  // ── Pick handlers ──────────────────────────────────────────────────────

  Future<void> _pickImage() async {
    final result = await _io.picker.pickImage();
    _absorbOne(result);
    _recordAsset(result, action: 'Pick image');
  }

  Future<void> _pickImages() async {
    final result = await _io.picker.pickImages(limit: 10);
    _absorbMany(result);
    _record(
      'Pick images',
      result,
      describe: (assets) => '${assets.length} selected',
    );
  }

  Future<void> _captureImage() async {
    final result = await _io.picker.captureImage();
    _absorbOne(result);
    _recordAsset(result, action: 'Capture image');
  }

  Future<void> _pickVideo() async {
    final result = await _io.picker.pickVideo();
    _absorbOne(result);
    _recordAsset(result, action: 'Pick video');
  }

  Future<void> _captureVideo() async {
    final result = await _io.picker.captureVideo(
      maxDuration: const Duration(seconds: 30),
    );
    _absorbOne(result);
    _recordAsset(result, action: 'Capture video');
  }

  Future<void> _pickMedia() async {
    final result = await _io.picker.pickMedia();
    _absorbOne(result);
    _recordAsset(result, action: 'Pick media');
  }

  Future<void> _pickMultipleMedia() async {
    final result = await _io.picker.pickMultipleMedia(limit: 5);
    _absorbMany(result);
    _record(
      'Pick multiple media',
      result,
      describe: (assets) => '${assets.length} selected',
    );
  }

  Future<void> _pickFile() async {
    final result = await _io.picker.pickFile();
    _absorbOne(result);
    _recordAsset(result, action: 'Pick file');
  }

  Future<void> _pickFiles() async {
    final result = await _io.picker.pickFiles();
    _absorbMany(result);
    _record(
      'Pick files',
      result,
      describe: (assets) => '${assets.length} selected',
    );
  }

  void _absorbOne(Outcome<PickedAsset> result) {
    if (result case Success(:final value)) {
      setState(() => _picked.add(PickedItem(value)));
    }
  }

  void _absorbMany(Outcome<List<PickedAsset>> result) {
    if (result case Success(:final value)) {
      setState(() => _picked.addAll(value.map(PickedItem.new)));
    }
  }

  /// Overload-style helper for single-asset picks: logs with the file name.
  void _recordAsset(Outcome<PickedAsset> result, {required String action}) {
    _record(
      action,
      result,
      describe: (asset) => asset.fileName ?? asset.mimeType,
    );
  }

  /// Read bytes on tap — demonstrates that nothing was loaded at pick time.
  Future<void> _measure(PickedItem item) async {
    setState(() => item.loading = true);
    final bytes = await item.asset.readBytes();
    setState(() {
      item.byteLength = bytes.length;
      item.loading = false;
    });
  }

  // ── Share handlers ─────────────────────────────────────────────────────

  Future<void> _shareText() async {
    final result = await _io.sharer.shareText(
      text: 'Shared from the device_io example app.',
      subject: 'device_io',
    );
    _record<void>('Share text', result);
  }

  Future<void> _shareFile() async {
    final bytes = _utf8('Generated in code, shared as a file.\n');
    final result = await _io.sharer.shareFile(
      bytes: bytes,
      fileName: 'note.txt',
      mimeType: 'text/plain',
      text: 'A small text file',
    );
    _record<void>('Share file', result);
  }

  Future<void> _shareFiles() async {
    // sharePositionOrigin omitted: it's the iPadOS popover anchor and null is
    // valid on every other platform. A real app anchors it to the tapped
    // button's global rect on iPad.
    final result = await _io.sharer.shareFiles(
      files: [
        ShareFile(
          bytes: _utf8('name,role\nAlice,admin\nBob,user\n'),
          fileName: 'people.csv',
          mimeType: 'text/csv',
        ),
        ShareFile(
          bytes: _utf8('Two files, one share sheet.\n'),
          fileName: 'note.txt',
          mimeType: 'text/plain',
        ),
      ],
      subject: 'device_io',
      text: 'A CSV and a text file together',
    );
    _record<void>('Share files (CSV + text)', result);
  }

  Future<void> _shareFileStream() async {
    final result = await _io.sharer.shareFileStream(
      byteStream: _patternedStream(1024 * 1024),
      fileName: 'onemb.bin',
      mimeType: 'application/octet-stream',
      text: 'A ~1MB streamed file',
    );
    _record<void>('Share file stream (~1MB)', result);
  }

  // ── Save handlers ──────────────────────────────────────────────────────

  Future<void> _save() async {
    final result = await _io.saver.save(
      bytes: _utf8('name,role\nAlice,admin\nBob,user\n'),
      fileName: 'people.csv',
      mimeType: 'text/csv',
    );
    _rememberSaved(result);
    _record('Save CSV', result, describe: _describeLocation);
  }

  Future<void> _saveStream() async {
    final result = await _io.saver.saveStream(
      byteStream: _textChunkStream(),
      fileName: 'log.txt',
      mimeType: 'text/plain',
    );
    _rememberSaved(result);
    _record('Save stream', result, describe: _describeLocation);
  }

  Future<void> _saveAs() async {
    final result = await _io.saver.saveAs(
      bytes: _utf8('id,value\n1,Pick where this lands\n'),
      fileName: 'export.csv',
      dialogTitle: 'Save example export',
      mimeType: 'text/csv',
    );
    _rememberSaved(result);
    _record('Save as…', result, describe: _describeLocation);
  }

  Future<void> _pickDirectory() async {
    final result = await _io.picker.pickDirectory(
      dialogTitle: 'Choose an export folder',
    );
    if (result case Success(:final value)) {
      setState(() => _pickedDir = value);
    }
    _record('Pick directory', result, describe: (dir) => dir);
  }

  Future<void> _saveInto() async {
    final dir = _pickedDir;
    if (dir == null) return;
    final result = await _io.saver.saveInto(
      directory: dir,
      bytes: _utf8('exported,into,chosen,folder\n1,2,3,4\n'),
      fileName: 'into.csv',
      mimeType: 'text/csv',
    );
    _record('Save into folder', result, describe: _describeLocation);
  }

  Future<void> _openLocation() async {
    final loc = _lastLocation;
    if (loc == null) return;
    final result = await _io.opener.open(loc, mimeType: 'text/csv');
    _record<void>('Open saved location', result);
  }

  void _rememberSaved(Outcome<SaveLocation> result) {
    if (result case Success(:final value)) {
      setState(() {
        _lastLocation = value;
        if (value case SavedAtPath(:final path)) _lastSavedPath = path;
      });
    }
  }

  String _describeLocation(SaveLocation loc) => switch (loc) {
    SavedAtPath(:final path) => path,
    SavedByBrowser() => 'saved (browser saver)',
  };

  // ── Open handlers ──────────────────────────────────────────────────────

  Future<void> _openBytes() async {
    final result = await _io.opener.openBytes(
      bytes: _utf8('Opened straight from memory.\n'),
      fileName: 'hello.txt',
      mimeType: 'text/plain',
    );
    _record<void>('Open bytes', result);
  }

  Future<void> _openLastSaved() async {
    final path = _lastSavedPath;
    if (path == null) return;
    final result = await _io.opener.openPath(filePath: path);
    _record<void>('Open last saved', result);
  }

  // ── Generated demo data (no assets, no network) ─────────────────────────

  Uint8List _utf8(String s) => Uint8List.fromList(utf8.encode(s));

  Stream<List<int>> _patternedStream(int total) async* {
    const chunkSize = 64 * 1024;
    final chunk = List<int>.generate(chunkSize, (i) => i & 0xFF);
    var sent = 0;
    while (sent < total) {
      final remaining = total - sent;
      yield remaining >= chunkSize ? chunk : chunk.sublist(0, remaining);
      sent += chunkSize;
    }
  }

  Stream<List<int>> _textChunkStream() async* {
    for (var i = 1; i <= 5; i++) {
      yield utf8.encode('chunk $i of 5\n');
    }
  }

  // ── UI ───────────────────────────────────────────────────────────────

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 4,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('device_io example'),
          bottom: const TabBar(
            tabs: [
              Tab(
                icon: Icon(Icons.photo_library_outlined, size: 20),
                text: 'Pick',
              ),
              Tab(
                icon: Icon(Icons.ios_share_outlined, size: 20),
                text: 'Share',
              ),
              Tab(icon: Icon(Icons.download_outlined, size: 20), text: 'Save'),
              Tab(
                icon: Icon(Icons.open_in_new_outlined, size: 20),
                text: 'Open',
              ),
            ],
          ),
        ),
        // The activity log sits BELOW the TabBarView — global, visible on
        // every tab, so a result is never hidden by tab switching.
        body: Column(
          children: [
            Expanded(
              child: TabBarView(
                children: [_pickTab(), _shareTab(), _saveTab(), _openTab()],
              ),
            ),
            _ActivityLog(entries: _log),
          ],
        ),
      ),
    );
  }

  Widget _pickTab() {
    final cameraSupported = _io.picker.isCameraSupported;
    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        _Section(
          title: 'Pick',
          children: [
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                FilledButton.tonal(
                  onPressed: _pickImage,
                  child: const Text('pickImage'),
                ),
                FilledButton.tonal(
                  onPressed: _pickImages,
                  child: const Text('pickImages'),
                ),
                FilledButton.tonal(
                  onPressed: cameraSupported ? _captureImage : null,
                  child: Text(
                    cameraSupported
                        ? 'captureImage'
                        : 'captureImage (no camera)',
                  ),
                ),
                FilledButton.tonal(
                  onPressed: _pickVideo,
                  child: const Text('pickVideo'),
                ),
                FilledButton.tonal(
                  onPressed: cameraSupported ? _captureVideo : null,
                  child: Text(
                    cameraSupported
                        ? 'captureVideo'
                        : 'captureVideo (no camera)',
                  ),
                ),
                FilledButton.tonal(
                  onPressed: _pickMedia,
                  child: const Text('pickMedia'),
                ),
                FilledButton.tonal(
                  onPressed: _pickMultipleMedia,
                  child: const Text('pickMultipleMedia'),
                ),
                FilledButton.tonal(
                  onPressed: _pickFile,
                  child: const Text('pickFile'),
                ),
                FilledButton.tonal(
                  onPressed: _pickFiles,
                  child: const Text('pickFiles'),
                ),
                FilledButton.tonal(
                  onPressed: _pickDirectory,
                  child: const Text('pickDirectory'),
                ),
              ],
            ),
            if (_picked.isNotEmpty) ...[
              const SizedBox(height: 12),
              Text(
                'Tap a picked asset to read its bytes on demand:',
                style: Theme.of(context).textTheme.bodySmall,
              ),
              const SizedBox(height: 4),
              ..._picked.map(_pickedTile),
            ],
          ],
        ),
      ],
    );
  }

  Widget _shareTab() {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        _Section(
          title: 'Share',
          children: [
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                FilledButton.tonal(
                  onPressed: _shareText,
                  child: const Text('shareText'),
                ),
                FilledButton.tonal(
                  onPressed: _shareFile,
                  child: const Text('shareFile'),
                ),
                FilledButton.tonal(
                  onPressed: _shareFiles,
                  child: const Text('shareFiles (CSV + text)'),
                ),
                FilledButton.tonal(
                  onPressed: _shareFileStream,
                  child: const Text('shareFileStream (~1MB)'),
                ),
              ],
            ),
          ],
        ),
      ],
    );
  }

  Widget _saveTab() {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        _Section(
          title: 'Save',
          children: [
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                FilledButton.tonal(
                  onPressed: _save,
                  child: const Text('save (CSV)'),
                ),
                FilledButton.tonal(
                  onPressed: _saveStream,
                  child: const Text('saveStream'),
                ),
                FilledButton.tonal(
                  onPressed: _saveAs,
                  child: const Text('saveAs'),
                ),
                FilledButton.tonal(
                  onPressed: _pickedDir != null ? _saveInto : null,
                  child: const Text('saveInto (picked folder)'),
                ),
              ],
            ),
            if (_lastSavedPath != null) ...[
              const SizedBox(height: 8),
              Text(
                'Last saved: $_lastSavedPath',
                style: Theme.of(context).textTheme.bodySmall,
              ),
            ],
          ],
        ),
      ],
    );
  }

  Widget _openTab() {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        _Section(
          title: 'Open',
          children: [
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                FilledButton.tonal(
                  onPressed: _openBytes,
                  child: const Text('openBytes'),
                ),
                FilledButton.tonal(
                  onPressed: _lastSavedPath != null ? _openLastSaved : null,
                  child: const Text('openPath (last saved)'),
                ),
                FilledButton.tonal(
                  onPressed: _lastLocation != null ? _openLocation : null,
                  child: const Text('open (last location)'),
                ),
              ],
            ),
          ],
        ),
      ],
    );
  }

  Widget _pickedTile(PickedItem item) {
    final subtitle = StringBuffer(item.asset.mimeType);
    if (item.loading) {
      subtitle.write(' • reading…');
    } else if (item.byteLength != null) {
      subtitle.write(' • ${item.byteLength} bytes');
    } else {
      subtitle.write(' • tap to read bytes');
    }
    return ListTile(
      dense: true,
      leading: const Icon(Icons.insert_drive_file_outlined),
      title: Text(item.asset.fileName ?? '(no name)'),
      subtitle: Text(subtitle.toString()),
      onTap: item.loading ? null : () => _measure(item),
    );
  }
}

// ── Presentational widgets ──────────────────────────────────────────────

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

  final String title;
  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.only(bottom: 16),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(title, style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 12),
            ...children,
          ],
        ),
      ),
    );
  }
}

class _ActivityLog extends StatelessWidget {
  const _ActivityLog({required this.entries});

  final List<LogEntry> entries;

  @override
  Widget build(BuildContext context) {
    return Material(
      elevation: 8,
      child: Container(
        height: 180,
        width: double.infinity,
        color: Theme.of(context).colorScheme.surfaceContainerHighest,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Padding(
              padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
              child: Text(
                'Activity',
                style: Theme.of(context).textTheme.labelLarge,
              ),
            ),
            Expanded(
              child: entries.isEmpty
                  ? const Center(child: Text('No activity yet.'))
                  : ListView.builder(
                      padding: const EdgeInsets.symmetric(horizontal: 16),
                      itemCount: entries.length,
                      itemBuilder: (context, i) => _logRow(entries[i]),
                    ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _logRow(LogEntry entry) {
    final (color, icon) = switch (entry.level) {
      LogLevel.success => (Colors.green, Icons.check_circle_outline),
      LogLevel.neutral => (Colors.blueGrey, Icons.remove_circle_outline),
      LogLevel.warning => (Colors.amber.shade800, Icons.warning_amber_outlined),
      LogLevel.unsupported => (Colors.grey, Icons.block_outlined),
      LogLevel.error => (Colors.red, Icons.error_outline),
    };
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 3),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Icon(icon, size: 16, color: color),
          const SizedBox(width: 8),
          Expanded(
            child: Text(entry.text, style: TextStyle(color: color)),
          ),
        ],
      ),
    );
  }
}
1
likes
150
points
251
downloads

Documentation

API reference

Publisher

verified publisherwhuppi.com

Weekly Downloads

Cross-platform file picking, saving, sharing, and opening for Flutter. Image and file pickers, save dialogs, OS share sheet, open-with — one API on mobile, desktop, and web.

Repository (GitHub)
View/report issues
Contributing

Topics

#file-picker #share #download #file-io #cross-platform

License

MIT (license)

Dependencies

file_picker, flutter, image_picker, meta, mime, open_filex, path_provider, share_plus, share_plus_platform_interface, web

More

Packages that depend on device_io