fox_utils 0.1.7: clipboard, drag & drop, Android file saving
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
dc16bba054
46 files changed
+4738
No files matched your search
@@ -0,0 +1,69 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// A public Android storage location that needs no write permission.
|
||||
enum AndroidPublicLocation {
|
||||
pictures,
|
||||
movies,
|
||||
music,
|
||||
downloads,
|
||||
}
|
||||
|
||||
/// Thrown when saving a file via [AndroidFiles.save] fails.
|
||||
class FileSaveException implements Exception {
|
||||
FileSaveException(this.message, {this.details});
|
||||
|
||||
final String message;
|
||||
final String? details;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'FileSaveException: $message${details != null ? ' — $details' : ''}';
|
||||
}
|
||||
|
||||
/// Saves files to public Android directories without any storage permission.
|
||||
///
|
||||
/// Files are written with plain file I/O, so on Android 10+ they are NOT
|
||||
/// indexed by the media store. Pass [scan] = true to run the media scanner
|
||||
/// and make the file show up in gallery apps.
|
||||
class AndroidFiles {
|
||||
AndroidFiles._();
|
||||
|
||||
static const MethodChannel _channel = MethodChannel('fox_utils/android_files');
|
||||
|
||||
/// Writes [bytes] to `<location>/<subPath>/<fileName>` on shared storage and
|
||||
/// returns the absolute path. Requires no storage permission on Android 10+.
|
||||
///
|
||||
/// When [scan] is true the file is indexed into the media store (gallery
|
||||
/// visibility); when false it stays on disk without being indexed.
|
||||
///
|
||||
/// Throws [FileSaveException] on failure (invalid arguments, I/O error, or
|
||||
/// a platform rejection — e.g. pre-Android-10 devices without legacy
|
||||
/// storage access). The native error message is carried in the exception.
|
||||
static Future<String> save(
|
||||
Uint8List bytes, {
|
||||
required String fileName,
|
||||
AndroidPublicLocation location = AndroidPublicLocation.pictures,
|
||||
String subPath = '',
|
||||
bool scan = true,
|
||||
}) async {
|
||||
try {
|
||||
final path = await _channel.invokeMethod<String>('saveFile', {
|
||||
'bytes': bytes,
|
||||
'fileName': fileName,
|
||||
'location': location.name,
|
||||
'subPath': subPath,
|
||||
'scan': scan,
|
||||
});
|
||||
if (path == null) {
|
||||
throw FileSaveException('saveFile returned no path.');
|
||||
}
|
||||
return path;
|
||||
} on PlatformException catch (e, st) {
|
||||
debugPrint('AndroidFiles.save failed (${e.code}): ${e.message}\n$st');
|
||||
throw FileSaveException(e.message ?? 'Failed to save file.', details: e.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'dart:io' show Directory, File, Platform;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'vendor/pasteboard.dart';
|
||||
|
||||
/// Thrown when a clipboard copy fails.
|
||||
class ClipboardCopyException implements Exception {
|
||||
ClipboardCopyException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'ClipboardCopyException: $message';
|
||||
}
|
||||
|
||||
/// Cross-project clipboard wrapper over the vendored pasteboard engine.
|
||||
///
|
||||
/// Text goes through Flutter's built-in clipboard; images and files go
|
||||
/// through the native channels (Android: FileProvider-served content URIs,
|
||||
/// Windows: GDI+/CF_HDROP, Linux: GTK).
|
||||
class ClipboardX {
|
||||
ClipboardX._();
|
||||
|
||||
static const MethodChannel _clipboardChannel = MethodChannel('fox_utils/clipboard');
|
||||
|
||||
/// Copies [bytes] to the clipboard as an image. The bytes are placed as-is
|
||||
/// (no re-encode) and the extension is sniffed from the magic bytes, so
|
||||
/// PNG/JPEG/GIF/WebP/BMP all paste with the correct MIME type.
|
||||
///
|
||||
/// Supported on Android, Windows, macOS and iOS. On Linux, raw image bytes
|
||||
/// cannot be placed on the clipboard (GTK limitation), so the bytes are
|
||||
/// staged to a temp file and copied as a file reference instead — pasted
|
||||
/// apps receive the image file.
|
||||
///
|
||||
/// Throws [ClipboardCopyException] on failure (e.g. invalid image bytes
|
||||
/// rejected by the platform).
|
||||
static Future<void> copyImage(Uint8List bytes) async {
|
||||
if (Platform.isLinux) {
|
||||
await copyFiles([await _stageLinuxImage(bytes)]);
|
||||
return;
|
||||
}
|
||||
await Pasteboard.writeImage(bytes);
|
||||
}
|
||||
|
||||
static File? _linuxStaged;
|
||||
|
||||
/// Stages [bytes] to a rotating temp file. Linux clipboards hold file
|
||||
/// references that receivers resolve at paste time, so the staged file must
|
||||
/// outlive the copy call; the previous staging file is replaced on each
|
||||
/// copy.
|
||||
static Future<String> _stageLinuxImage(Uint8List bytes) async {
|
||||
final file = File('${Directory.systemTemp.path}/fox_clipboard_image.png');
|
||||
await file.writeAsBytes(bytes);
|
||||
final old = _linuxStaged;
|
||||
if (old != null && old.path != file.path) {
|
||||
try {
|
||||
await old.delete();
|
||||
} on Exception catch (e, st) {
|
||||
// Stale staging file is harmless; log and move on.
|
||||
debugPrint('Failed to delete stale clipboard staging file: $e\n$st');
|
||||
}
|
||||
}
|
||||
_linuxStaged = file;
|
||||
return file.path;
|
||||
}
|
||||
|
||||
/// Copies [paths] (absolute file paths) to the clipboard as files.
|
||||
///
|
||||
/// Throws [ClipboardCopyException] when the native write fails or reports
|
||||
/// failure (pasteboard's `writeFiles` returns a bool that is converted to
|
||||
/// an exception here).
|
||||
static Future<void> copyFiles(List<String> paths) async {
|
||||
final ok = await Pasteboard.writeFiles(paths);
|
||||
if (!ok) {
|
||||
throw ClipboardCopyException('Failed to copy files: $paths');
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies [text] to the clipboard.
|
||||
static void copyText(String text) => Pasteboard.writeText(text);
|
||||
|
||||
static Future<String?> readText() => Pasteboard.text;
|
||||
|
||||
static Future<Uint8List?> readImage() => Pasteboard.image;
|
||||
|
||||
static Future<List<String>> readFiles() => Pasteboard.files();
|
||||
|
||||
/// Reads an image from the clipboard regardless of how it was copied:
|
||||
/// image data (a browser's "copy image") or a file reference (a file
|
||||
/// manager's copy). Returns the image bytes, or null when the clipboard
|
||||
/// holds no image.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. image data on the clipboard (browser copy) — all platforms;
|
||||
/// 2. the first file reference (file manager copy) — desktop paths are
|
||||
/// read directly, Android content URIs are resolved via the platform
|
||||
/// channel. The file's first bytes are sniffed to confirm an image
|
||||
/// before the full read, so huge non-image files are never loaded.
|
||||
static Future<Uint8List?> pasteImage() async {
|
||||
final image = await readImage();
|
||||
if (image != null) return image;
|
||||
|
||||
final files = await readFiles();
|
||||
if (files.isEmpty) return null;
|
||||
|
||||
final Uint8List? bytes;
|
||||
if (Platform.isAndroid) {
|
||||
bytes = await _readFileBytes(files.first);
|
||||
} else {
|
||||
final file = File(files.first);
|
||||
try {
|
||||
final raf = file.openSync();
|
||||
final head = raf.readSync(16);
|
||||
raf.closeSync();
|
||||
if (!_looksLikeImage(head)) return null;
|
||||
bytes = await file.readAsBytes();
|
||||
} on Exception catch (e, st) {
|
||||
debugPrint('pasteImage: could not read clipboard file ${file.path}: $e\n$st');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (bytes == null || !_looksLikeImage(bytes)) return null;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static Future<Uint8List?> _readFileBytes(String uri) async {
|
||||
try {
|
||||
return await _clipboardChannel.invokeMethod<Uint8List>('readFileBytes', uri);
|
||||
} on PlatformException catch (e, st) {
|
||||
debugPrint('pasteImage: failed to read $uri: ${e.message}\n$st');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sniffs common image magic bytes (PNG/JPEG/GIF/WebP/BMP).
|
||||
static bool _looksLikeImage(Uint8List b) {
|
||||
if (b.length < 4) return false;
|
||||
if (b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF) return true; // jpeg
|
||||
if (b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47) return true; // png
|
||||
if (b[0] == 0x47 && b[1] == 0x49 && b[2] == 0x46 && b[3] == 0x38) return true; // gif
|
||||
if (b.length >= 12 &&
|
||||
b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F' &&
|
||||
b[4] == 'W' && b[5] == 'E' && b[6] == 'B' && b[7] == 'P') {
|
||||
return true; // webp
|
||||
}
|
||||
if (b[0] == 'B' && b[1] == 'M') return true; // bmp
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'dart:io' show File;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
|
||||
import 'vendor/desktop_drop.dart' show DropItem, DropItemDirectory;
|
||||
|
||||
// The widget API and drop item model come from the vendored engine:
|
||||
// DropTarget (drag-enter/exit/updated/done callbacks), DropItem / DropItemFile /
|
||||
// DropItemDirectory, DropDoneDetails and DropEventDetails.
|
||||
export 'vendor/desktop_drop.dart'
|
||||
show
|
||||
DropTarget,
|
||||
DropItem,
|
||||
DropItemFile,
|
||||
DropItemDirectory,
|
||||
DropDoneDetails,
|
||||
DropEventDetails;
|
||||
|
||||
/// Drag & drop of files and images from the OS into the app.
|
||||
///
|
||||
/// The widget side of this API is [DropTarget] (re-exported from this
|
||||
/// library). Wrap any widget in a [DropTarget] to receive drag-enter,
|
||||
/// drag-exit, drag-update and drop callbacks for OS-level drags — anything
|
||||
/// that can produce file URIs or file lists: file managers, browsers,
|
||||
/// terminals, image editors.
|
||||
///
|
||||
/// [DesktopDropX] provides the ergonomics the app needs on top of the raw
|
||||
/// [DropItem] list delivered by [DropDoneDetails.files]: extracting plain
|
||||
/// file paths and loading the first image among the dropped items.
|
||||
///
|
||||
/// Platform behavior:
|
||||
/// - **Linux**: the native side installs a GTK drag destination on the whole
|
||||
/// Flutter window and delivers `text/uri-list` (GNOME/KDE file managers,
|
||||
/// browsers, etc.). Dropped items are absolute filesystem paths.
|
||||
/// - **Windows**: the native side registers an OLE `IDropTarget` on the
|
||||
/// window and reads `CF_HDROP`, so drops work from Explorer and any
|
||||
/// browser. Dropped items are absolute filesystem paths.
|
||||
/// - **Android/iOS**: not implemented — this package does not vendor the
|
||||
/// desktop_drop mobile bindings. [DropTarget] compiles and renders its
|
||||
/// child, but no native handler answers the channel, so no drop events
|
||||
/// ever arrive.
|
||||
/// - **macOS**: not vendored (no macOS target in the app); re-adding it is
|
||||
/// a copy of upstream's `macos/` folder behind the same API.
|
||||
///
|
||||
/// Errors: nothing here throws. A drop that contains no readable image is an
|
||||
/// expected outcome (returns `null`), and file reads that fail (stale drop,
|
||||
/// permissions) are logged with a stack trace and skipped.
|
||||
class DesktopDropX {
|
||||
DesktopDropX._();
|
||||
|
||||
/// Returns one filesystem path per top-level dropped item.
|
||||
///
|
||||
/// Each item in [items] maps to its path — files and directories alike; a
|
||||
/// dropped folder yields its own path, its contents are not expanded
|
||||
/// ([DropItemDirectory.children] is only populated on macOS, which is not
|
||||
/// vendored). Items that carry no path (web data drops) yield `''`.
|
||||
///
|
||||
/// Pass [DropDoneDetails.files] from a drop callback to get the paths the
|
||||
/// app can hand to file I/O:
|
||||
///
|
||||
/// ```dart
|
||||
/// DropTarget(
|
||||
/// onDragDone: (details) {
|
||||
/// final paths = DesktopDropX.pathsOf(details.files);
|
||||
/// // read, copy, upload…
|
||||
/// },
|
||||
/// child: …,
|
||||
/// )
|
||||
/// ```
|
||||
static List<String> pathsOf(List<DropItem> items) =>
|
||||
items.map((item) => item.path).toList();
|
||||
|
||||
/// Reads the first image among [items] and returns its bytes.
|
||||
///
|
||||
/// Resolution order follows [items]: for each item the first 16 bytes are
|
||||
/// sniffed for image magic bytes (PNG/JPEG/GIF/WebP/BMP — same table as
|
||||
/// `ClipboardX.pasteImage`); the first match is loaded in full. Huge
|
||||
/// non-image files are never fully read. Returns `null` when none of the
|
||||
/// dropped items is an image, or when none could be read.
|
||||
///
|
||||
/// Directories are skipped ([DropItemDirectory] carries no file bytes).
|
||||
/// Files that fail to open or read (deleted between drop and read,
|
||||
/// permission denied) are logged with a stack trace and skipped — a later
|
||||
/// item is still tried, and the method returns `null` only if nothing
|
||||
/// readable remains.
|
||||
///
|
||||
/// The returned bytes are as-dropped (no re-encode); use the bytes with
|
||||
/// `Image.memory` or upload them as-is.
|
||||
///
|
||||
/// ```dart
|
||||
/// DropTarget(
|
||||
/// onDragDone: (details) async {
|
||||
/// final Uint8List? bytes = await DesktopDropX.readImage(details.files);
|
||||
/// if (bytes == null) return; // drop contained no image
|
||||
/// // display or upload
|
||||
/// },
|
||||
/// child: …,
|
||||
/// )
|
||||
/// ```
|
||||
static Future<Uint8List?> readImage(List<DropItem> items) async {
|
||||
for (final item in items) {
|
||||
if (item is DropItemDirectory) continue;
|
||||
final path = item.path;
|
||||
if (path.isEmpty) continue;
|
||||
final head = await _readHead(path);
|
||||
if (head == null || !_looksLikeImage(head)) continue;
|
||||
try {
|
||||
final bytes = await item.readAsBytes();
|
||||
if (bytes == null || !_looksLikeImage(bytes)) continue;
|
||||
return bytes;
|
||||
} on Exception catch (e, st) {
|
||||
debugPrint(
|
||||
'DesktopDropX.readImage: failed reading ${item.path}: $e\n$st');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<Uint8List?> _readHead(String path) async {
|
||||
try {
|
||||
final raf = File(path).openSync();
|
||||
final head = raf.readSync(16);
|
||||
raf.closeSync();
|
||||
return head;
|
||||
} on Exception catch (e, st) {
|
||||
debugPrint('DesktopDropX.readImage: could not read $path: $e\n$st');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sniffs common image magic bytes (PNG/JPEG/GIF/WebP/BMP).
|
||||
///
|
||||
/// Shares the table used by `ClipboardX._looksLikeImage`; kept as a local
|
||||
/// copy so the two APIs stay independent.
|
||||
static bool _looksLikeImage(Uint8List b) {
|
||||
if (b.length < 4) return false;
|
||||
if (b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF) return true; // jpeg
|
||||
if (b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47) return true; // png
|
||||
if (b[0] == 0x47 && b[1] == 0x49 && b[2] == 0x46 && b[3] == 0x38) return true; // gif
|
||||
if (b.length >= 12 &&
|
||||
b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F' &&
|
||||
b[4] == 'W' && b[5] == 'E' && b[6] == 'B' && b[7] == 'P') {
|
||||
return true; // webp
|
||||
}
|
||||
if (b[0] == 'B' && b[1] == 'M') return true; // bmp
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Executable
+211
@@ -0,0 +1,211 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
extension DioExceptionX on DioException {
|
||||
String get userMessage {
|
||||
switch (type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'Connection timeout. Please check your internet.';
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = response?.statusCode;
|
||||
if (statusCode == 401) return 'Unauthorized. Please log in again.';
|
||||
if (statusCode == 404) return 'Resource not found.';
|
||||
if (statusCode != null && statusCode >= 500) {
|
||||
return 'Server error (Status $statusCode).';
|
||||
}
|
||||
return 'Request failed with status $statusCode.';
|
||||
case DioExceptionType.cancel:
|
||||
return 'Request was cancelled.';
|
||||
case DioExceptionType.unknown:
|
||||
return 'An unexpected error occurred.';
|
||||
default:
|
||||
return 'Request failed.';
|
||||
}
|
||||
}
|
||||
|
||||
String toDebugString() {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('🚨 [DioException] ${type.name}');
|
||||
buffer.writeln('📤 Request: ${requestOptions.method} ${requestOptions.uri}');
|
||||
if (response != null) {
|
||||
buffer.writeln('📥 Response: ${response!.statusCode}');
|
||||
if (response!.data != null) {
|
||||
buffer.writeln(' Body: ${_formatData(response!.data)}');
|
||||
}
|
||||
} else if (error != null) {
|
||||
buffer.writeln('⚠️ Error: ${error.toString()}');
|
||||
}
|
||||
return buffer.toString().trim();
|
||||
}
|
||||
|
||||
String _formatData(dynamic data) {
|
||||
if (data == null) return 'null';
|
||||
if (data is Map || data is List) {
|
||||
try {
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
return encoder.convert(data);
|
||||
} catch (_) {
|
||||
return data.toString();
|
||||
}
|
||||
}
|
||||
return data.toString();
|
||||
}
|
||||
}
|
||||
|
||||
extension DiscordDioException on DioException {
|
||||
/// Generates a Markdown formatted error report suitable for Discord messages.
|
||||
String toDiscordMarkdown() {
|
||||
final emoji = _getEmoji();
|
||||
final title = _getFriendlyTitle();
|
||||
final message = userMessage;
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('$emoji **$title**');
|
||||
buffer.writeln();
|
||||
buffer.writeln(message);
|
||||
buffer.writeln();
|
||||
buffer.writeln('`${requestOptions.method} ${requestOptions.uri}`');
|
||||
buffer.writeln();
|
||||
|
||||
// Technical details
|
||||
buffer.writeln('```yaml');
|
||||
buffer.writeln('Type: ${type.name}');
|
||||
if (response != null) {
|
||||
buffer.writeln('Status: ${response!.statusCode}');
|
||||
}
|
||||
if (error != null) {
|
||||
final errorStr = error.toString();
|
||||
buffer.writeln('Error: ${errorStr.length > 200 ? '${errorStr.substring(0, 200)}...' : errorStr}');
|
||||
}
|
||||
buffer.writeln('```');
|
||||
|
||||
// Ensure whole message fits in Discord's 2000‑char limit
|
||||
var result = buffer.toString().trim();
|
||||
const maxLength = 2000;
|
||||
if (result.length > maxLength) {
|
||||
result = '${result.substring(0, maxLength - 3)}...';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Human‑readable message, enhanced with the server’s own error message if available.
|
||||
String get userMessage {
|
||||
print("Server message: ${response?.data}");
|
||||
final base = _baseUserMessage();
|
||||
final server = _getServerMessage();
|
||||
if (server == null || server.isEmpty) return base;
|
||||
if (base.contains(server)) return base; // avoid duplication
|
||||
return '$base — $server';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
String _baseUserMessage() {
|
||||
switch (type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'The connection timed out.';
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
final errorStr = error?.toString().toLowerCase() ?? '';
|
||||
if (errorStr.contains('semaphore timeout') || errorStr.contains('timed out')) {
|
||||
return 'Connection timed out. The server took too long to respond.';
|
||||
}
|
||||
if (errorStr.contains('connection refused')) {
|
||||
return 'Connection refused. The server is likely offline.';
|
||||
}
|
||||
if (errorStr.contains('failed host lookup')) {
|
||||
return 'DNS Lookup Failed. Could not reach the host.';
|
||||
}
|
||||
return 'A network connection error occurred.';
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = response?.statusCode;
|
||||
// Common client errors
|
||||
if (statusCode == 400) return 'Bad Request (400).';
|
||||
if (statusCode == 401) return 'Unauthorized (401). Authentication failed.';
|
||||
if (statusCode == 403) return 'Forbidden (403). You lack permission.';
|
||||
if (statusCode == 404) return 'Not Found (404). The resource does not exist.';
|
||||
if (statusCode == 422) return 'Validation Error (422). Some fields are invalid.';
|
||||
if (statusCode == 429) return 'Too Many Requests (429). You are being rate‑limited.';
|
||||
// Server errors
|
||||
if (statusCode != null && statusCode >= 500) {
|
||||
if (statusCode == 503) return 'Service Unavailable (503).';
|
||||
return 'Server Error ($statusCode). Please try again later.';
|
||||
}
|
||||
return 'Request failed with status $statusCode.';
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return 'The request was cancelled.';
|
||||
|
||||
case DioExceptionType.unknown:
|
||||
final errorStr = error?.toString().toLowerCase() ?? '';
|
||||
if (errorStr.contains('socketexception')) {
|
||||
if (errorStr.contains('connection refused')) return 'Server connection refused.';
|
||||
if (errorStr.contains('failed host lookup')) return 'Could not find the server address.';
|
||||
if (errorStr.contains('semaphore timeout')) return 'Network timeout.';
|
||||
return 'Network connection unstable.';
|
||||
}
|
||||
return 'An unexpected error occurred.';
|
||||
|
||||
default:
|
||||
return 'Request failed.';
|
||||
}
|
||||
}
|
||||
|
||||
String? _getServerMessage() {
|
||||
final data = response?.data;
|
||||
if (data is Map) {
|
||||
// Try common top‑level keys
|
||||
for (final key in ['message', 'error', 'detail', 'description']) {
|
||||
final val = data[key];
|
||||
if (val is String && val.trim().isNotEmpty) return val.trim();
|
||||
}
|
||||
// Nested errors map (e.g. { "errors": { "email": ["is taken"] } })
|
||||
final errors = data['errors'];
|
||||
if (errors is Map) {
|
||||
for (final entry in errors.entries) {
|
||||
final v = entry.value;
|
||||
if (v is List && v.isNotEmpty) return '${entry.key}: ${v.first}';
|
||||
if (v is String && v.isNotEmpty) return '${entry.key}: $v';
|
||||
}
|
||||
}
|
||||
} else if (data is List && data.isNotEmpty) {
|
||||
if (data.first is Map) {
|
||||
return _getServerMessageFromMap(data.first as Map);
|
||||
}
|
||||
} else if (data is String && data.isNotEmpty) {
|
||||
// Plain text body – use it directly, but keep it short
|
||||
return data.length > 200 ? '${data.substring(0, 200)}...' : data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper for when the first element of a list is a map
|
||||
String? _getServerMessageFromMap(Map map) {
|
||||
print("Server has map: ${map}");
|
||||
for (final key in ['message', 'error', 'detail']) {
|
||||
final val = map[key];
|
||||
if (val is String && val.trim().isNotEmpty) {
|
||||
return val.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _getFriendlyTitle() {
|
||||
if (type == DioExceptionType.badResponse) return 'API Error';
|
||||
return 'Network Error';
|
||||
}
|
||||
|
||||
String _getEmoji() {
|
||||
if (type == DioExceptionType.badResponse) return '⚠️';
|
||||
return '⛔';
|
||||
}
|
||||
}
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dio_exceptions.dart'; // Assuming they are in the same package
|
||||
|
||||
class ErrorWindow extends StatelessWidget {
|
||||
final Object? originalError;
|
||||
final Widget? child; // For retry buttons, etc.
|
||||
|
||||
const ErrorWindow(this.originalError, {this.child, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final error = originalError;
|
||||
|
||||
Widget errorContent = Text(
|
||||
'An unexpected error occurred:\n${error.toString()}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
);
|
||||
|
||||
if (error is DioException) {
|
||||
errorContent = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildFriendlyHeader(context, error),
|
||||
const Divider(height: 30),
|
||||
Text(
|
||||
'Technical Details:',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SelectableText(
|
||||
error.toDebugString(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 600, minHeight: 200),
|
||||
padding: const EdgeInsets.all(20),
|
||||
margin: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
errorContent,
|
||||
if (child != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
child: Align(alignment: Alignment.bottomRight, child: child),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFriendlyHeader(BuildContext context, DioException error) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.warning_rounded, color: Colors.redAccent, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
error.userMessage,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Request: ${error.requestOptions.method} ${error.requestOptions.uri.path}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
export 'android_files.dart';
|
||||
export 'clipboard_x.dart';
|
||||
export 'desktop_drop_x.dart';
|
||||
|
||||
/// A Calculator.
|
||||
class Calculator {
|
||||
/// Returns [value] plus 1.
|
||||
int addOne(int value) => value + 1;
|
||||
}
|
||||
Executable
+420
@@ -0,0 +1,420 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GradientText extends StatelessWidget {
|
||||
const GradientText(this.text, {super.key, required this.gradient});
|
||||
|
||||
final Widget text;
|
||||
final Gradient gradient;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ShaderMask(
|
||||
blendMode: BlendMode.srcIn,
|
||||
shaderCallback: (bounds) => gradient.createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)),
|
||||
child: text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum CornerSide { topLeft, topRight, bottomLeft, bottomRight }
|
||||
|
||||
class SingleScoopedCornerClipper extends CustomClipper<Path> {
|
||||
SingleScoopedCornerClipper({required this.radius, this.side = CornerSide.topRight});
|
||||
|
||||
final double radius;
|
||||
final CornerSide side;
|
||||
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
final path = Path();
|
||||
final w = size.width;
|
||||
final h = size.height;
|
||||
|
||||
// Start at top-left
|
||||
path.moveTo(0, 0);
|
||||
|
||||
switch (side) {
|
||||
case CornerSide.topRight:
|
||||
// Draw top edge to where the curve starts (right side)
|
||||
path.lineTo(w - radius, 0);
|
||||
// Concave arc
|
||||
path.arcToPoint(Offset(w, radius), radius: Radius.circular(radius), clockwise: false);
|
||||
// Draw remainder of edges
|
||||
path.lineTo(w, h);
|
||||
path.lineTo(0, h);
|
||||
break;
|
||||
|
||||
case CornerSide.bottomRight:
|
||||
// Draw right edge to where the curve starts (bottom side)
|
||||
path.lineTo(w, h - radius);
|
||||
// Concave arc
|
||||
path.arcToPoint(Offset(w - radius, h), radius: Radius.circular(radius), clockwise: false);
|
||||
// Draw remainder of edges
|
||||
path.lineTo(0, h);
|
||||
break;
|
||||
|
||||
case CornerSide.bottomLeft:
|
||||
// Draw bottom edge to where the curve starts (left side)
|
||||
path.lineTo(radius, h);
|
||||
// Concave arc
|
||||
path.arcToPoint(Offset(0, h - radius), radius: Radius.circular(radius), clockwise: false);
|
||||
// Draw remainder of edges
|
||||
path.lineTo(0, 0); // Already moved here, just closing path
|
||||
break;
|
||||
|
||||
case CornerSide.topLeft:
|
||||
// Draw left edge to where the curve starts (top side)
|
||||
path.lineTo(0, radius);
|
||||
// Concave arc
|
||||
path.arcToPoint(Offset(radius, 0), radius: Radius.circular(radius), clockwise: false);
|
||||
// Draw remainder of edges
|
||||
path.lineTo(w, 0);
|
||||
path.lineTo(w, h);
|
||||
path.lineTo(0, h);
|
||||
break;
|
||||
}
|
||||
|
||||
path.close();
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(SingleScoopedCornerClipper oldClipper) => oldClipper.radius != radius || oldClipper.side != side;
|
||||
}
|
||||
|
||||
extension IntBytesExtension on int {
|
||||
/// Converts to Kilobytes (1024 bytes)
|
||||
int get kb => this * 1024;
|
||||
|
||||
/// Converts to Megabytes (1024 * 1024 bytes)
|
||||
int get mb => this * 1024 * 1024;
|
||||
|
||||
/// Converts to Gigabytes (1024 * 1024 * 1024 bytes)
|
||||
int get gb => this * 1024 * 1024 * 1024;
|
||||
|
||||
/// Converts to Terabytes (1024^4 bytes)
|
||||
int get tb => this * 1024 * 1024 * 1024 * 1024;
|
||||
}
|
||||
|
||||
String formatBytes(int bytes, {int decimals = 2, bool useBinary = true}) {
|
||||
if (bytes <= 0) return "0 B";
|
||||
|
||||
// Choose between binary (1024) or decimal (1000) base
|
||||
final base = useBinary ? 1024.0 : 1000.0;
|
||||
final units = useBinary
|
||||
? ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
|
||||
: ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
final exp = (log(bytes) / log(base)).floor();
|
||||
final size = bytes / pow(base, exp);
|
||||
|
||||
// Clamp the exponent to avoid index out of bounds
|
||||
final unitIndex = exp.clamp(0, units.length - 1);
|
||||
|
||||
return '${size.toStringAsFixed(decimals)} ${units[unitIndex]}';
|
||||
}
|
||||
|
||||
// Extension method for easier usage
|
||||
extension BytesFormatter on int {
|
||||
String toReadableSize({int decimals = 2}) => formatBytes(this, decimals: decimals);
|
||||
}
|
||||
|
||||
extension DateTimeExtension on DateTime {
|
||||
String timeAgo() {
|
||||
final diff = DateTime.now().difference(this);
|
||||
if (diff.inDays >= 1) return '${diff.inDays} days ago';
|
||||
if (diff.inHours >= 1) return '${diff.inHours} hours ago';
|
||||
if (diff.inMinutes >= 1) return '${diff.inMinutes} minutes ago';
|
||||
return 'Just now';
|
||||
}
|
||||
|
||||
String toSmartDateWithTime() {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(this);
|
||||
|
||||
// Helper for "14:30" format
|
||||
final clockTime = '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';
|
||||
|
||||
String datePart;
|
||||
|
||||
// 1. Just now
|
||||
if (diff.inMinutes < 1) {
|
||||
datePart = 'just now';
|
||||
}
|
||||
// 2. Minute precision (up to 1 hour)
|
||||
else if (diff.inHours < 1) {
|
||||
datePart = '${diff.inMinutes}m ago';
|
||||
}
|
||||
// 3. Hour precision (up to 24 hours)
|
||||
else if (diff.inDays < 1) {
|
||||
datePart = '${diff.inHours}h ago';
|
||||
}
|
||||
// 4. Compact date (older than a day)
|
||||
else if (year == now.year) {
|
||||
datePart = '${_getMonth(month)} $day';
|
||||
} else {
|
||||
datePart = '${_getMonth(month)} $day, $year';
|
||||
}
|
||||
|
||||
// Combine them: "5m ago at 14:30" or "Jan 2 at 07:11"
|
||||
return '$datePart at $clockTime';
|
||||
}
|
||||
|
||||
String _getMonth(int month) {
|
||||
return ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][month - 1];
|
||||
}
|
||||
|
||||
String _getWeekday(int day) {
|
||||
return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][day - 1];
|
||||
}
|
||||
}
|
||||
|
||||
extension CompactDateTime on DateTime {
|
||||
String toCompactPostDate() {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(this);
|
||||
|
||||
// 1. Less than a minute: "Just now"
|
||||
if (difference.inMinutes < 1) {
|
||||
return 'now';
|
||||
}
|
||||
|
||||
// 2. Less than an hour: "5m", "59m"
|
||||
if (difference.inHours < 1) {
|
||||
return '${difference.inMinutes}m';
|
||||
}
|
||||
|
||||
// 3. Less than a day: "2h", "23h"
|
||||
if (difference.inDays < 1) {
|
||||
return '${difference.inHours}h';
|
||||
}
|
||||
|
||||
// 4. Less than a week: "Mon", "Tue"
|
||||
if (difference.inDays < 7) {
|
||||
return _getWeekday(weekday);
|
||||
}
|
||||
|
||||
// 5. This calendar year: "Mar 12"
|
||||
if (year == now.year) {
|
||||
return '${_getMonth(month)} $day';
|
||||
}
|
||||
|
||||
// 6. Different year: "Mar 2024"
|
||||
return '${_getMonth(month)} $year';
|
||||
}
|
||||
|
||||
// Helper for short weekdays
|
||||
String _getWeekday(int day) {
|
||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
return days[day - 1];
|
||||
}
|
||||
|
||||
// Helper for short months
|
||||
String _getMonth(int month) {
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return months[month - 1];
|
||||
}
|
||||
}
|
||||
|
||||
extension TimeGradientColor on DateTime {
|
||||
Color toSkyColor(Color baseColor) {
|
||||
// Define the color transition for a full 24-hour cycle
|
||||
const skyline = LinearGradient(
|
||||
colors: [
|
||||
Color(0xFF0F172A), // 00:00 - Deep Night
|
||||
Color(0xFF334155), // 06:00 - Dawn
|
||||
Color(0xFF7DD3FC), // 12:00 - Noon
|
||||
Color(0xFFFB923C), // 18:00 - Sunset
|
||||
Color(0xFF0F172A), // 24:00 - Back to Night
|
||||
],
|
||||
stops: [0.0, 0.25, 0.5, 0.75, 1.0],
|
||||
);
|
||||
|
||||
// Calculate the day's progress (0.0 to 1.0)
|
||||
final double dayProgress = (hour * 60 + minute) / (24 * 60);
|
||||
|
||||
// Sample the color from the gradient
|
||||
final Color sampledColor = _sampleGradient(skyline, dayProgress);
|
||||
|
||||
// Blend it subtly with your theme's surface color (15% intensity)
|
||||
return Color.lerp(baseColor, sampledColor, 0.15)!;
|
||||
}
|
||||
|
||||
// Helper to interpolate color from a LinearGradient
|
||||
Color _sampleGradient(LinearGradient gradient, double t) {
|
||||
if (t <= 0) return gradient.colors.first;
|
||||
if (t >= 1) return gradient.colors.last;
|
||||
|
||||
final int segmentCount = gradient.colors.length - 1;
|
||||
final double segmentValue = t * segmentCount;
|
||||
final int index = segmentValue.floor();
|
||||
final double weight = segmentValue - index;
|
||||
|
||||
return Color.lerp(gradient.colors[index], gradient.colors[index + 1], weight)!;
|
||||
}
|
||||
}
|
||||
|
||||
extension NullableProgress01 on ImageChunkEvent? {
|
||||
/// 0.0 → 1.0 while loading; null when unknown.
|
||||
double? get progress {
|
||||
final chunk = this;
|
||||
if (chunk == null) return null;
|
||||
|
||||
final total = chunk.expectedTotalBytes;
|
||||
return (total != null && total > 0) ? (chunk.cumulativeBytesLoaded / total).clamp(0.0, 1.0) : null;
|
||||
}
|
||||
}
|
||||
|
||||
extension ObjectExt<T> on T {
|
||||
R let<R>(R Function(T e) block) => block(this);
|
||||
}
|
||||
|
||||
extension ColorUtils on Color {
|
||||
// ---------------------------------------------------------
|
||||
// 1. Brightness Manipulation (HSL Based)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// Darkens the color by a given [amount] (0.0 to 1.0).
|
||||
///
|
||||
/// Defaults to 10% (0.1).
|
||||
/// Uses HSL calculation to preserve color hue.
|
||||
// Color darken([double amount = 0.1]) {
|
||||
// assert(amount >= 0 && amount <= 1, 'Amount must be between 0 and 1');
|
||||
|
||||
// final hsl = HSLColor.fromColor(this);
|
||||
// final newLightness = (hsl.lightness - amount).clamp(0.0, 1.0);
|
||||
|
||||
// return hsl.withLightness(newLightness).toColor();
|
||||
// }
|
||||
|
||||
// /// Lightens the color by a given [amount] (0.0 to 1.0).
|
||||
// ///
|
||||
// /// Defaults to 10% (0.1).
|
||||
// /// Uses HSL calculation to preserve color hue.
|
||||
// Color lighten([double amount = 0.1]) {
|
||||
// assert(amount >= 0 && amount <= 1, 'Amount must be between 0 and 1');
|
||||
|
||||
// final hsl = HSLColor.fromColor(this);
|
||||
// final newLightness = (hsl.lightness + amount).clamp(0.0, 1.0);
|
||||
|
||||
// return hsl.withLightness(newLightness).toColor();
|
||||
// }
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 2. Opacity & Color Mixing
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// Returns a new color with the specified opacity.
|
||||
/// Shortcut for `withOpacity`.
|
||||
Color withOpacityValue(double opacity) {
|
||||
return withOpacity(opacity);
|
||||
}
|
||||
|
||||
/// Blends this color with another color.
|
||||
///
|
||||
/// [amount] defines how much of [color] to mix in (0.0 to 1.0).
|
||||
/// 0.0 results in the original color, 1.0 results in the input color.
|
||||
Color mix(Color color, [double amount = 0.5]) {
|
||||
assert(amount >= 0 && amount <= 1, 'Amount must be between 0 and 1');
|
||||
|
||||
return Color.lerp(this, color, amount)!;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3. Color Properties & Conversion
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// Returns the color as a Hex string (e.g. "#FF0000").
|
||||
/// Includes alpha channel if [includeAlpha] is true.
|
||||
String toHexString({bool includeAlpha = false}) {
|
||||
if (includeAlpha) {
|
||||
return '#${value.toRadixString(16).padLeft(8, '0').toUpperCase()}';
|
||||
}
|
||||
return '#${value.toRadixString(16).substring(2).padLeft(6, '0').toUpperCase()}';
|
||||
}
|
||||
|
||||
/// Calculates the relative luminance.
|
||||
/// Useful for determining if the color is "light" or "dark".
|
||||
bool get isDark => computeLuminance() < 0.5;
|
||||
|
||||
bool get isLight => !isDark;
|
||||
|
||||
/// Returns a contrasting text color (Black or White)
|
||||
/// suitable for reading on top of this color.
|
||||
Color get contrastingColor => isDark ? Colors.white : Colors.black;
|
||||
}
|
||||
|
||||
/// Optional: Extension on String to parse Hex colors easily.
|
||||
extension StringColorUtils on String {
|
||||
/// Parses a hex string to a Color.
|
||||
/// Supports formats: "FF0000", "#FF0000", "0xFF0000", "#FFFF0000".
|
||||
Color toColor() {
|
||||
var hexColor = replaceAll('#', '').replaceAll('0x', '');
|
||||
|
||||
if (hexColor.length == 6) {
|
||||
hexColor = 'FF$hexColor'; // Add full opacity if alpha is missing
|
||||
}
|
||||
|
||||
return Color(int.parse(hexColor, radix: 16));
|
||||
}
|
||||
}
|
||||
|
||||
enum Side { top, left, bottom, right }
|
||||
|
||||
extension NumBorderRadiusExtension on num {
|
||||
/// Creates a circular radius where specific sides are flattened (radius = 0).
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// // A pill shape flattened on the left side
|
||||
/// Container(
|
||||
/// decoration: BoxDecoration(
|
||||
/// borderRadius: 50.circularExcept({Side.left}),
|
||||
/// color: Colors.blue,
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
BorderRadius circularExcept(Set<Side> excludedSides) {
|
||||
final double r = toDouble();
|
||||
|
||||
// If a side is in the exclusion set, its corners become 0.
|
||||
// Otherwise, they take the radius [r].
|
||||
|
||||
// Top-Left is affected by Top OR Left
|
||||
final tl = (excludedSides.contains(Side.top) || excludedSides.contains(Side.left)) ? 0.0 : r;
|
||||
|
||||
// Top-Right is affected by Top OR Right
|
||||
final tr = (excludedSides.contains(Side.top) || excludedSides.contains(Side.right)) ? 0.0 : r;
|
||||
|
||||
// Bottom-Left is affected by Bottom OR Left
|
||||
final bl = (excludedSides.contains(Side.bottom) || excludedSides.contains(Side.left)) ? 0.0 : r;
|
||||
|
||||
// Bottom-Right is affected by Bottom OR Right
|
||||
final br = (excludedSides.contains(Side.bottom) || excludedSides.contains(Side.right)) ? 0.0 : r;
|
||||
|
||||
return BorderRadius.only(
|
||||
topLeft: Radius.circular(tl),
|
||||
topRight: Radius.circular(tr),
|
||||
bottomLeft: Radius.circular(bl),
|
||||
bottomRight: Radius.circular(br),
|
||||
);
|
||||
}
|
||||
|
||||
BorderRadius get circular {
|
||||
return BorderRadius.circular(this.toDouble());
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: Extension on BorderRadius itself if you want to modify an existing one
|
||||
extension BorderRadiusModifier on BorderRadius {
|
||||
BorderRadius exclude(Set<Side> sides) {
|
||||
return copyWith(
|
||||
topLeft: sides.contains(Side.top) || sides.contains(Side.left) ? Radius.zero : topLeft,
|
||||
topRight: sides.contains(Side.top) || sides.contains(Side.right) ? Radius.zero : topRight,
|
||||
bottomLeft: sides.contains(Side.bottom) || sides.contains(Side.left) ? Radius.zero : bottomLeft,
|
||||
bottomRight: sides.contains(Side.bottom) || sides.contains(Side.right) ? Radius.zero : bottomRight,
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export 'src/drop_target.dart';
|
||||
export 'src/drop_item.dart';
|
||||
export 'src/channel.dart';
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'src/pasteboard_platform_web.dart'
|
||||
if (dart.library.io) 'src/pasteboard_platform_io.dart';
|
||||
|
||||
class Pasteboard {
|
||||
/// Returns the image data of the pasteboard.
|
||||
///
|
||||
/// available on iOS, Desktop, Web, Android
|
||||
static Future<Uint8List?> get image => pasteboard.image;
|
||||
|
||||
/// only available on Windows and the web.
|
||||
/// Get "HTML format" from system pasteboard.
|
||||
/// HTML format: https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767917(v=vs.85)
|
||||
static Future<String?> get html => pasteboard.html;
|
||||
|
||||
/// available on iOS, Android, Web
|
||||
///
|
||||
/// set image data to system pasteboard.
|
||||
static Future<void> writeImage(Uint8List? image) =>
|
||||
pasteboard.writeImage(image);
|
||||
|
||||
/// available on Desktop, Android.
|
||||
///
|
||||
/// Android: paths are content uris, must read by content resolver.
|
||||
///
|
||||
/// Get files from system pasteboard.
|
||||
static Future<List<String>> files() => pasteboard.files();
|
||||
|
||||
/// Only available on desktop platforms.
|
||||
///
|
||||
/// Set files to system pasteboard.
|
||||
static Future<bool> writeFiles(List<String> files) =>
|
||||
pasteboard.writeFiles(files);
|
||||
|
||||
/// Available on all platforms.
|
||||
/// Get text from system pasteboard.
|
||||
static Future<String?> get text => pasteboard.text;
|
||||
|
||||
/// Available on all platforms.
|
||||
/// Set text to system pasteboard.
|
||||
static void writeText(String value) => pasteboard.writeText(value);
|
||||
}
|
||||
Vendored
+190
@@ -0,0 +1,190 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fox_utils/vendor/src/drop_item.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'events.dart';
|
||||
import 'web_drop_item.dart';
|
||||
|
||||
typedef RawDropListener = void Function(DropEvent);
|
||||
|
||||
class DesktopDrop {
|
||||
static const MethodChannel _channel = MethodChannel('desktop_drop');
|
||||
|
||||
DesktopDrop._();
|
||||
|
||||
static final instance = DesktopDrop._();
|
||||
|
||||
final _listeners = <RawDropListener>{};
|
||||
|
||||
var _inited = false;
|
||||
|
||||
Offset? _offset;
|
||||
|
||||
void init() {
|
||||
if (_inited) {
|
||||
return;
|
||||
}
|
||||
_inited = true;
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
try {
|
||||
return await _handleMethodChannel(call);
|
||||
} catch (e, s) {
|
||||
debugPrint('_handleMethodChannel: $e $s');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// macOS: Attempt to start security-scoped access for a bookmarked URL.
|
||||
///
|
||||
/// Pass the [DropItem.extraAppleBookmark] bytes from a dropped file that
|
||||
/// originated outside the app container. Returns `true` if access began.
|
||||
///
|
||||
/// If [bookmark] is empty, this function returns `false` and does not
|
||||
/// invoke the platform call. Promise files written under your container do
|
||||
/// not require security-scoped access.
|
||||
Future<bool> startAccessingSecurityScopedResource(
|
||||
{required Uint8List bookmark}) async {
|
||||
if (bookmark.isEmpty) return false;
|
||||
Map<String, dynamic> resultMap = {};
|
||||
resultMap["apple-bookmark"] = bookmark;
|
||||
final bool? result = await _channel.invokeMethod(
|
||||
"startAccessingSecurityScopedResource", resultMap);
|
||||
if (result == null) return false;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// macOS: Stop security-scoped access previously started.
|
||||
///
|
||||
/// If [bookmark] is empty, this function returns `true` and does not
|
||||
/// invoke the platform call, acting as a no-op.
|
||||
Future<bool> stopAccessingSecurityScopedResource(
|
||||
{required Uint8List bookmark}) async {
|
||||
if (bookmark.isEmpty) return true;
|
||||
Map<String, dynamic> resultMap = {};
|
||||
resultMap["apple-bookmark"] = bookmark;
|
||||
final bool result = await _channel.invokeMethod(
|
||||
"stopAccessingSecurityScopedResource", resultMap);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _handleMethodChannel(MethodCall call) async {
|
||||
switch (call.method) {
|
||||
case "entered":
|
||||
final position = (call.arguments as List).cast<double>();
|
||||
_offset = Offset(position[0], position[1]);
|
||||
_notifyEvent(DropEnterEvent(location: _offset!));
|
||||
break;
|
||||
case "updated":
|
||||
if (_offset == null && UniversalPlatform.isLinux) {
|
||||
final position = (call.arguments as List).cast<double>();
|
||||
_offset = Offset(position[0], position[1]);
|
||||
_notifyEvent(DropEnterEvent(location: _offset!));
|
||||
return;
|
||||
}
|
||||
final position = (call.arguments as List).cast<double>();
|
||||
_offset = Offset(position[0], position[1]);
|
||||
_notifyEvent(DropUpdateEvent(location: _offset!));
|
||||
break;
|
||||
case "exited":
|
||||
_notifyEvent(DropExitEvent(location: _offset ?? Offset.zero));
|
||||
_offset = null;
|
||||
break;
|
||||
case "performOperation":
|
||||
final paths = (call.arguments as List).cast<String>();
|
||||
_notifyEvent(
|
||||
DropDoneEvent(
|
||||
location: _offset ?? Offset.zero,
|
||||
files: paths.map((e) => DropItemFile(e)).toList(),
|
||||
),
|
||||
);
|
||||
_offset = null;
|
||||
break;
|
||||
case "performOperation_macos":
|
||||
final items = (call.arguments as List).cast<Map>();
|
||||
_notifyEvent(
|
||||
DropDoneEvent(
|
||||
location: _offset ?? Offset.zero,
|
||||
files: items.map((raw) {
|
||||
final path = raw["path"] as String;
|
||||
final bookmark = raw["apple-bookmark"] as Uint8List?;
|
||||
final isDir = (raw["isDirectory"] as bool?) ?? false;
|
||||
final fromPromise = (raw["fromPromise"] as bool?) ?? false;
|
||||
if (isDir) {
|
||||
return DropItemDirectory(
|
||||
path,
|
||||
const [],
|
||||
extraAppleBookmark: bookmark,
|
||||
fromPromise: fromPromise,
|
||||
);
|
||||
}
|
||||
return DropItemFile(
|
||||
path,
|
||||
extraAppleBookmark: bookmark,
|
||||
fromPromise: fromPromise,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
_offset = null;
|
||||
break;
|
||||
|
||||
case "performOperation_linux":
|
||||
// gtk notify 'exit' before 'performOperation'.
|
||||
final text = (call.arguments as List<dynamic>)[0] as String;
|
||||
final offset = ((call.arguments as List<dynamic>)[1] as List<dynamic>)
|
||||
.cast<double>();
|
||||
final paths = const LineSplitter().convert(text).map((e) {
|
||||
try {
|
||||
final uri = Uri.tryParse(e);
|
||||
if (uri == null) {
|
||||
return '';
|
||||
}
|
||||
if (uri.scheme == 'file') {
|
||||
return uri.toFilePath();
|
||||
}
|
||||
return e;
|
||||
} catch (error, stacktrace) {
|
||||
debugPrint('failed to parse linux path: $error $stacktrace');
|
||||
}
|
||||
return '';
|
||||
}).where((e) => e.isNotEmpty);
|
||||
_notifyEvent(DropDoneEvent(
|
||||
location: Offset(offset[0], offset[1]),
|
||||
files: paths.map((e) => DropItemFile(e)).toList(),
|
||||
));
|
||||
break;
|
||||
case "performOperation_web":
|
||||
final results = (call.arguments as List)
|
||||
.cast<Map>()
|
||||
.map((e) => WebDropItem.fromJson(e.cast<String, dynamic>()))
|
||||
.map((e) => e.toDropItem())
|
||||
.toList();
|
||||
_notifyEvent(
|
||||
DropDoneEvent(location: _offset ?? Offset.zero, files: results),
|
||||
);
|
||||
_offset = null;
|
||||
break;
|
||||
default:
|
||||
throw UnimplementedError('${call.method} not implement.');
|
||||
}
|
||||
}
|
||||
|
||||
void _notifyEvent(DropEvent event) {
|
||||
for (final listener in _listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
void addRawDropEventListener(RawDropListener listener) {
|
||||
assert(!_listeners.contains(listener));
|
||||
_listeners.add(listener);
|
||||
}
|
||||
|
||||
void removeRawDropEventListener(RawDropListener listener) {
|
||||
assert(_listeners.contains(listener));
|
||||
_listeners.remove(listener);
|
||||
}
|
||||
}
|
||||
Vendored
+104
@@ -0,0 +1,104 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
|
||||
/// A dropped item.
|
||||
///
|
||||
/// On desktop, this is usually a filesystem path (file or directory).
|
||||
///
|
||||
/// macOS specifics:
|
||||
/// - If the drag source provided a real file URL (e.g. Finder/JetBrains),
|
||||
/// [extraAppleBookmark] will typically be non-null and allow security-scoped
|
||||
/// access when running sandboxed.
|
||||
/// - If the drag source used a file promise (e.g. VS Code/Electron), the
|
||||
/// system delivers bytes into a per-drop temporary folder inside your app's
|
||||
/// container. In that case [fromPromise] is true and [extraAppleBookmark]
|
||||
/// is usually null/empty. There is no original source path in this flow.
|
||||
abstract class DropItem extends XFile {
|
||||
/// Security-scoped bookmark bytes for the dropped item (macOS only).
|
||||
///
|
||||
/// Use with [DesktopDrop.startAccessingSecurityScopedResource] to gain
|
||||
/// temporary access to files outside your sandbox. When empty or null,
|
||||
/// you typically don't need to call start/stop (e.g. promise files in
|
||||
/// your app's container).
|
||||
Uint8List? extraAppleBookmark;
|
||||
|
||||
/// True when this item was delivered via a macOS file promise and was
|
||||
/// written into your app's temporary Drops directory.
|
||||
///
|
||||
/// In this case, the original source path is not available by design.
|
||||
final bool fromPromise;
|
||||
DropItem(
|
||||
super.path, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.bytes,
|
||||
super.lastModified,
|
||||
this.extraAppleBookmark,
|
||||
this.fromPromise = false,
|
||||
});
|
||||
|
||||
DropItem.fromData(
|
||||
super.bytes, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.lastModified,
|
||||
super.path,
|
||||
this.extraAppleBookmark,
|
||||
this.fromPromise = false,
|
||||
}) : super.fromData();
|
||||
}
|
||||
|
||||
class DropItemFile extends DropItem {
|
||||
DropItemFile(
|
||||
super.path, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.bytes,
|
||||
super.lastModified,
|
||||
super.extraAppleBookmark,
|
||||
super.fromPromise,
|
||||
});
|
||||
|
||||
DropItemFile.fromData(
|
||||
super.bytes, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.lastModified,
|
||||
super.path,
|
||||
super.fromPromise,
|
||||
}) : super.fromData();
|
||||
}
|
||||
|
||||
/// A dropped directory.
|
||||
class DropItemDirectory extends DropItem {
|
||||
final List<DropItem> children;
|
||||
|
||||
DropItemDirectory(
|
||||
super.path,
|
||||
this.children, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.bytes,
|
||||
super.lastModified,
|
||||
super.extraAppleBookmark,
|
||||
super.fromPromise,
|
||||
});
|
||||
|
||||
DropItemDirectory.fromData(
|
||||
super.bytes,
|
||||
this.children, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.lastModified,
|
||||
super.path,
|
||||
super.extraAppleBookmark,
|
||||
super.fromPromise,
|
||||
}) : super.fromData();
|
||||
}
|
||||
Vendored
+220
@@ -0,0 +1,220 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'channel.dart';
|
||||
import 'drop_item.dart';
|
||||
import 'events.dart';
|
||||
|
||||
@immutable
|
||||
class DropDoneDetails {
|
||||
const DropDoneDetails({
|
||||
required this.files,
|
||||
required this.localPosition,
|
||||
required this.globalPosition,
|
||||
});
|
||||
|
||||
final List<DropItem> files;
|
||||
final Offset localPosition;
|
||||
final Offset globalPosition;
|
||||
}
|
||||
|
||||
class DropEventDetails {
|
||||
DropEventDetails({
|
||||
required this.localPosition,
|
||||
required this.globalPosition,
|
||||
});
|
||||
|
||||
final Offset localPosition;
|
||||
|
||||
final Offset globalPosition;
|
||||
}
|
||||
|
||||
typedef OnDragDoneCallback = void Function(DropDoneDetails details);
|
||||
|
||||
typedef OnDragCallback<Detail> = void Function(Detail details);
|
||||
|
||||
/// A widget that accepts draggable files.
|
||||
class DropTarget extends StatefulWidget {
|
||||
const DropTarget({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onDragEntered,
|
||||
this.onDragExited,
|
||||
this.onDragDone,
|
||||
this.onDragUpdated,
|
||||
this.enable = true,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// Callback when drag entered target area.
|
||||
final OnDragCallback<DropEventDetails>? onDragEntered;
|
||||
|
||||
/// Callback when drag exited target area.
|
||||
final OnDragCallback<DropEventDetails>? onDragExited;
|
||||
|
||||
/// Callback when drag hover on target area.
|
||||
final OnDragCallback<DropEventDetails>? onDragUpdated;
|
||||
|
||||
/// Callback when drag dropped on target area.
|
||||
final OnDragDoneCallback? onDragDone;
|
||||
|
||||
/// Whether to enable drop target.
|
||||
///
|
||||
/// ATTENTION: You should disable drop target when you push a new page/widget in
|
||||
/// front of this drop target, since the drop target will still receive drag events
|
||||
/// even it is invisible.
|
||||
/// https://github.com/MixinNetwork/flutter-plugins/issues/2
|
||||
final bool enable;
|
||||
|
||||
@override
|
||||
State<DropTarget> createState() => _DropTargetState();
|
||||
}
|
||||
|
||||
enum _DragTargetStatus {
|
||||
enter,
|
||||
update,
|
||||
idle,
|
||||
}
|
||||
|
||||
class _DropTargetState extends State<DropTarget> {
|
||||
_DragTargetStatus _status = _DragTargetStatus.idle;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
DesktopDrop.instance.init();
|
||||
if (widget.enable) {
|
||||
DesktopDrop.instance.addRawDropEventListener(_onDropEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DropTarget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.enable && !oldWidget.enable) {
|
||||
DesktopDrop.instance.addRawDropEventListener(_onDropEvent);
|
||||
} else if (!widget.enable && oldWidget.enable) {
|
||||
DesktopDrop.instance.removeRawDropEventListener(_onDropEvent);
|
||||
if (_status != _DragTargetStatus.idle) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.idle,
|
||||
localLocation: Offset.zero,
|
||||
globalLocation: Offset.zero,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onDropEvent(DropEvent event) {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) {
|
||||
return;
|
||||
}
|
||||
final globalPosition = _scaleHoverPoint(context, event.location);
|
||||
final position = renderBox.globalToLocal(globalPosition);
|
||||
bool inBounds = renderBox.paintBounds.contains(position);
|
||||
if (event is DropEnterEvent) {
|
||||
if (!inBounds) {
|
||||
assert(_status == _DragTargetStatus.idle);
|
||||
} else {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.enter,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
);
|
||||
}
|
||||
} else if (event is DropUpdateEvent) {
|
||||
if (_status == _DragTargetStatus.idle && inBounds) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.enter,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
);
|
||||
} else if ((_status == _DragTargetStatus.enter ||
|
||||
_status == _DragTargetStatus.update) &&
|
||||
inBounds) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.update,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
debugRequiredStatus: false,
|
||||
);
|
||||
} else if (_status != _DragTargetStatus.idle && !inBounds) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.idle,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
);
|
||||
}
|
||||
} else if (event is DropExitEvent && _status != _DragTargetStatus.idle) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.idle,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
);
|
||||
} else if (event is DropDoneEvent &&
|
||||
(_status != _DragTargetStatus.idle || UniversalPlatform.isLinux) &&
|
||||
inBounds) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.idle,
|
||||
debugRequiredStatus: false,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
);
|
||||
widget.onDragDone?.call(DropDoneDetails(
|
||||
files: event.files,
|
||||
localPosition: position,
|
||||
globalPosition: globalPosition,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _updateStatus(
|
||||
_DragTargetStatus status, {
|
||||
bool debugRequiredStatus = true,
|
||||
required Offset localLocation,
|
||||
required Offset globalLocation,
|
||||
}) {
|
||||
assert(!debugRequiredStatus || _status != status);
|
||||
_status = status;
|
||||
final details = DropEventDetails(
|
||||
localPosition: localLocation,
|
||||
globalPosition: globalLocation,
|
||||
);
|
||||
switch (_status) {
|
||||
case _DragTargetStatus.enter:
|
||||
widget.onDragEntered?.call(details);
|
||||
break;
|
||||
case _DragTargetStatus.update:
|
||||
widget.onDragUpdated?.call(details);
|
||||
break;
|
||||
case _DragTargetStatus.idle:
|
||||
widget.onDragExited?.call(details);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.enable) {
|
||||
DesktopDrop.instance.removeRawDropEventListener(_onDropEvent);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
Offset _scaleHoverPoint(BuildContext context, Offset point) {
|
||||
if (UniversalPlatform.isWindows || UniversalPlatform.isAndroid) {
|
||||
return point.scale(
|
||||
1 / MediaQuery.of(context).devicePixelRatio,
|
||||
1 / MediaQuery.of(context).devicePixelRatio,
|
||||
);
|
||||
}
|
||||
return point;
|
||||
}
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
import 'package:fox_utils/vendor/src/drop_item.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
abstract class DropEvent {
|
||||
Offset location;
|
||||
|
||||
DropEvent(this.location);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType($location)';
|
||||
}
|
||||
}
|
||||
|
||||
class DropEnterEvent extends DropEvent {
|
||||
DropEnterEvent({required Offset location}) : super(location);
|
||||
}
|
||||
|
||||
class DropExitEvent extends DropEvent {
|
||||
DropExitEvent({required Offset location}) : super(location);
|
||||
}
|
||||
|
||||
class DropUpdateEvent extends DropEvent {
|
||||
DropUpdateEvent({required Offset location}) : super(location);
|
||||
}
|
||||
|
||||
class DropDoneEvent extends DropEvent {
|
||||
final List<DropItem> files;
|
||||
|
||||
DropDoneEvent({
|
||||
required Offset location,
|
||||
required this.files,
|
||||
}) : super(location);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType($location, $files)';
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
abstract class PasteboardPlatform {
|
||||
Future<Uint8List?> get image;
|
||||
|
||||
Future<String?> get html;
|
||||
|
||||
Future<void> writeImage(Uint8List? image);
|
||||
|
||||
Future<List<String>> files();
|
||||
|
||||
Future<bool> writeFiles(List<String> files);
|
||||
|
||||
Future<String?> get text;
|
||||
|
||||
void writeText(String value);
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import 'dart:io';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'pasteboard_platform.dart';
|
||||
|
||||
const PasteboardPlatform pasteboard = PasteboardPlatformIO();
|
||||
|
||||
class PasteboardPlatformIO implements PasteboardPlatform {
|
||||
const PasteboardPlatformIO();
|
||||
|
||||
static const MethodChannel _channel = MethodChannel('pasteboard');
|
||||
|
||||
@override
|
||||
Future<List<String>> files() async {
|
||||
final files = await _channel.invokeMethod<List>('files');
|
||||
return files?.cast<String>() ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> get html async {
|
||||
if (Platform.isWindows || Platform.isAndroid) {
|
||||
return await _channel.invokeMethod<Object>('html') as String?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List?> get image async {
|
||||
final image = await _channel.invokeMethod<Object>('image');
|
||||
|
||||
if (image == null) {
|
||||
return null;
|
||||
}
|
||||
if (Platform.isMacOS ||
|
||||
Platform.isLinux ||
|
||||
Platform.isIOS ||
|
||||
Platform.isAndroid) {
|
||||
return image as Uint8List;
|
||||
} else if (Platform.isWindows) {
|
||||
final file = File(image as String);
|
||||
final bytes = await file.readAsBytes();
|
||||
await file.delete();
|
||||
return bytes;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> writeFiles(List<String> files) async {
|
||||
try {
|
||||
await _channel.invokeMethod<Object>('writeFiles', files);
|
||||
return true;
|
||||
} catch (error, stacktrace) {
|
||||
debugPrint('$error\n$stacktrace');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeImage(Uint8List? image) async {
|
||||
if (image == null) {
|
||||
return;
|
||||
}
|
||||
if (Platform.isIOS || Platform.isMacOS || Platform.isAndroid) {
|
||||
await _channel.invokeMethod<void>('writeImage', image);
|
||||
} else if (Platform.isWindows) {
|
||||
final file = await File(_getTempFileName()).create();
|
||||
file.writeAsBytesSync(image);
|
||||
await _channel
|
||||
.invokeMethod<Object>('writeImage', {'fileName': file.path});
|
||||
file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> get text async {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
return data?.text;
|
||||
}
|
||||
|
||||
@override
|
||||
void writeText(String value) {
|
||||
Clipboard.setData(ClipboardData(text: value));
|
||||
}
|
||||
}
|
||||
|
||||
String _getTempFileName() {
|
||||
final dir = Directory.systemTemp;
|
||||
String tempFileName;
|
||||
|
||||
const uuid = Uuid();
|
||||
|
||||
while (true) {
|
||||
tempFileName = p.join(dir.path, uuid.v1().toString());
|
||||
if (!File(tempFileName).existsSync()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return tempFileName;
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import 'dart:js_interop';
|
||||
|
||||
import 'package:web/web.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'pasteboard_platform.dart';
|
||||
|
||||
Future<String?> _readBlobAsText(Blob blob) async {
|
||||
final FileReader reader = FileReader();
|
||||
final future =
|
||||
reader.onLoadEnd.first.then((ProgressEvent event) => reader.result);
|
||||
|
||||
reader.readAsText(blob);
|
||||
|
||||
final res = await future;
|
||||
return res.toString();
|
||||
}
|
||||
|
||||
Clipboard get _clipboard => window.navigator.clipboard;
|
||||
|
||||
extension on Blob {
|
||||
Future<Uint8List?> _readAsUint8List() async {
|
||||
final buffer = await arrayBuffer().toDart;
|
||||
return buffer.toDart.asUint8List();
|
||||
}
|
||||
}
|
||||
|
||||
const PasteboardPlatform pasteboard = PasteboardPlatformWeb();
|
||||
|
||||
class PasteboardPlatformWeb implements PasteboardPlatform {
|
||||
const PasteboardPlatformWeb();
|
||||
|
||||
@override
|
||||
Future<List<String>> files() async => const [];
|
||||
|
||||
@override
|
||||
Future<String?> get html async {
|
||||
try {
|
||||
final clipboardItems = await _clipboard.read().toDart;
|
||||
for (var clipboardItem in clipboardItems.toDart) {
|
||||
if (clipboardItem.types.toDart.contains('text/html'.toJS)) {
|
||||
final blob = await clipboardItem.getType('text/html').toDart;
|
||||
return _readBlobAsText(blob);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List?> get image async {
|
||||
try {
|
||||
final clipboardItems = await _clipboard.read().toDart;
|
||||
for (var item in clipboardItems.toDart) {
|
||||
for (var type in item.types.toDart) {
|
||||
if (type.toDart.startsWith('image/')) {
|
||||
final blob = await item.getType(type.toDart).toDart;
|
||||
return blob._readAsUint8List();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error, stacktrace) {
|
||||
debugPrint('get image failed: $error $stacktrace');
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> writeFiles(List<String> files) async => false;
|
||||
|
||||
@override
|
||||
Future<void> writeImage(Uint8List? image) async {
|
||||
if (image == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final blob = Blob([image.toJS].toJS, BlobPropertyBag(type: 'image/png'));
|
||||
window.navigator.clipboard.write([
|
||||
ClipboardItem({'image/png': blob}.jsify()! as JSObject)
|
||||
].toJS);
|
||||
} catch (e) {
|
||||
debugPrint('Error writing image to clipboard: $e');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> get text async {
|
||||
final data = await _clipboard.readText().toDart;
|
||||
return data.toDart;
|
||||
}
|
||||
|
||||
@override
|
||||
void writeText(String value) {
|
||||
final fakeElement = _createCopyFakeElement(value)..select();
|
||||
document.body?.append(fakeElement);
|
||||
_select(fakeElement);
|
||||
_copyCommand();
|
||||
fakeElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
void _select(HTMLTextAreaElement element) {
|
||||
final isReadOnly = element.hasAttribute('readonly');
|
||||
|
||||
if (!isReadOnly) {
|
||||
element.setAttribute('readonly', '');
|
||||
}
|
||||
|
||||
element
|
||||
..select()
|
||||
..setSelectionRange(0, element.value.length);
|
||||
|
||||
if (!isReadOnly) {
|
||||
element.removeAttribute('readonly');
|
||||
}
|
||||
}
|
||||
|
||||
/// https://github.com/zenorocha/clipboard.js/blob/master/src/common/create-fake-element.js
|
||||
HTMLTextAreaElement _createCopyFakeElement(String value) {
|
||||
final isRtl = document.documentElement?.getAttribute('dir') == 'rtl';
|
||||
final fakeElement = HTMLTextAreaElement()
|
||||
// Prevent zooming on iOS
|
||||
..style.fontSize = '12pt'
|
||||
// Reset box model
|
||||
..style.border = '0'
|
||||
..style.padding = '0'
|
||||
// Move element out of screen horizontally
|
||||
..style.position = 'absolute'
|
||||
..style.setProperty(isRtl ? 'right' : 'left', '-9999px');
|
||||
|
||||
// Move element to the same position vertically
|
||||
final yPosition = document.documentElement?.scrollTop.toInt() ?? 0;
|
||||
fakeElement
|
||||
..style.top = '${yPosition}px'
|
||||
..setAttribute('readonly', '')
|
||||
..value = value;
|
||||
return fakeElement;
|
||||
}
|
||||
|
||||
bool _copyCommand() {
|
||||
try {
|
||||
return document.execCommand('copy');
|
||||
} catch (error, stack) {
|
||||
window.alert('$error, $stack');
|
||||
// ignore
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Vendored
+85
@@ -0,0 +1,85 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:fox_utils/vendor/src/drop_item.dart';
|
||||
|
||||
class WebDropItem {
|
||||
WebDropItem({
|
||||
required this.uri,
|
||||
required this.children,
|
||||
this.data,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.size,
|
||||
required this.relativePath,
|
||||
required this.lastModified,
|
||||
});
|
||||
|
||||
final String uri;
|
||||
final List<WebDropItem> children;
|
||||
final Uint8List? data;
|
||||
final String name;
|
||||
final String type;
|
||||
final int size;
|
||||
final String? relativePath;
|
||||
final DateTime lastModified;
|
||||
|
||||
factory WebDropItem.fromJson(Map<String, dynamic> json) => WebDropItem(
|
||||
uri: json['uri'],
|
||||
children: json['children'] == null
|
||||
? []
|
||||
: (json['children'] as List)
|
||||
.cast<Map>()
|
||||
.map((e) => WebDropItem.fromJson(e.cast<String, dynamic>()))
|
||||
.toList(),
|
||||
data: json['data'] != null ? Uint8List.fromList(json['data']) : null,
|
||||
name: json['name'],
|
||||
type: json['type'],
|
||||
size: json['size'],
|
||||
relativePath: json['relativePath'],
|
||||
lastModified: DateTime.fromMillisecondsSinceEpoch(json['lastModified']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'uri': uri,
|
||||
'children': children.map((e) => e.toJson()).toList(),
|
||||
'data': data?.toList(),
|
||||
'name': name,
|
||||
'type': type,
|
||||
'size': size,
|
||||
'relativePath': relativePath,
|
||||
'lastModified': lastModified.millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
DropItem toDropItem() {
|
||||
if (type == 'directory') {
|
||||
return DropItemDirectory(
|
||||
uri,
|
||||
children.map((e) => e.toDropItem()).toList(),
|
||||
name: name,
|
||||
mimeType: type,
|
||||
length: size,
|
||||
lastModified: lastModified,
|
||||
bytes: data,
|
||||
);
|
||||
} else {
|
||||
if (data != null) {
|
||||
return DropItemFile.fromData(
|
||||
data!,
|
||||
name: name,
|
||||
mimeType: type,
|
||||
length: size,
|
||||
lastModified: lastModified,
|
||||
path: uri,
|
||||
);
|
||||
}
|
||||
return DropItemFile(
|
||||
uri,
|
||||
name: name,
|
||||
mimeType: type,
|
||||
length: size,
|
||||
lastModified: lastModified,
|
||||
bytes: data,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user