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:
FoxyzandClaude Opus 5.5 committed 2026-10-09 06:31:15 +03:00
commit dc16bba054
46 files changed
+4738

No files matched your search

+69
View File
@@ -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);
}
}
}
+152
View File
@@ -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;
}
}
+149
View File
@@ -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;
}
}
+211
View File
@@ -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 '⛔';
}
}
+115
View File
@@ -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,
),
],
),
),
],
);
}
}
+9
View File
@@ -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;
}
+420
View File
@@ -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,
);
}
}
+3
View File
@@ -0,0 +1,3 @@
export 'src/drop_target.dart';
export 'src/drop_item.dart';
export 'src/channel.dart';
+44
View File
@@ -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);
}
+190
View File
@@ -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);
}
}
+104
View File
@@ -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();
}
+220
View File
@@ -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;
}
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+85
View File
@@ -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,
);
}
}
}