Skip to main content

livemd_flutter_clipboard 0.1.0 copy "livemd_flutter_clipboard: ^0.1.0" to clipboard
livemd_flutter_clipboard: ^0.1.0 copied to clipboard

PlatformAndroid

Optional native and browser media clipboard adapter for the LiveMD Markdown editor.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:livemd_flutter/livemd_flutter.dart';
import 'package:livemd_flutter_clipboard/livemd_flutter_clipboard.dart';

import 'attachment_repository.dart';
import 'media_player.dart';
import 'storage/open_storage.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const ClipboardDemo());
}

class ClipboardDemo extends StatefulWidget {
  const ClipboardDemo({
    super.key,
    this.clipboard = const SuperClipboardAdapter(),
    this.repository,
    this.createPlayer = createDemoPlayer,
  });
  final LiveMarkdownClipboard clipboard;

  /// An application can inject a repository with its own upload service.
  /// The caller owns an injected repository and closes it after unmounting.
  final DemoAttachmentRepository? repository;
  final DemoPlayerFactory createPlayer;
  @override
  State<ClipboardDemo> createState() => _ClipboardDemoState();
}

class _ClipboardDemoState extends State<ClipboardDemo>
    with WidgetsBindingObserver {
  static const initialDocument =
      '# Paste media\n\n'
      'Copy images, audio or video files, then paste here.\n\n'
      '| Place | Image |\n| --- | --- |\n| Table cell | here |\n\n'
      '> [!tip] Callout\n> You can paste into this body too.\n\n';
  DemoAttachmentRepository? _repository;
  LiveMarkdownController? _document;
  LiveMarkdownPasteConfiguration? _configuration;
  Timer? _saveTimer;
  String _lastText = '';
  String _status = 'Ready';
  String _saveStatus = 'Saved';
  String? _loadError;
  String? _cleanupError;
  final _unused = <LiveMarkdownImportedMedia>[];
  bool _importing = false;
  bool _reopening = false;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    unawaited(_load());
  }

  Future<void> _load() async {
    DemoAttachmentRepository? repository;
    try {
      repository =
          widget.repository ??
          DemoAttachmentRepository(await openDemoStorage());
      final saved = await repository.loadDocument();
      final text = saved ?? initialDocument;
      if (saved == null) await repository.saveDocument(text);
      if (!mounted) {
        if (widget.repository == null) await repository.close();
        return;
      }
      _repository = repository;
      _installDocument(text);
      _configuration = LiveMarkdownPasteConfiguration(
        clipboard: widget.clipboard,
        onImportMedia: (request) async {
          if (mounted) {
            setState(() {
              _importing = true;
              _status = 'Importing media…';
            });
          }
          return repository!.importMedia(request);
        },
        onResult: (result) => unawaited(_handleResult(result)),
      );
      setState(() => _loadError = null);
    } catch (_) {
      if (repository != null && widget.repository == null) {
        await repository.close();
      }
      if (mounted) {
        setState(
          () => _loadError =
              'Could not open the saved document. Check storage and retry.',
        );
      }
    }
  }

  void _installDocument(String text) {
    _document?.removeListener(_changed);
    _document?.dispose();
    _document = LiveMarkdownController(text: text)..addListener(_changed);
    _lastText = text;
  }

  void _changed() {
    final text = _document!.text;
    if (text == _lastText) return;
    _lastText = text;
    _saveTimer?.cancel();
    _saveTimer = Timer(
      const Duration(milliseconds: 300),
      () => unawaited(_save()),
    );
    if (mounted) setState(() => _saveStatus = 'Unsaved changes');
  }

  Future<bool> _save() async {
    _saveTimer?.cancel();
    final repository = _repository;
    final text = _document?.text;
    if (repository == null || text == null) return false;
    if (mounted) setState(() => _saveStatus = 'Saving…');
    try {
      await repository.saveDocument(text);
      if (mounted) {
        setState(
          () => _saveStatus = _document!.text == text
              ? 'Saved'
              : 'Unsaved changes',
        );
      }
      return true;
    } catch (_) {
      if (mounted) {
        setState(() => _saveStatus = 'Save failed — retry with Save now');
      }
      return false;
    }
  }

  Future<void> _reopen() async {
    if (_importing || _reopening) return;
    FocusManager.instance.primaryFocus?.unfocus();
    setState(() => _reopening = true);
    try {
      if (!await _save()) return;
      final text = await _repository!.loadDocument();
      if (mounted) {
        setState(() {
          _installDocument(text ?? initialDocument);
          _status = 'Document reopened';
        });
      }
    } catch (_) {
      if (mounted) setState(() => _status = 'Could not reopen the document');
    } finally {
      if (mounted) setState(() => _reopening = false);
    }
  }

  Future<void> _handleResult(LiveMarkdownPasteResult result) async {
    if (result.status != LiveMarkdownPasteStatus.inserted) {
      _unused.addAll(result.importedMedia);
      await _cleanup();
    }
    if (!mounted) return;
    setState(() {
      if (result.status != LiveMarkdownPasteStatus.busy) _importing = false;
      _status = switch (result.status) {
        LiveMarkdownPasteStatus.inserted =>
          result.items.length > 1
              ? '${result.items.length} items inserted'
              : switch (_repository!
                    .lookup(result.imported!.destination)
                    ?.type) {
                  LiveMarkdownMediaType.video => 'Video inserted',
                  LiveMarkdownMediaType.audio => 'Audio inserted',
                  _ => 'Image inserted',
                },
        LiveMarkdownPasteStatus.textPasted => 'Text pasted',
        LiveMarkdownPasteStatus.empty => 'Clipboard is empty',
        LiveMarkdownPasteStatus.unsupported =>
          'Unsupported media or too many files',
        LiveMarkdownPasteStatus.rejected =>
          'File is empty or exceeds the 20 MiB limit',
        LiveMarkdownPasteStatus.readFailed => 'Could not read the clipboard',
        LiveMarkdownPasteStatus.importFailed =>
          'Could not save or upload this media',
        LiveMarkdownPasteStatus.cancelled => 'Paste cancelled',
        LiveMarkdownPasteStatus.busy =>
          'A media batch is already being imported',
      };
    });
  }

  Future<void> _cleanup() async {
    if (_unused.isEmpty) return;
    final batch = List<LiveMarkdownImportedMedia>.of(_unused);
    try {
      await _repository!.discard(batch);
      for (final item in batch) {
        _unused.remove(item);
      }
      if (mounted) setState(() => _cleanupError = null);
    } catch (_) {
      if (mounted) {
        setState(
          () => _cleanupError = 'Some unused attachments could not be removed.',
        );
      }
    }
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state != AppLifecycleState.resumed) unawaited(_save());
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _saveTimer?.cancel();
    final repository = _repository;
    final text = _document?.text;
    _document?.removeListener(_changed);
    _document?.dispose();
    // Normal navigation gets a final flush. Abrupt process termination cannot
    // await Dart futures; the visible Saved indicator is the durability boundary.
    if (repository != null) {
      unawaited(
        () async {
          try {
            if (text != null) await repository.saveDocument(text);
          } finally {
            if (widget.repository == null) await repository.close();
          }
        }().catchError((Object _) {}),
      );
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => MaterialApp(
    theme: ThemeData(colorSchemeSeed: const Color(0xff637d69)),
    home: Scaffold(
      appBar: AppBar(title: const Text('LiveMD · Media paste')),
      body: _loadError != null
          ? Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text(_loadError!),
                  TextButton(
                    onPressed: _load,
                    child: const Text('Retry opening'),
                  ),
                ],
              ),
            )
          : _document == null
          ? const Center(child: CircularProgressIndicator())
          : Column(
              children: [
                Padding(
                  padding: const EdgeInsets.all(12),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: [
                      Text(_status, key: const ValueKey('paste-status')),
                      const SizedBox(height: 4),
                      Text(_repository!.storage.description),
                      Wrap(
                        spacing: 12,
                        crossAxisAlignment: WrapCrossAlignment.center,
                        children: [
                          Text(_saveStatus, key: const ValueKey('save-status')),
                          TextButton.icon(
                            onPressed: _save,
                            icon: const Icon(Icons.save_outlined),
                            label: const Text('Save now'),
                          ),
                          TextButton.icon(
                            onPressed: _importing || _reopening
                                ? null
                                : _reopen,
                            icon: const Icon(Icons.refresh),
                            label: const Text('Reopen document'),
                          ),
                        ],
                      ),
                      if (_cleanupError != null)
                        Wrap(
                          crossAxisAlignment: WrapCrossAlignment.center,
                          children: [
                            Text(_cleanupError!),
                            TextButton(
                              onPressed: _cleanup,
                              child: const Text('Retry cleanup'),
                            ),
                          ],
                        ),
                    ],
                  ),
                ),
                const Divider(height: 1),
                Expanded(
                  child: Stack(
                    fit: StackFit.expand,
                    children: [
                      LiveMarkdownEditor(
                        controller: _document!,
                        autofocus: true,
                        pasteConfiguration: _configuration,
                        mediaTypeResolver: (destination) =>
                            _repository!.lookup(destination)?.type,
                        imageResolver: _repository!.imageResolver,
                        videoBuilder: (_, destination, alt) => DemoMediaPlayer(
                          key: ValueKey('video-$destination'),
                          repository: _repository!,
                          destination: destination,
                          label: _repository!.lookup(destination)?.name ?? alt,
                          video: true,
                          createPlayer: widget.createPlayer,
                        ),
                        audioBuilder: (_, destination, alt) => DemoMediaPlayer(
                          key: ValueKey('audio-$destination'),
                          repository: _repository!,
                          destination: destination,
                          label: _repository!.lookup(destination)?.name ?? alt,
                          video: false,
                          createPlayer: widget.createPlayer,
                        ),
                      ),
                      if (_reopening)
                        const ModalBarrier(
                          dismissible: false,
                          color: Colors.transparent,
                        ),
                    ],
                  ),
                ),
              ],
            ),
    ),
  );
}
0
likes
160
points
131
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Optional native and browser media clipboard adapter for the LiveMD Markdown editor.

Repository (GitHub)

License

MIT (license)

Dependencies

flutter, livemd_flutter, super_clipboard

More

Packages that depend on livemd_flutter_clipboard

Packages that implement livemd_flutter_clipboard