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

Executable
+31
View File
@@ -0,0 +1,31 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins-dependencies
/build/
/coverage/
Executable
+10
View File
@@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
channel: "stable"
project_type: package
Executable
+41
View File
@@ -0,0 +1,41 @@
## 0.1.7
* Add `DesktopDropX` + `DropTarget` — OS-level drag & drop of files and
images into the app, powered by a vendored desktop_drop engine (v0.7.1,
Apache-2.0): GTK uri-list drag destination on Linux, OLE `IDropTarget`
(`CF_HDROP`) on Windows, dead code on mobile and web. `DesktopDropX`
provides `pathsOf` (dropped file paths) and `readImage` (magic-byte
image sniffing, same table as `ClipboardX.pasteImage`).
* The Linux/Windows plugin entry is now a combined `FoxUtilsPlugin`
registration that wires the vendored pasteboard and desktop_drop engines
from one native entry per platform (the registered plugin class changed
from `PasteboardPlugin` to `FoxUtilsPlugin` — generated registrants
regenerate on the next build).
* New dependencies: `cross_file` (drop items are `XFile`s) and
`universal_platform` (web-safe platform checks), both required by the
vendored engine.
## 0.1.6
* Add `ClipboardX.pasteImage` — reads an image from the clipboard whether it
was copied as image data (browser) or as a file reference (file manager),
on Windows, Linux and Android. Adds a `readFileBytes` channel method for
resolving Android content URIs.
## 0.1.5
* Add `ClipboardX` — clipboard image/files/text copy via a vendored
pasteboard engine (MixinNetwork, Apache-2.0) with modernized builds:
the Android channel is Java (no KGP), the FileProvider is merged from
the plugin manifest (zero per-project setup), image writes are byte-faithful
with magic-byte MIME sniffing, and two upstream bugs are fixed.
## 0.1.4
* Add `AndroidFiles.save` — permission-free file saving to public Android
directories (Pictures/Movies/Music/Download) with an optional media scan.
Android platform channel, no storage permissions required.
## 0.0.1
* TODO: Describe initial release.
Executable
+1
View File
@@ -0,0 +1 @@
TODO: Add your license here.
Executable
+183
View File
@@ -0,0 +1,183 @@
# fox_utils
Reusable Flutter utilities shared across projects: permission-free Android
file saving, a cross-platform clipboard wrapper, and desktop drag & drop.
## Features
### `AndroidFiles.save` — save files to public Android storage
Writes any bytes to a public Android directory (`Pictures`, `Movies`,
`Music`, `Download`) **without any storage permission**, with an optional
media scan so the file shows up in gallery apps.
```dart
final path = await AndroidFiles.save(
bytes, // any file type
fileName: 'image.jpg',
location: AndroidPublicLocation.pictures, // pictures | movies | music | downloads
subPath: 'Imagen', // folder structure under the location
scan: true, // false = saved but not indexed (no gallery)
);
```
Notes:
- Requires **no permissions** on Android 10+ (API 29+): creating new files
inside media collections is permission-free. Legacy devices (< API 29) may
fail — the exception carries the native message.
- `scan: false` writes the file without indexing it. On modern Android the
file can still be picked up by a later system rescan; for a hard
"never in gallery" guarantee, save outside a media collection or add a
`.nomedia` file to the folder.
- Throws `FileSaveException` on failure (logged with stack trace).
### `ClipboardX` — clipboard copy
```dart
await ClipboardX.copyImage(bytes); // image bytes as-is, MIME sniffed
await ClipboardX.copyFiles(['/path/a.png']); // desktop: files
ClipboardX.copyText('hello'); // text (Flutter built-in)
final text = await ClipboardX.readText();
final image = await ClipboardX.readImage();
final files = await ClipboardX.readFiles();
```
Platform matrix:
| Operation | Android | Windows | Linux | macOS | iOS |
|---|---|---|---|---|---|
| copy image | ✓ (FileProvider) | ✓ (GDI+) | ✓ (file reference) | ✓ | ✓ |
| copy files | – | ✓ (CF_HDROP) | ✓ (GNOME) | ✓ | – |
| copy/read text | ✓ | ✓ | ✓ | ✓ | ✓ |
| read image | ✓ | ✓ | ✓ | ✓ | ✓ |
| read files | ✓ (URIs) | ✓ | ✓ | ✓ | – |
Notes:
- Image bytes are placed **as-is** (no re-encode); the extension is sniffed
from magic bytes (PNG/JPEG/GIF/WebP/BMP), so animated GIFs survive.
- **Linux cannot hold raw image bytes** on the clipboard (GTK limitation), so
`copyImage` stages the bytes to a rotating temp file and copies it as a
file reference — pasted apps receive the image file. The staging file is
replaced on each copy.
- **Android requires no manifest setup**: the FileProvider is declared in
this plugin's manifest and merged automatically (`${applicationId}.provider`).
Images are staged in the app cache dir.
- Throws `ClipboardCopyException` on failure.
### `ClipboardX.pasteImage` — paste an image, whatever the source
```dart
final Uint8List? image = await ClipboardX.pasteImage();
if (image != null) {
// send to server, display, whatever
}
```
Resolution order: image data on the clipboard first (browser "copy image"),
then the first file reference (file manager "copy"). The file's first bytes
are sniffed (PNG/JPEG/GIF/WebP/BMP) before the full read, so huge non-image
files are never loaded. Returns `null` when the clipboard holds no image.
Source matrix:
| Copied from | Windows | Linux | Android |
|---|---|---|---|
| Browser, "copy image" | CF_DIB → image bytes | `image/png` → image bytes | content URI (image mime) → image bytes |
| File manager, "copy" | CF_HDROP → first file's bytes | `text/uri-list` → first file's bytes | content URI → resolved bytes |
Android notes:
- Reading the clipboard works **only while the app is in the foreground**
(Android 10+); background reads return `null`.
- Android 12+ shows a system toast ("app pasted from your clipboard") on
read — cosmetic.
- File references come back as `content://` URIs; `pasteImage` resolves them
through the platform channel (`readFileBytes`), so no manifest setup or
permission is needed.
### `DesktopDropX` + `DropTarget` — drag & drop files and images
OS-level drag & drop into the app (desktop only). Wrap any widget in
`DropTarget` to get drag enter/exit/update/drop callbacks; `DesktopDropX`
turns the dropped items into plain paths or image bytes:
```dart
DropTarget(
onDragEntered: (d) => setState(() => dragging = true),
onDragExited: (d) => setState(() => dragging = false),
onDragDone: (details) async {
final paths = DesktopDropX.pathsOf(details.files); // absolute paths
final Uint8List? image = await DesktopDropX.readImage(details.files);
// image == null when the drop contains no readable image
},
child: myWidget,
)
```
Platform notes:
- **Linux**: GTK drag destination on the whole window (`text/uri-list` from
GNOME/KDE file managers, browsers, …). Items are absolute paths, delivered
in the order dropped.
- **Windows**: OLE `IDropTarget` on the window (`CF_HDROP` from Explorer,
browsers, …). Same path semantics; `DropDoneDetails.localPosition` is
already DPI-corrected.
- **Android/iOS/web**: no native handler — the widget renders its child and
no drop events ever arrive. **macOS** is not vendored (no macOS target);
re-adding it is a copy of upstream's `macos/` folder behind the same API.
- `DropItem` extends `XFile`: `item.path`, `item.name`, `item.readAsBytes()`,
`item.openRead()`, … are available directly.
- Drop targets receive events for the **whole window**; an invisible
`DropTarget` keeps receiving them, so set `enable: false` when pushing a
page over it. Coordinate-based `DropTarget` bounds filtering is handled by
the widget.
- `readImage` never throws: unreadable files are logged with a stack trace
and skipped; `null` means "no image in this drop".
## Vendored engines
The clipboard channels (`pasteboard` method channel) are **vendored from
MixinNetwork's `pasteboard` package (v0.5.0, Apache-2.0)** because upstream's
build applies the legacy Kotlin Gradle Plugin (KGP), which future Flutter
versions will reject. The vendored copy lives in `lib/vendor/` (Dart) and the
native platform folders, with these changes:
- **Android**: the Kotlin plugin was ported to Java and merged into
`FoxUtilsPlugin` (one plugin class, no KGP, no Kotlin in the module).
- **Byte-faithful image writes**: upstream decodes and re-encodes images as
PNG (destroying GIF animation and metadata); ours writes the original bytes
with magic-byte MIME sniffing.
- **Merged FileProvider**: declared in the plugin manifest — zero per-project
setup (upstream requires a manual manifest entry per app).
- **Two upstream bugs fixed**: clipboard image reads ran synchronously on the
calling thread (now on the executor), and `writeFiles` dropped the first
clip item (now assembles correctly).
- **Linux/Windows**: C++ sources byte-identical, include paths and CMake
targets renamed to the `fox_utils` package name.
To diff against upstream: `git diff` the folders against
`packages/pasteboard/` at tag `v0.5.0` of
`https://github.com/MixinNetwork/flutter-plugins`. If upstream ever
modernizes their build, un-vendoring is a swap behind the same `ClipboardX`
API.
The drag & drop channels (`desktop_drop` method channel) are **vendored from
MixinNetwork's `desktop_drop` package (v0.7.1, Apache-2.0)** for the same
reason: its `android/` folder applies KGP. Only the Dart sources and the
Linux/Windows native folders are vendored — the Android/Kotlin, macOS and
web bindings are omitted (Android DnD is out of scope, and there is no macOS
target in the app). Linux/Windows native sources are byte-identical to
upstream except the include path rename (`include/fox_utils/`). Both engines
are registered from one native entry per platform:
`fox_utils_plugin_register_with_registrar` (Linux) /
`FoxUtilsPluginRegisterWithRegistrar` (Windows) — the plugin class in
`pubspec.yaml` is no longer `PasteboardPlugin`. To diff against upstream, use
tag `desktop_drop-v0.7.1` of the same monorepo; un-vendoring is a swap
behind the same `DesktopDropX` API.
## License
MIT for this package's own code; the vendored engines are Apache-2.0
© MixinNetwork (see `lib/vendor/` and the native platform folders).
+13
View File
@@ -0,0 +1,13 @@
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+9
View File
@@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx
+46
View File
@@ -0,0 +1,46 @@
group = "com.foxyz.fox_utils"
version = "0.1.4"
buildscript {
val kotlinVersion = "2.4.0"
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:9.1.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
plugins {
id("com.android.library")
}
android {
namespace = "com.foxyz.fox_utils"
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
minSdk = 24
}
}
dependencies {
// FileProvider for clipboard image sharing (pasteboard channel).
implementation("androidx.core:core:1.16.0")
}
+1
View File
@@ -0,0 +1 @@
rootProject.name = "fox_utils"
+17
View File
@@ -0,0 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.foxyz.fox_utils">
<application>
<!-- Serves clipboard images to other apps (pasteboard clipboard engine).
Authority matches what the plugin computes at runtime:
context.getPackageName() + ".provider". -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,306 @@
package com.foxyz.fox_utils;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.Environment;
import androidx.annotation.NonNull;
import androidx.core.content.FileProvider;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import io.flutter.embedding.engine.plugins.FlutterPlugin;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
/**
* Fox utils Android plugin.
*
* <p>Two channels:
* <ul>
* <li>{@code fox_utils/android_files} — permission-free saving to public
* directories (see AndroidFiles.save).</li>
* <li>{@code pasteboard} — clipboard image/files read/write, ported from
* one.mixin.pasteboard (Apache-2.0) with fixes: byte-faithful image
* writes (no Bitmap re-encode), correct async executor use, and a
* working writeFiles clip assembly.</li>
* </ul>
*/
public class FoxUtilsPlugin implements FlutterPlugin, MethodCallHandler {
private static final String FILES_CHANNEL = "fox_utils/android_files";
private static final String CLIPBOARD_CHANNEL = "pasteboard";
private static final String CLIPBOARD_X_CHANNEL = "fox_utils/clipboard";
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private Context context;
@Override
public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) {
context = binding.getApplicationContext();
new MethodChannel(binding.getBinaryMessenger(), FILES_CHANNEL).setMethodCallHandler(this);
new MethodChannel(binding.getBinaryMessenger(), CLIPBOARD_CHANNEL).setMethodCallHandler(this);
new MethodChannel(binding.getBinaryMessenger(), CLIPBOARD_X_CHANNEL).setMethodCallHandler(this);
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
switch (call.method) {
// --- fox_utils/android_files ---
case "saveFile":
saveFile(call, result);
break;
// --- pasteboard (vendored) ---
case "image":
readImage(result);
break;
case "files":
readFiles(result);
break;
case "html":
readHtml(result);
break;
case "writeFiles":
writeFiles(call, result);
break;
case "writeImage":
writeImage(call, result);
break;
// --- fox_utils/clipboard ---
case "readFileBytes":
readFileBytes(call, result);
break;
default:
result.notImplemented();
}
}
// --- fox_utils/android_files ---
private void saveFile(MethodCall call, Result result) {
final byte[] bytes = call.argument("bytes");
final String fileName = call.argument("fileName");
final String location = call.argument("location");
final String subPath = call.argument("subPath");
final Boolean scan = call.argument("scan");
if (bytes == null || fileName == null || location == null || subPath == null || scan == null) {
result.error("INVALID_ARGUMENTS", "saveFile requires bytes, fileName, location, subPath and scan.", null);
return;
}
executor.execute(() -> {
try {
result.success(saveFileSync(bytes, fileName, location, subPath, scan));
} catch (Exception e) {
result.error("SAVE_FAILED", e.getMessage(), null);
}
});
}
@SuppressWarnings("deprecation") // Still works permission-free on modern Android.
private String saveFileSync(byte[] bytes, String fileName, String location, String subPath, boolean scan)
throws IOException {
File base = new File(Environment.getExternalStoragePublicDirectory(directoryFor(location)), subPath);
if (!base.exists() && !base.mkdirs()) {
throw new IOException("Could not create directory " + base);
}
File file = new File(base, fileName);
try (FileOutputStream out = new FileOutputStream(file)) {
out.write(bytes);
}
if (scan) {
MediaScannerConnection.scanFile(context, new String[]{file.getAbsolutePath()}, null, null);
}
return file.getAbsolutePath();
}
private static String directoryFor(String location) {
switch (location) {
case "movies":
return Environment.DIRECTORY_MOVIES;
case "music":
return Environment.DIRECTORY_MUSIC;
case "downloads":
return Environment.DIRECTORY_DOWNLOADS;
case "pictures":
default:
return Environment.DIRECTORY_PICTURES;
}
}
// --- pasteboard channel (vendored from one.mixin.pasteboard, Apache-2.0) ---
private ClipboardManager clipboard() {
return (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
}
private void readImage(Result result) {
executor.execute(() -> {
try {
ClipData.Item first = firstClipItem();
if (first == null || first.getUri() == null) {
result.success(null);
return;
}
Uri uri = first.getUri();
String mime = context.getContentResolver().getType(uri);
if (mime == null || !mime.startsWith("image")) {
result.success(null);
return;
}
try (InputStream in = context.getContentResolver().openInputStream(uri)) {
if (in == null) {
result.success(null);
return;
}
result.success(readAll(in));
}
} catch (Exception e) {
result.error("Error", "Failed to read image", e.getMessage());
}
});
}
private void readFiles(Result result) {
ClipData clip = clipboard().getPrimaryClip();
if (clip == null || clip.getItemCount() == 0) {
result.success(null);
return;
}
List<String> files = new ArrayList<>();
for (int i = 0; i < clip.getItemCount(); i++) {
Uri uri = clip.getItemAt(i).getUri();
if (uri != null) {
files.add(uri.toString());
}
}
result.success(files);
}
private void readHtml(Result result) {
ClipData.Item first = firstClipItem();
result.success(first == null ? null : first.getHtmlText());
}
private void writeFiles(MethodCall call, Result result) {
@SuppressWarnings("unchecked")
List<String> args = call.arguments();
if (args == null) {
result.error("NoArgs", "Missing Arguments", null);
return;
}
ClipboardManager manager = clipboard();
ClipData clip = null;
for (String path : args) {
Uri uri = Uri.parse(path);
if (clip == null) {
clip = ClipData.newUri(context.getContentResolver(), "files", uri);
} else {
clip.addItem(new ClipData.Item(uri));
}
}
if (clip != null) {
manager.setPrimaryClip(clip);
}
result.success(null);
}
private void writeImage(MethodCall call, Result result) {
byte[] image = call.arguments();
if (image == null) {
result.error("NoArgs", "Missing Arguments", null);
return;
}
// Bytes are staged as-is (no re-encode); the extension is sniffed from
// the magic bytes so receivers get the correct MIME type — animated
// GIFs and WebP survive copy/paste this way.
final File file = new File(context.getCacheDir(),
UUID.randomUUID().toString() + extensionFor(image));
executor.execute(() -> {
try {
try (FileOutputStream out = new FileOutputStream(file)) {
out.write(image);
}
Uri uri = FileProvider.getUriForFile(context, context.getPackageName() + ".provider", file);
clipboard().setPrimaryClip(ClipData.newUri(context.getContentResolver(), "image", uri));
result.success(null);
} catch (Exception e) {
result.error("Error", "Failed to write image", e.getMessage());
}
});
}
private ClipData.Item firstClipItem() {
ClipData clip = clipboard().getPrimaryClip();
return clip == null || clip.getItemCount() == 0 ? null : clip.getItemAt(0);
}
/** Resolves a content URI (e.g. a file manager's clipboard entry) to bytes. */
private void readFileBytes(MethodCall call, Result result) {
String uri = call.arguments();
if (uri == null) {
result.error("NoArgs", "Missing Arguments", null);
return;
}
executor.execute(() -> {
try (InputStream in = context.getContentResolver().openInputStream(Uri.parse(uri))) {
if (in == null) {
result.success(null);
return;
}
result.success(readAll(in));
} catch (Exception e) {
result.error("Error", "Failed to read file", e.getMessage());
}
});
}
/** Sniffs common image magic bytes so the pasted file carries the right MIME type. */
private static String extensionFor(byte[] b) {
if (b.length >= 3 && (b[0] & 0xFF) == 0xFF && (b[1] & 0xFF) == 0xD8 && (b[2] & 0xFF) == 0xFF) {
return ".jpg";
}
if (b.length >= 8 && (b[0] & 0xFF) == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47) {
return ".png";
}
if (b.length >= 6 && (b[0] & 0xFF) == 0x47 && b[1] == 0x49 && b[2] == 0x46 && b[3] == 0x38) {
return ".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 ".webp";
}
if (b.length >= 2 && b[0] == 'B' && b[1] == 'M') {
return ".bmp";
}
return ".png";
}
private static byte[] readAll(InputStream in) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
return out.toByteArray();
}
@Override
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
context = null;
executor.shutdown();
}
}
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Clipboard images are staged in the app cache dir. -->
<cache-path name="cache" path="." />
</paths>
+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,
);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
cmake_minimum_required(VERSION 3.10)
set(PROJECT_NAME "fox_utils")
project(${PROJECT_NAME} LANGUAGES CXX)
# This value is used when generating builds using this plugin, so it must
# not be changed
set(PLUGIN_NAME "fox_utils_plugin")
add_library(${PLUGIN_NAME} SHARED
"fox_utils_plugin.cc"
"pasteboard_plugin.cc"
"desktop_drop_plugin.cc"
)
apply_standard_settings(${PLUGIN_NAME})
set_target_properties(${PLUGIN_NAME} PROPERTIES
CXX_VISIBILITY_PRESET hidden)
target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL)
target_include_directories(${PLUGIN_NAME} INTERFACE
"${CMAKE_CURRENT_SOURCE_DIR}/include")
target_link_libraries(${PLUGIN_NAME} PRIVATE flutter)
target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK)
# List of absolute paths to libraries that should be bundled with the plugin
set(fox_utils_bundled_libraries
""
PARENT_SCOPE
)
+123
View File
@@ -0,0 +1,123 @@
#include "include/fox_utils/desktop_drop_plugin.h"
#include <flutter_linux/flutter_linux.h>
#include <gtk/gtk.h>
#include <stdlib.h>
#include <sys/utsname.h>
#define DESKTOP_DROP_PLUGIN(obj) \
(G_TYPE_CHECK_INSTANCE_CAST((obj), desktop_drop_plugin_get_type(), \
DesktopDropPlugin))
struct _DesktopDropPlugin {
GObject parent_instance;
};
static gboolean isKDE = FALSE;
static gboolean ignoreNext = FALSE;
G_DEFINE_TYPE(DesktopDropPlugin, desktop_drop_plugin, g_object_get_type())
void on_drag_data_received(GtkWidget *widget, GdkDragContext *drag_context,
gint x, gint y, GtkSelectionData *sdata, guint info,
guint time, gpointer user_data) {
auto *channel = static_cast<FlMethodChannel *>(user_data);
auto *data = gtk_selection_data_get_data(sdata);
double point[] = {double(x), double(y)};
auto args = fl_value_new_list();
fl_value_append(args, fl_value_new_string((gchar *) data));
fl_value_append(args, fl_value_new_float_list(point, 2));
fl_method_channel_invoke_method(channel, "performOperation_linux", args,
nullptr, nullptr, nullptr);
}
void on_drag_motion(GtkWidget *widget, GdkDragContext *drag_context,
gint x, gint y, guint time, gpointer user_data) {
if (ignoreNext) {
ignoreNext = FALSE;
return;
}
auto *channel = static_cast<FlMethodChannel *>(user_data);
double point[] = {double(x), double(y)};
g_autoptr(FlValue) args = fl_value_new_float_list(point, 2);
fl_method_channel_invoke_method(channel, "updated", args,
nullptr, nullptr, nullptr);
}
void on_drag_leave(GtkWidget *widget, GdkDragContext *drag_context, guint time, gpointer user_data) {
auto *channel = static_cast<FlMethodChannel *>(user_data);
fl_method_channel_invoke_method(channel, "exited", nullptr,
nullptr, nullptr, nullptr);
}
// Called when a method call is received from Flutter.
static void desktop_drop_plugin_handle_method_call(
DesktopDropPlugin *self,
FlMethodCall *method_call) {
fl_method_call_respond_not_implemented(method_call, nullptr);
}
static void desktop_drop_plugin_dispose(GObject *object) {
G_OBJECT_CLASS(desktop_drop_plugin_parent_class)->dispose(object);
}
static void desktop_drop_plugin_class_init(DesktopDropPluginClass *klass) {
G_OBJECT_CLASS(klass)->dispose = desktop_drop_plugin_dispose;
}
static void desktop_drop_plugin_init(DesktopDropPlugin *self) {
const char * desktopEnv = getenv("XDG_CURRENT_DESKTOP");
if (desktopEnv) {
const char * lowercaseDesktopEnv = g_ascii_strdown(desktopEnv, -1);
if (strcmp(lowercaseDesktopEnv, "kde") == 0 || strcmp(lowercaseDesktopEnv, "plasma") == 0) {
isKDE = TRUE;
}
}
}
static gboolean on_focus_in_event(GtkWidget *widget, GdkEventFocus *event, gpointer user_data) {
if (isKDE) {
ignoreNext = TRUE;
}
return FALSE;
}
static void method_call_cb(FlMethodChannel *channel, FlMethodCall *method_call,
gpointer user_data) {
DesktopDropPlugin *plugin = DESKTOP_DROP_PLUGIN(user_data);
desktop_drop_plugin_handle_method_call(plugin, method_call);
}
void desktop_drop_plugin_register_with_registrar(FlPluginRegistrar *registrar) {
DesktopDropPlugin *plugin = DESKTOP_DROP_PLUGIN(
g_object_new(desktop_drop_plugin_get_type(), nullptr));
auto *fl_view = fl_plugin_registrar_get_view(registrar);
static GtkTargetEntry entries[] = {
{strdup("STRING"), GTK_TARGET_OTHER_APP, 0}
};
gtk_drag_dest_set(GTK_WIDGET(fl_view), GTK_DEST_DEFAULT_ALL, entries, 1, GDK_ACTION_COPY);
gtk_drag_dest_add_uri_targets(GTK_WIDGET(fl_view));
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
FlMethodChannel *channel =
fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar),
"desktop_drop",
FL_METHOD_CODEC(codec));
fl_method_channel_set_method_call_handler(channel, method_call_cb,
g_object_ref(plugin),
g_object_unref);
g_signal_connect(fl_view, "drag-motion",
G_CALLBACK(on_drag_motion), channel);
g_signal_connect(GTK_WIDGET(fl_view), "drag-data-received",
G_CALLBACK(on_drag_data_received), channel);
g_signal_connect(GTK_WIDGET(fl_view), "drag-leave",
G_CALLBACK(on_drag_leave), channel);
g_signal_connect(fl_view, "focus-in-event",
G_CALLBACK(on_focus_in_event), nullptr);
g_object_unref(plugin);
}
+8
View File
@@ -0,0 +1,8 @@
#include "include/fox_utils/fox_utils_plugin.h"
#include "include/fox_utils/pasteboard_plugin.h"
#include "include/fox_utils/desktop_drop_plugin.h"
void fox_utils_plugin_register_with_registrar(FlPluginRegistrar* registrar) {
pasteboard_plugin_register_with_registrar(registrar);
desktop_drop_plugin_register_with_registrar(registrar);
}
@@ -0,0 +1,26 @@
#ifndef FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
#define FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
#include <flutter_linux/flutter_linux.h>
G_BEGIN_DECLS
#ifdef FLUTTER_PLUGIN_IMPL
#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default")))
#else
#define FLUTTER_PLUGIN_EXPORT
#endif
typedef struct _DesktopDropPlugin DesktopDropPlugin;
typedef struct {
GObjectClass parent_class;
} DesktopDropPluginClass;
FLUTTER_PLUGIN_EXPORT GType desktop_drop_plugin_get_type();
FLUTTER_PLUGIN_EXPORT void desktop_drop_plugin_register_with_registrar(
FlPluginRegistrar* registrar);
G_END_DECLS
#endif // FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
@@ -0,0 +1,22 @@
#ifndef FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
#define FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
#include <flutter_linux/flutter_linux.h>
G_BEGIN_DECLS
#ifdef FLUTTER_PLUGIN_IMPL
#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default")))
#else
#define FLUTTER_PLUGIN_EXPORT
#endif
// Single native registration entry for the fox_utils package on Linux.
// The generated plugin registrant calls this function; it in turn registers
// every vendored engine in this package (pasteboard, desktop_drop).
FLUTTER_PLUGIN_EXPORT void fox_utils_plugin_register_with_registrar(
FlPluginRegistrar* registrar);
G_END_DECLS
#endif // FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
@@ -0,0 +1,26 @@
#ifndef FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
#define FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
#include <flutter_linux/flutter_linux.h>
G_BEGIN_DECLS
#ifdef FLUTTER_PLUGIN_IMPL
#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default")))
#else
#define FLUTTER_PLUGIN_EXPORT
#endif
typedef struct _PasteboardPlugin PasteboardPlugin;
typedef struct {
GObjectClass parent_class;
} PasteboardPluginClass;
FLUTTER_PLUGIN_EXPORT GType pasteboard_plugin_get_type();
FLUTTER_PLUGIN_EXPORT void pasteboard_plugin_register_with_registrar(
FlPluginRegistrar* registrar);
G_END_DECLS
#endif // FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
+190
View File
@@ -0,0 +1,190 @@
#include "include/fox_utils/pasteboard_plugin.h"
#include <flutter_linux/flutter_linux.h>
#include <gtk/gtk.h>
#include <cstring>
#include <vector>
#define GNOME_COPIED_FILES gdk_atom_intern_static_string ("x-special/gnome-copied-files")
#define PASTEBOARD_PLUGIN(obj) \
(G_TYPE_CHECK_INSTANCE_CAST((obj), pasteboard_plugin_get_type(), \
PasteboardPlugin))
struct _PasteboardPlugin {
GObject parent_instance;
};
G_DEFINE_TYPE(PasteboardPlugin, pasteboard_plugin, g_object_get_type())
static void gtk_clipboard_request_uris_callback(
GtkClipboard *clipboard,
gchar **uris,
gpointer user_data
) {
g_autoptr(FlMethodCall) method_call = static_cast<FlMethodCall *>(user_data);
g_autoptr(FlValue) result = fl_value_new_list();
for (auto uri = uris; uri != nullptr && *uri != nullptr; uri++) {
g_autoptr(GFile) file = g_file_new_for_uri(*uri);
auto *file_path = g_file_get_path(file);
if (file_path) {
fl_value_append(result, fl_value_new_string(file_path));
}
}
fl_method_call_respond_success(method_call, result, nullptr);
}
static void gtk_clipboard_get_file_uri(GtkClipboard *clipboard,
GtkSelectionData *selection_data,
guint info,
gpointer user_data_or_owner) {
auto target = gtk_selection_data_get_target(selection_data);
auto **uris = reinterpret_cast<gchar **>(user_data_or_owner);
if (gtk_targets_include_uri(&target, 1)) {
gtk_selection_data_set_uris(selection_data, reinterpret_cast<gchar **>(user_data_or_owner));
} else if (gtk_targets_include_text(&target, 1)) {
g_autoptr(GString) string = g_string_new(nullptr);
bool should_insert_new_line = false;
for (auto uri = uris; uri != nullptr && *uri != nullptr; uri++) {
if (should_insert_new_line) {
g_string_append_c(string, '\n');
}
g_string_append(string, *uri);
should_insert_new_line = true;
}
gtk_selection_data_set_text(selection_data, string->str, int(string->len));
} else if (target == GNOME_COPIED_FILES) {
g_autoptr(GString) string = g_string_new("copy");
for (auto uri = uris; uri != nullptr && *uri != nullptr; uri++) {
g_string_append(string, "\n");
g_string_append(string, *uri);
}
gtk_selection_data_set(selection_data, target, 8, (const guchar *) string->str, int(string->len));
} else {
g_critical("unsupported action: gdk_atom_name(target) = %s", gdk_atom_name(target));
}
}
static void gtk_clipboard_clear(GtkClipboard *clipboard,
gpointer user_data_or_owner) {
auto **uris = reinterpret_cast<gchar **>(user_data_or_owner);
delete[] uris;
}
static void clipboard_request_image_callback(
GtkClipboard *clipboard,
GdkPixbuf *pixbuf,
gpointer user_data) {
g_autoptr(FlMethodCall) method_call = static_cast<FlMethodCall *>(user_data);
if (!pixbuf) {
fl_method_call_respond_success(method_call, nullptr, nullptr);
return;
}
gchar *buffer = nullptr;
gsize buffer_size = 0;
GError *error = nullptr;
gdk_pixbuf_save_to_buffer(pixbuf, &buffer, &buffer_size, "png", &error, nullptr);
if (error) {
fl_method_call_respond_error(method_call, "0", error->message, nullptr, nullptr);
return;
}
if (!buffer) {
fl_method_call_respond_error(method_call, "0", "failed to get image", nullptr, nullptr);
return;
}
fl_method_call_respond_success(method_call,
fl_value_new_uint8_list(reinterpret_cast<const uint8_t *>(buffer), buffer_size),
nullptr);
}
// Called when a method call is received from Flutter.
static void pasteboard_plugin_handle_method_call(
PasteboardPlugin *self,
FlMethodCall *method_call) {
const gchar *method = fl_method_call_get_name(method_call);
if (strcmp(method, "files") == 0) {
auto *clipboard = gtk_clipboard_get_default(gdk_display_get_default());
gtk_clipboard_request_uris(clipboard, gtk_clipboard_request_uris_callback, g_object_ref(method_call));
} else if (strcmp(method, "writeFiles") == 0) {
auto *clipboard = gtk_clipboard_get_default(gdk_display_get_default());
auto args = fl_method_call_get_args(method_call);
auto size = fl_value_get_length(args);
auto **paths = new gchar *[size + 1];
paths[size] = nullptr;
for (unsigned int i = 0; i < size; ++i) {
auto path = fl_value_get_string(fl_value_get_list_value(args, i));
paths[i] = g_strconcat("file://", path, nullptr);
}
GtkTargetList *target_list = gtk_target_list_new(nullptr, 0);
gtk_target_list_add(target_list, GNOME_COPIED_FILES, 0, 0);
gtk_target_list_add_uri_targets(target_list, 0);
gtk_target_list_add_text_targets(target_list, 0);
int n_targets = 1;
GtkTargetEntry *target_table = gtk_target_table_new_from_list(target_list, &n_targets);
gtk_clipboard_set_with_data(clipboard,
target_table,
n_targets,
gtk_clipboard_get_file_uri,
gtk_clipboard_clear,
paths);
gtk_target_list_unref(target_list);
gtk_target_table_free(target_table, n_targets);
fl_method_call_respond_success(method_call, nullptr, nullptr);
} else if (strcmp(method, "image") == 0) {
auto *clipboard = gtk_clipboard_get_default(gdk_display_get_default());
gtk_clipboard_request_image(clipboard, clipboard_request_image_callback, g_object_ref(method_call));
} else {
fl_method_call_respond_not_implemented(method_call, nullptr);
}
}
static void pasteboard_plugin_dispose(GObject *object) {
G_OBJECT_CLASS(pasteboard_plugin_parent_class)->dispose(object);
}
static void pasteboard_plugin_class_init(PasteboardPluginClass *klass) {
G_OBJECT_CLASS(klass)->dispose = pasteboard_plugin_dispose;
}
static void pasteboard_plugin_init(PasteboardPlugin *self) {}
static void method_call_cb(FlMethodChannel *channel, FlMethodCall *method_call,
gpointer user_data) {
PasteboardPlugin *plugin = PASTEBOARD_PLUGIN(user_data);
pasteboard_plugin_handle_method_call(plugin, method_call);
}
void pasteboard_plugin_register_with_registrar(FlPluginRegistrar *registrar) {
PasteboardPlugin *plugin = PASTEBOARD_PLUGIN(
g_object_new(pasteboard_plugin_get_type(), nullptr));
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
g_autoptr(FlMethodChannel) channel =
fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar),
"pasteboard",
FL_METHOD_CODEC(codec));
fl_method_channel_set_method_call_handler(channel, method_call_cb,
g_object_ref(plugin),
g_object_unref);
g_object_unref(plugin);
}
Executable
+35
View File
@@ -0,0 +1,35 @@
name: fox_utils
description: A collection of reusable Flutter widgets and extensions.
version: 0.1.7
environment:
sdk: ^3.9.2
flutter: ">=1.17.0"
dependencies:
flutter:
sdk: flutter
flutter_web_plugins:
sdk: flutter
cross_file: ^0.3.4+2 # XFile base of DropItem (vendored desktop_drop engine)
dio: # Only non-vendored 3rd-party dependency
path: ^1.9.0
universal_platform: ^1.1.0 # web-safe platform checks (vendored desktop_drop engine)
uuid: ^4.0.0
web: ^1.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
plugin:
platforms:
android:
package: com.foxyz.fox_utils
pluginClass: FoxUtilsPlugin
linux:
pluginClass: FoxUtilsPlugin
windows:
pluginClass: FoxUtilsPlugin
+12
View File
@@ -0,0 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fox_utils/fox_utils.dart';
void main() {
test('adds one to input values', () {
final calculator = Calculator();
expect(calculator.addOne(2), 3);
expect(calculator.addOne(-7), -6);
expect(calculator.addOne(0), 1);
});
}
+36
View File
@@ -0,0 +1,36 @@
cmake_minimum_required(VERSION 3.14)
set(PROJECT_NAME "fox_utils")
project(${PROJECT_NAME} LANGUAGES CXX)
# This value is used when generating builds using this plugin, so it must
# not be changed
set(PLUGIN_NAME "fox_utils_plugin")
add_library(${PLUGIN_NAME} SHARED
"fox_utils_plugin.cpp"
"pasteboard_plugin.cpp"
"desktop_drop_plugin.cpp"
)
apply_standard_settings(${PLUGIN_NAME})
set_target_properties(${PLUGIN_NAME} PROPERTIES
CXX_VISIBILITY_PRESET hidden)
target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL)
target_include_directories(${PLUGIN_NAME} INTERFACE
"${CMAKE_CURRENT_SOURCE_DIR}/include")
target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin)
# GdiPlus doesn't compile with /W4 warning level; strict flags are scoped to
# this package's own sources. The vendored desktop_drop engine is compiled
# as upstream ships it (no warnings-as-errors) since it is not ours to churn.
set_source_files_properties("fox_utils_plugin.cpp" "pasteboard_plugin.cpp"
PROPERTIES COMPILE_OPTIONS
"$<$<CXX_COMPILER_ID:MSVC>:/W3 /WX>;$<$<NOT:$<CXX_COMPILER_ID:MSVC>>:-Wall -Wextra -Wpedantic -Werror>")
set_source_files_properties("desktop_drop_plugin.cpp"
PROPERTIES COMPILE_OPTIONS
"$<$<CXX_COMPILER_ID:MSVC>:/W3>")
# List of absolute paths to libraries that should be bundled with the plugin
set(fox_utils_bundled_libraries
""
PARENT_SCOPE
)
+285
View File
@@ -0,0 +1,285 @@
#include "include/fox_utils/desktop_drop_plugin.h"
#include <windows.h>
#include <flutter/method_channel.h>
#include <flutter/plugin_registrar_windows.h>
#include <flutter/standard_method_codec.h>
#include <memory>
#include <sstream>
#include <algorithm>
namespace {
using FlutterMethodChannel = std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>>;
using namespace std::literals::string_literals;
std::string ws2s(const std::wstring &wstr) {
if (wstr.empty()) {
return {};
}
size_t pos;
size_t begin = 0;
std::string ret;
int size;
pos = wstr.find(static_cast<wchar_t>(0), begin);
while (pos != std::wstring::npos && begin < wstr.length()) {
std::wstring segment = std::wstring(&wstr[begin], pos - begin);
size = WideCharToMultiByte(CP_UTF8,
WC_ERR_INVALID_CHARS,
&segment[0],
(int) segment.size(),
nullptr,
0,
nullptr,
nullptr);
std::string converted = std::string(size, 0);
WideCharToMultiByte(CP_UTF8,
WC_ERR_INVALID_CHARS,
&segment[0],
(int) segment.size(),
&converted[0],
(int) converted.size(),
nullptr,
nullptr);
ret.append(converted);
ret.append({0});
begin = pos + 1;
pos = wstr.find(static_cast<wchar_t>(0), begin);
}
if (begin <= wstr.length()) {
std::wstring segment = std::wstring(&wstr[begin], wstr.length() - begin);
size =
WideCharToMultiByte(CP_UTF8,
WC_ERR_INVALID_CHARS,
&segment[0],
(int) segment.size(),
nullptr,
0,
nullptr,
nullptr);
std::string converted = std::string(size, 0);
WideCharToMultiByte(CP_UTF8,
WC_ERR_INVALID_CHARS,
&segment[0],
(int) segment.size(),
&converted[0],
(int) converted.size(),
nullptr,
nullptr);
ret.append(converted);
}
return ret;
}
class DesktopDropTarget : public IDropTarget {
public:
DesktopDropTarget(FlutterMethodChannel channel, HWND window_handle);
HRESULT DragEnter(IDataObject *pDataObj, DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) override;
HRESULT DragOver(DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) override;
HRESULT DragLeave() override;
HRESULT Drop(IDataObject *pDataObj, DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) override;
HRESULT QueryInterface(const IID &riid, void **ppvObject) override;
ULONG AddRef() override;
ULONG Release() override;
virtual ~DesktopDropTarget();
private:
FlutterMethodChannel channel_;
HWND window_handle_;
LONG ref_count_;
bool need_revoke_ole_initialize_;
};
class DesktopDropPlugin : public flutter::Plugin {
public:
static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar);
explicit DesktopDropPlugin(DesktopDropTarget *target);
~DesktopDropPlugin() override;
private:
DesktopDropTarget *target_;
};
DesktopDropPlugin::DesktopDropPlugin(DesktopDropTarget *target) : target_(target) {
target_->AddRef();
}
DesktopDropPlugin::~DesktopDropPlugin() {
target_->Release();
}
// static
void DesktopDropPlugin::RegisterWithRegistrar(
flutter::PluginRegistrarWindows *registrar) {
auto channel =
std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
registrar->messenger(), "desktop_drop",
&flutter::StandardMethodCodec::GetInstance());
HWND hwnd = nullptr;
if (registrar->GetView()) {
hwnd = registrar->GetView()->GetNativeWindow();
}
if (hwnd == nullptr) {
// no window, no drop.
return;
}
channel->SetMethodCallHandler([](const auto &call, auto result) {
result->NotImplemented();
});
auto drop_target = new DesktopDropTarget(std::move(channel), hwnd);
auto plugin = std::make_unique<DesktopDropPlugin>(drop_target);
registrar->AddPlugin(std::move(plugin));
}
DesktopDropTarget::DesktopDropTarget(FlutterMethodChannel channel, HWND window_handle) : channel_(
std::move(channel)), window_handle_(window_handle), ref_count_(0), need_revoke_ole_initialize_(false) {
auto ret = RegisterDragDrop(window_handle_, this);
if (ret == E_OUTOFMEMORY) {
OleInitialize(nullptr);
ret = RegisterDragDrop(window_handle_, this);
if (ret == 0) {
need_revoke_ole_initialize_ = true;
}
}
if (ret != 0) {
std::cout << "RegisterDragDrop failed: " << ret << std::endl;
}
}
HRESULT DesktopDropTarget::DragEnter(IDataObject *pDataObj, DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) {
POINT point = {pt.x, pt.y};
ScreenToClient(window_handle_, &point);
channel_->InvokeMethod("entered", std::make_unique<flutter::EncodableValue>(
flutter::EncodableList{
flutter::EncodableValue(double(point.x)),
flutter::EncodableValue(double(point.y))
}
));
return 0;
}
HRESULT DesktopDropTarget::DragOver(DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) {
POINT point = {pt.x, pt.y};
ScreenToClient(window_handle_, &point);
channel_->InvokeMethod("updated", std::make_unique<flutter::EncodableValue>(
flutter::EncodableList{
flutter::EncodableValue(double(point.x)),
flutter::EncodableValue(double(point.y))
}
));
return 0;
}
HRESULT DesktopDropTarget::DragLeave() {
channel_->InvokeMethod("exited", std::make_unique<flutter::EncodableValue>());
return 0;
}
HRESULT DesktopDropTarget::Drop(IDataObject *pDataObj, DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) {
flutter::EncodableList list = {};
// construct a FORMATETC object
FORMATETC fmtetc = {CF_HDROP, nullptr, DVASPECT_CONTENT, -1, TYMED_HGLOBAL};
STGMEDIUM stgmed;
// See if the dataobject contains any TEXT stored as a HGLOBAL
if (pDataObj->QueryGetData(&fmtetc) == S_OK) {
// Yippie! the data is there, so go get it!
if (pDataObj->GetData(&fmtetc, &stgmed) == S_OK) {
// we asked for the data as a HGLOBAL, so access it appropriately
PVOID data = GlobalLock(stgmed.hGlobal);
if (data != nullptr) {
auto files = DragQueryFile(reinterpret_cast<HDROP>(data), 0xFFFFFFFF, nullptr, 0);
for (unsigned int i = 0; i < files; ++i) {
TCHAR filename[MAX_PATH];
DragQueryFile(reinterpret_cast<HDROP>(data), i, filename, sizeof(TCHAR) * MAX_PATH);
std::wstring wide(filename);
std::string path = ws2s(wide);
std::cout << "done: " << path << std::endl;
list.push_back(flutter::EncodableValue(path));
}
GlobalUnlock(stgmed.hGlobal);
}
// release the data using the COM API
ReleaseStgMedium(&stgmed);
}
}
channel_->InvokeMethod("performOperation", std::make_unique<flutter::EncodableValue>(list));
return 0;
}
HRESULT DesktopDropTarget::QueryInterface(const IID &iid, void **ppvObject) {
if (iid == IID_IDropTarget || iid == IID_IUnknown) {
AddRef();
*ppvObject = this;
return S_OK;
} else {
*ppvObject = nullptr;
return E_NOINTERFACE;
}
}
ULONG DesktopDropTarget::AddRef() {
return InterlockedIncrement(&ref_count_);
}
ULONG DesktopDropTarget::Release() {
LONG count = InterlockedDecrement(&ref_count_);
if (count == 0) {
delete this;
return 0;
} else {
return count;
}
}
DesktopDropTarget::~DesktopDropTarget() {
RevokeDragDrop(window_handle_);
if (need_revoke_ole_initialize_) {
OleUninitialize();
}
}
} // namespace
void DesktopDropPluginRegisterWithRegistrar(
FlutterDesktopPluginRegistrarRef registrar) {
DesktopDropPlugin::RegisterWithRegistrar(
flutter::PluginRegistrarManager::GetInstance()
->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
}
+9
View File
@@ -0,0 +1,9 @@
#include "include/fox_utils/fox_utils_plugin.h"
#include "include/fox_utils/pasteboard_plugin.h"
#include "include/fox_utils/desktop_drop_plugin.h"
void FoxUtilsPluginRegisterWithRegistrar(
FlutterDesktopPluginRegistrarRef registrar) {
PasteboardPluginRegisterWithRegistrar(registrar);
DesktopDropPluginRegisterWithRegistrar(registrar);
}
@@ -0,0 +1,23 @@
#ifndef FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
#define FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
#include <flutter_plugin_registrar.h>
#ifdef FLUTTER_PLUGIN_IMPL
#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport)
#else
#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport)
#endif
#if defined(__cplusplus)
extern "C" {
#endif
FLUTTER_PLUGIN_EXPORT void DesktopDropPluginRegisterWithRegistrar(
FlutterDesktopPluginRegistrarRef registrar);
#if defined(__cplusplus)
} // extern "C"
#endif
#endif // FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
@@ -0,0 +1,26 @@
#ifndef FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
#define FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
#include <flutter_plugin_registrar.h>
#ifdef FLUTTER_PLUGIN_IMPL
#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport)
#else
#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport)
#endif
#if defined(__cplusplus)
extern "C" {
#endif
// Single native registration entry for the fox_utils package on Windows.
// The generated plugin registrant calls this function; it in turn registers
// every vendored engine in this package (pasteboard, desktop_drop).
FLUTTER_PLUGIN_EXPORT void FoxUtilsPluginRegisterWithRegistrar(
FlutterDesktopPluginRegistrarRef registrar);
#if defined(__cplusplus)
} // extern "C"
#endif
#endif // FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
@@ -0,0 +1,23 @@
#ifndef FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
#define FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
#include <flutter_plugin_registrar.h>
#ifdef FLUTTER_PLUGIN_IMPL
#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport)
#else
#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport)
#endif
#if defined(__cplusplus)
extern "C" {
#endif
FLUTTER_PLUGIN_EXPORT void PasteboardPluginRegisterWithRegistrar(
FlutterDesktopPluginRegistrarRef registrar);
#if defined(__cplusplus)
} // extern "C"
#endif
#endif // FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
+555
View File
@@ -0,0 +1,555 @@
#include "include/fox_utils/pasteboard_plugin.h"
#include <flutter/method_channel.h>
#include <flutter/plugin_registrar_windows.h>
#include <flutter/standard_method_codec.h>
#include <Windows.h>
#include <ShlObj.h>
#include <gdiplus.h>
#include <map>
#include <memory>
#include "strconv.h"
#pragma comment(lib, "GdiPlus")
namespace {
constexpr STGMEDIUM kNullStorageMedium = {TYMED_NULL, nullptr, nullptr};
STGMEDIUM CreateStorageForFileNames(const std::vector<std::string> &filenames) {
// CF_HDROP clipboard format consists of DROPFILES structure, a series of file
// names including the terminating null character and the additional null
// character at the tail to terminate the array.
// For example,
//| DROPFILES | FILENAME 1 | NULL | ... | FILENAME n | NULL | NULL |
// For more details, please refer to
// https://docs.microsoft.com/en-us/windows/desktop/shell/clipboard#cf_hdrop
if (filenames.empty())
return kNullStorageMedium;
const size_t kDropFilesHeaderSizeInBytes = sizeof(DROPFILES);
size_t total_bytes = kDropFilesHeaderSizeInBytes;
for (const auto &filename: filenames) {
// Allocate memory of the filename's length including the null
// character.
total_bytes += (filename.length() + 1) * sizeof(wchar_t);
}
// |data| needs to be terminated by an additional null character.
total_bytes += sizeof(wchar_t);
// GHND combines GMEM_MOVEABLE and GMEM_ZEROINIT, and GMEM_ZEROINIT
// initializes memory contents to zero.
HANDLE hdata = GlobalAlloc(GHND, total_bytes);
auto *drop_files = (DROPFILES *) GlobalLock(hdata);
drop_files->pFiles = sizeof(DROPFILES);
drop_files->fWide = TRUE;
auto *data = reinterpret_cast<wchar_t *>(
reinterpret_cast<BYTE *>(drop_files) + kDropFilesHeaderSizeInBytes);
size_t next_filename_offset = 0;
for (const auto &filename: filenames) {
auto wide_filename = utf8_to_wide(filename);
wcsncpy_s(data + next_filename_offset,
wide_filename.length() + 1,
wide_filename.c_str(),
wide_filename.length() + 1);
// Skip the terminating null character of the filename.
next_filename_offset += wide_filename.length() + 1;
}
STGMEDIUM storage;
storage.tymed = TYMED_HGLOBAL;
storage.hGlobal = hdata;
storage.pUnkForRelease = nullptr;
GlobalUnlock(hdata);
return storage;
}
PBITMAPINFO CreateBitmapInfoStruct(HBITMAP hBmp) {
BITMAP bmp;
PBITMAPINFO pbmi;
WORD cClrBits;
// Retrieve the bitmap color format, width, and height.
GetObject(hBmp, sizeof(BITMAP), (LPSTR) &bmp);
// Convert the color format to a count of bits.
cClrBits = (WORD) (bmp.bmPlanes * bmp.bmBitsPixel);
if (cClrBits == 1)
cClrBits = 1;
else if (cClrBits <= 4)
cClrBits = 4;
else if (cClrBits <= 8)
cClrBits = 8;
else if (cClrBits <= 16)
cClrBits = 16;
else if (cClrBits <= 24)
cClrBits = 24;
else
cClrBits = 32;
// Allocate memory for the BITMAPINFO structure. (This structure
// contains a BITMAPINFOHEADER structure and an array of RGBQUAD
// data structures.)
if (cClrBits < 24)
pbmi = (PBITMAPINFO) LocalAlloc(
LPTR, sizeof(BITMAPINFOHEADER) + sizeof(RGBQUAD) * int(1 << cClrBits));
// There is no RGBQUAD array for these formats: 24-bit-per-pixel or
// 32-bit-per-pixel
else
pbmi = (PBITMAPINFO) LocalAlloc(LPTR, sizeof(BITMAPINFOHEADER));
// Initialize the fields in the BITMAPINFO structure.
pbmi->bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
pbmi->bmiHeader.biWidth = bmp.bmWidth;
pbmi->bmiHeader.biHeight = bmp.bmHeight;
pbmi->bmiHeader.biPlanes = bmp.bmPlanes;
pbmi->bmiHeader.biBitCount = bmp.bmBitsPixel;
if (cClrBits < 24) pbmi->bmiHeader.biClrUsed = (1 << cClrBits);
// If the bitmap is not compressed, set the BI_RGB flag.
pbmi->bmiHeader.biCompression = BI_RGB;
// Compute the number of bytes in the array of color
// indices and store the result in biSizeImage.
// The width must be DWORD aligned unless the bitmap is RLE
// compressed.
pbmi->bmiHeader.biSizeImage =
((pbmi->bmiHeader.biWidth * cClrBits + 31) & ~31) / 8 *
pbmi->bmiHeader.biHeight;
// Set biClrImportant to 0, indicating that all of the
// device colors are important.
pbmi->bmiHeader.biClrImportant = 0;
return pbmi;
}
// The code was taken from this answer: https://stackoverflow.com/a/39201008/2134488
//
bool CopyImageToClipboard(const wchar_t* filename)
{
//initialize Gdiplus once:
Gdiplus::GdiplusStartupInput gdiplusStartupInput;
ULONG_PTR gdiplusToken;
Gdiplus::GdiplusStartup(&gdiplusToken, &gdiplusStartupInput, NULL);
bool result = false;
Gdiplus::Bitmap *gdibmp = Gdiplus::Bitmap::FromFile(filename);
if (gdibmp)
{
HBITMAP hbitmap;
gdibmp->GetHBITMAP(0, &hbitmap);
if (OpenClipboard(NULL))
{
EmptyClipboard();
DIBSECTION ds;
if (GetObject(hbitmap, sizeof(DIBSECTION), &ds))
{
HDC hdc = GetDC(HWND_DESKTOP);
//create compatible bitmap (get DDB from DIB)
HBITMAP hbitmap_ddb = CreateDIBitmap(hdc, &ds.dsBmih, CBM_INIT,
ds.dsBm.bmBits, (BITMAPINFO*)&ds.dsBmih, DIB_RGB_COLORS);
ReleaseDC(HWND_DESKTOP, hdc);
SetClipboardData(CF_BITMAP, hbitmap_ddb);
DeleteObject(hbitmap_ddb);
result = true;
}
CloseClipboard();
}
//cleanup:
DeleteObject(hbitmap);
delete gdibmp;
}
Gdiplus::GdiplusShutdown(gdiplusToken);
return result;
}
void CreateBMPFile(LPCTSTR pszFile, HBITMAP hBMP) {
HANDLE hf; // file handle
BITMAPFILEHEADER hdr; // bitmap file-header
PBITMAPINFOHEADER pbih; // bitmap info-header
LPBYTE lpBits; // memory pointer
DWORD cb; // incremental count of bytes
BYTE *hp; // byte pointer
DWORD dwTmp;
PBITMAPINFO pbi;
HDC hDC;
hDC = CreateCompatibleDC(GetWindowDC(GetDesktopWindow()));
SelectObject(hDC, hBMP);
pbi = CreateBitmapInfoStruct(hBMP);
pbih = (PBITMAPINFOHEADER) pbi;
lpBits = (LPBYTE) GlobalAlloc(GMEM_FIXED, pbih->biSizeImage);
assert(lpBits);
// Retrieve the color table (RGBQUAD array) and the bits
// (array of palette indices) from the DIB.
GetDIBits(hDC, hBMP, 0, (WORD) pbih->biHeight, lpBits, pbi,
DIB_RGB_COLORS);
// Create the .BMP file.
hf = CreateFile(pszFile, GENERIC_READ | GENERIC_WRITE, (DWORD) 0, nullptr,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
assert(hf != INVALID_HANDLE_VALUE);
hdr.bfType = 0x4d42; // 0x42 = "B" 0x4d = "M"
// Compute the size of the entire file.
hdr.bfSize = (DWORD) (sizeof(BITMAPFILEHEADER) + pbih->biSize +
pbih->biClrUsed * sizeof(RGBQUAD) + pbih->biSizeImage);
hdr.bfReserved1 = 0;
hdr.bfReserved2 = 0;
// Compute the offset to the array of color indices.
hdr.bfOffBits = (DWORD) sizeof(BITMAPFILEHEADER) + pbih->biSize +
pbih->biClrUsed * sizeof(RGBQUAD);
// Copy the BITMAPFILEHEADER into the .BMP file.
WriteFile(hf, (LPVOID) &hdr, sizeof(BITMAPFILEHEADER), (LPDWORD) &dwTmp,
nullptr);
// Copy the BITMAPINFOHEADER and RGBQUAD array into the file.
WriteFile(hf, (LPVOID) pbih,
sizeof(BITMAPINFOHEADER) + pbih->biClrUsed * sizeof(RGBQUAD),
(LPDWORD) &dwTmp, (nullptr));
// Copy the array of color indices into the .BMP file.
cb = pbih->biSizeImage;
hp = lpBits;
WriteFile(hf, (LPSTR) hp, (int) cb, (LPDWORD) &dwTmp, nullptr);
// Close the .BMP file.
CloseHandle(hf);
// Free memory.
GlobalFree((HGLOBAL) lpBits);
}
void CreateBitmapHeaderWithColorDepth(LONG width, LONG height, WORD color_depth,
BITMAPINFOHEADER *hdr) {
// These values are shared with gfx::PlatformDevice.
hdr->biSize = sizeof(BITMAPINFOHEADER);
hdr->biWidth = width;
hdr->biHeight = -height; // Minus means top-down bitmap.
hdr->biPlanes = 1;
hdr->biBitCount = color_depth;
hdr->biCompression = BI_RGB; // No compression.
hdr->biSizeImage = 0;
hdr->biXPelsPerMeter = 1;
hdr->biYPelsPerMeter = 1;
hdr->biClrUsed = 0;
hdr->biClrImportant = 0;
}
HBITMAP CreateHBitmapXRGB8888(int width, int height, HANDLE shared_section,
void **data) {
if (width == 0 || height == 0) {
width = 1;
height = 1;
}
BITMAPINFOHEADER hdr = {0};
CreateBitmapHeaderWithColorDepth(width, height, 32, &hdr);
HBITMAP hbitmap =
CreateDIBSection(nullptr, reinterpret_cast<const BITMAPINFO *>(&hdr), 0,
data, shared_section, 0);
return hbitmap;
}
// A scoper to manage acquiring and automatically releasing the clipboard.
class ScopedClipboard {
public:
ScopedClipboard() : opened_(false) {}
~ScopedClipboard() {
if (opened_) Release();
}
bool Acquire(HWND owner) {
const int kMaxAttemptsToOpenClipboard = 5;
if (opened_) {
return false;
}
for (int attempts = 0; attempts < kMaxAttemptsToOpenClipboard; ++attempts) {
if (::OpenClipboard(owner)) {
opened_ = true;
return true;
}
// If we didn't manage to open the clipboard, sleep a bit and be hopeful.
::Sleep(5);
}
// We failed to acquire the clipboard.
return false;
}
void Release() {
if (opened_) {
::CloseClipboard();
opened_ = false;
}
}
private:
bool opened_;
};
class PasteboardPlugin : public flutter::Plugin {
public:
static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar);
PasteboardPlugin();
virtual ~PasteboardPlugin();
private:
// Called when a method is called on this plugin's channel from Dart.
void HandleMethodCall(
const flutter::MethodCall<flutter::EncodableValue> &method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
};
// static
void PasteboardPlugin::RegisterWithRegistrar(
flutter::PluginRegistrarWindows *registrar) {
auto channel =
std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
registrar->messenger(), "pasteboard",
&flutter::StandardMethodCodec::GetInstance());
auto plugin = std::make_unique<PasteboardPlugin>();
channel->SetMethodCallHandler(
[plugin_pointer = plugin.get()](const auto &call, auto result) {
plugin_pointer->HandleMethodCall(call, std::move(result));
});
registrar->AddPlugin(std::move(plugin));
}
PasteboardPlugin::PasteboardPlugin() {}
PasteboardPlugin::~PasteboardPlugin() {}
void PasteboardPlugin::HandleMethodCall(
const flutter::MethodCall<flutter::EncodableValue> &method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
if (method_call.method_name() == "image") {
if (!IsClipboardFormatAvailable(CF_DIB)) {
result->Success();
return;
}
ScopedClipboard clipboard;
if (!clipboard.Acquire(nullptr)) {
result->Error("0", "open clipboard failed");
return;
}
// We use a DIB rather than a DDB here since ::GetObject() with the
// HBITMAP returned from ::GetClipboardData(CF_BITMAP) always reports a
// color depth of 32bpp.
auto *bitmap = static_cast<BITMAPINFO *>(::GetClipboardData(CF_DIB));
if (!bitmap) {
result->Success();
return;
}
int color_table_length = 0;
// For more information on BITMAPINFOHEADER and biBitCount definition,
// see https://docs.microsoft.com/en-us/windows/win32/wmdm/-bitmapinfoheader
switch (bitmap->bmiHeader.biBitCount) {
case 1:
case 4:
case 8:
color_table_length = bitmap->bmiHeader.biClrUsed
? int(bitmap->bmiHeader.biClrUsed)
: int(1 << bitmap->bmiHeader.biBitCount);
break;
case 16:
case 32:
if (bitmap->bmiHeader.biCompression == BI_BITFIELDS)
color_table_length = 3;
break;
case 24:break;
default:result->Success();
return;
}
const void *bitmap_bits = reinterpret_cast<const char *>(bitmap) +
bitmap->bmiHeader.biSize +
color_table_length * sizeof(RGBQUAD);
void *dst_bits;
auto dst_hbitmap =
CreateHBitmapXRGB8888(bitmap->bmiHeader.biWidth,
bitmap->bmiHeader.biHeight, nullptr, &dst_bits);
auto hdc = CreateCompatibleDC(nullptr);
auto old_hbitmap = static_cast<HBITMAP>(SelectObject(hdc, dst_hbitmap));
::SetDIBitsToDevice(
hdc, 0, 0, bitmap->bmiHeader.biWidth, bitmap->bmiHeader.biHeight, 0, 0,
0, bitmap->bmiHeader.biHeight, bitmap_bits, bitmap, DIB_RGB_COLORS);
SelectObject(hdc, old_hbitmap);
DeleteDC(hdc);
TCHAR path[MAX_PATH];
GetTempPath(MAX_PATH, path);
TCHAR name[MAX_PATH];
GetTempFileName(path, L"pasteboard", false, name);
CreateBMPFile(name, dst_hbitmap);
DeleteObject(dst_hbitmap);
result->Success(flutter::EncodableValue(wide_to_utf8(name)));
} else if (method_call.method_name() == "files") {
if (!IsClipboardFormatAvailable(CF_HDROP)) {
result->Success();
return;
}
if (!OpenClipboard(nullptr)) {
result->Error("0", "open clipboard failed");
return;
}
auto handle = GetClipboardData(CF_HDROP);
flutter::EncodableList file_list;
if (handle) {
auto data = reinterpret_cast<HDROP>(GlobalLock(handle));
if (data) {
auto files = DragQueryFile(data, 0xFFFFFFFF, nullptr, 0);
for (unsigned int i = 0; i < files; ++i) {
TCHAR filename[MAX_PATH];
DragQueryFile(data, i, filename, sizeof(TCHAR) * MAX_PATH);
std::wstring wide_filename(filename);
file_list.emplace_back(wide_to_utf8(wide_filename));
}
}
}
CloseClipboard();
result->Success(flutter::EncodableValue(file_list));
} else if (method_call.method_name() == "writeFiles") {
auto *arguments = method_call.arguments();
auto files = std::get_if<std::vector<flutter::EncodableValue>>(arguments);
if (!files) {
result->Error("0", "files is empty");
return;
}
std::vector<std::string> paths;
for (const auto &item: *files) {
if (std::holds_alternative<std::string>(item)) {
paths.push_back(std::get<std::string>(item));
}
}
if (paths.empty()) {
result->Error("0", "files is empty");
return;
}
ScopedClipboard clipboard;
if (!clipboard.Acquire(nullptr)) {
result->Error("0", "failed to open clipboard");
return;
}
auto storage = CreateStorageForFileNames(paths);
if (storage.tymed == TYMED_NULL) {
result->Error("0", "create storage failed");
return;
}
SetClipboardData(CF_HDROP, storage.hGlobal);
result->Success();
}
else if (method_call.method_name() == "writeImage") {
const auto* arguments = std::get_if<flutter::EncodableMap>(method_call.arguments());
std::string fileName;
if (arguments) {
auto it = arguments->find(flutter::EncodableValue("fileName"));
if (it != arguments->end()) {
if (std::holds_alternative<std::string>(it->second)) {
fileName = std::get<std::string>(it->second);
}
}
}
if (fileName.size() == 0) {
result->Error("0", "File name is empty");
return;
}
std::wstring wsFileName = cp_to_wide(fileName, CP_UTF8);
if (!CopyImageToClipboard(wsFileName.c_str())) {
result->Error("0", "Failed to copy image to clipboard");
return;
}
result->Success();
}
else if (method_call.method_name() == "html") {
UINT CF_HTML = RegisterClipboardFormatA("HTML Format");
bool isHTMLFormatAvailable = false;
if (IsClipboardFormatAvailable(CF_HTML)) {
isHTMLFormatAvailable = true;
}
if (!isHTMLFormatAvailable) {
result->Success();
return;
}
if (!OpenClipboard(nullptr)) {
result->Error("0", "open clipboard failed");
return;
}
HANDLE hClipboardData = GetClipboardData(CF_HTML);
if (hClipboardData == NULL) {
result->Success();
CloseClipboard();
return;
}
else {
char* p = (char*)GlobalLock(hClipboardData);
SIZE_T size = GlobalSize(hClipboardData);
std::string str;
str.assign(p, size);
result->Success(flutter::EncodableValue(str));
GlobalUnlock(hClipboardData);
}
CloseClipboard();
}
else {
result->NotImplemented();
}
}
} // namespace
void PasteboardPluginRegisterWithRegistrar(
FlutterDesktopPluginRegistrarRef registrar) {
PasteboardPlugin::RegisterWithRegistrar(
flutter::PluginRegistrarManager::GetInstance()
->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
}
+565
View File
@@ -0,0 +1,565 @@
/*
------------------------------------------------------------------------------
This software is available under 2 licenses -- choose whichever you prefer.
------------------------------------------------------------------------------
ALTERNATIVE A - MIT License
Copyright (c) 2019-2021 JavaCommons
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
------------------------------------------------------------------------------
ALTERNATIVE B - Public Domain (www.unlicense.org)
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
software, either in source code form or as a compiled binary, for any purpose,
commercial or non-commercial, and by any means.
In jurisdictions that recognize copyright laws, the author or authors of this
software dedicate any and all copyright interest in the software to the public
domain. We make this dedication for the benefit of the public at large and to
the detriment of our heirs and successors. We intend this dedication to be an
overt act of relinquishment in perpetuity of all present and future rights to
this software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-----------------------------------------------------------------------------
*/
/* strconv.h v1.8.10 */
/* Last Modified: 2021/08/30 21:53 */
#ifndef STRCONV_H
#define STRCONV_H
#include <windows.h>
#include <string>
#include <vector>
#include <iostream>
#include <sstream>
#if __cplusplus >= 201103L && !defined(STRCONV_CPP98)
static inline std::wstring cp_to_wide(const std::string &s, UINT codepage)
{
int in_length = (int)s.length();
int out_length = MultiByteToWideChar(codepage, 0, s.c_str(), in_length, 0, 0);
std::wstring result(out_length, L'\0');
if (out_length)
MultiByteToWideChar(codepage, 0, s.c_str(), in_length, &result[0], out_length);
return result;
}
static inline std::string wide_to_cp(const std::wstring &s, UINT codepage)
{
int in_length = (int)s.length();
int out_length = WideCharToMultiByte(codepage, 0, s.c_str(), in_length, 0, 0, 0, 0);
std::string result(out_length, '\0');
if (out_length)
WideCharToMultiByte(codepage, 0, s.c_str(), in_length, &result[0], out_length, 0, 0);
return result;
}
#else /* __cplusplus < 201103L */
static inline std::wstring cp_to_wide(const std::string &s, UINT codepage)
{
int in_length = (int)s.length();
int out_length = MultiByteToWideChar(codepage, 0, s.c_str(), in_length, 0, 0);
std::vector<wchar_t> buffer(out_length);
if (out_length)
MultiByteToWideChar(codepage, 0, s.c_str(), in_length, &buffer[0], out_length);
std::wstring result(buffer.begin(), buffer.end());
return result;
}
static inline std::string wide_to_cp(const std::wstring &s, UINT codepage)
{
int in_length = (int)s.length();
int out_length = WideCharToMultiByte(codepage, 0, s.c_str(), in_length, 0, 0, 0, 0);
std::vector<char> buffer(out_length);
if (out_length)
WideCharToMultiByte(codepage, 0, s.c_str(), in_length, &buffer[0], out_length, 0, 0);
std::string result(buffer.begin(), buffer.end());
return result;
}
#endif
static inline std::string cp_to_utf8(const std::string &s, UINT codepage)
{
if (codepage == CP_UTF8)
return s;
std::wstring wide = cp_to_wide(s, codepage);
return wide_to_cp(wide, CP_UTF8);
}
static inline std::string utf8_to_cp(const std::string &s, UINT codepage)
{
if (codepage == CP_UTF8)
return s;
std::wstring wide = cp_to_wide(s, CP_UTF8);
return wide_to_cp(wide, codepage);
}
static inline std::wstring ansi_to_wide(const std::string &s)
{
return cp_to_wide(s, CP_ACP);
}
static inline std::string wide_to_ansi(const std::wstring &s)
{
return wide_to_cp(s, CP_ACP);
}
static inline std::wstring sjis_to_wide(const std::string &s)
{
return cp_to_wide(s, 932);
}
static inline std::string wide_to_sjis(const std::wstring &s)
{
return wide_to_cp(s, 932);
}
static inline std::wstring utf8_to_wide(const std::string &s)
{
return cp_to_wide(s, CP_UTF8);
}
static inline std::string wide_to_utf8(const std::wstring &s)
{
return wide_to_cp(s, CP_UTF8);
}
static inline std::string ansi_to_utf8(const std::string &s)
{
return cp_to_utf8(s, CP_ACP);
}
static inline std::string utf8_to_ansi(const std::string &s)
{
return utf8_to_cp(s, CP_ACP);
}
static inline std::string sjis_to_utf8(const std::string &s)
{
return cp_to_utf8(s, 932);
}
static inline std::string utf8_to_sjis(const std::string &s)
{
return utf8_to_cp(s, 932);
}
#ifdef __cpp_char8_t
static inline std::u8string utf8_to_char8(const std::string &s)
{
return std::u8string(s.begin(), s.end());
}
static inline std::string char8_to_utf8(const std::u8string &s)
{
return std::string(s.begin(), s.end());
}
static inline std::wstring char8_to_wide(const std::u8string &s)
{
return cp_to_wide(char8_to_utf8(s), CP_UTF8);
}
static inline std::u8string wide_to_char8(const std::wstring &s)
{
return utf8_to_char8(wide_to_cp(s, CP_UTF8));
}
static inline std::u8string cp_to_char8(const std::string &s, UINT codepage)
{
return utf8_to_char8(cp_to_utf8(s, codepage));
}
static inline std::string char8_to_cp(const std::u8string &s, UINT codepage)
{
return utf8_to_cp(char8_to_utf8(s), codepage);
}
static inline std::u8string ansi_to_char8(const std::string &s)
{
return cp_to_char8(s, CP_ACP);
}
static inline std::string char8_to_ansi(const std::u8string &s)
{
return char8_to_cp(s, CP_ACP);
}
static inline std::u8string sjis_to_char8(const std::string &s)
{
return cp_to_char8(s, 932);
}
static inline std::string char8_to_sjis(const std::u8string &s)
{
return char8_to_cp(s, 932);
}
#endif
#if defined(_MSC_VER)
#pragma warning(push)
#pragma warning(disable : 4996)
#endif
static inline std::wstring vformat(const wchar_t *format, va_list args)
{
int len = _vsnwprintf(0, 0, format, args);
if (len < 0)
return L"";
std::vector<wchar_t> buffer(len + 1);
len = _vsnwprintf(&buffer[0], len, format, args);
if (len < 0)
return L"";
buffer[len] = L'\0';
return &buffer[0];
}
static inline std::string vformat(const char *format, va_list args)
{
int len = _vsnprintf(0, 0, format, args);
if (len < 0)
return "";
std::vector<char> buffer(len + 1);
len = _vsnprintf(&buffer[0], len, format, args);
if (len < 0)
return "";
buffer[len] = '\0';
return &buffer[0];
}
#ifdef __cpp_char8_t
static inline std::u8string vformat(const char8_t *format, va_list args)
{
int len = _vsnprintf(0, 0, (const char *)format, args);
if (len < 0)
return u8"";
std::vector<char> buffer(len + 1);
len = _vsnprintf(&buffer[0], len, (const char *)format, args);
if (len < 0)
return u8"";
buffer[len] = '\0';
return (char8_t *)&buffer[0];
}
#endif
#if defined(_MSC_VER)
#pragma warning(pop)
#endif
static inline std::wstring format(const wchar_t *format, ...)
{
va_list args;
va_start(args, format);
std::wstring s = vformat(format, args);
va_end(args);
return s;
}
static inline std::string format(const char *format, ...)
{
va_list args;
va_start(args, format);
std::string s = vformat(format, args);
va_end(args);
return s;
}
#ifdef __cpp_char8_t
static inline std::u8string format(const char8_t *format, ...)
{
va_list args;
va_start(args, format);
std::u8string s = vformat(format, args);
va_end(args);
return s;
}
#endif
static inline void format(std::ostream &ostrm, const wchar_t *format, ...)
{
va_list args;
va_start(args, format);
std::wstring s = vformat(format, args);
va_end(args);
ostrm << wide_to_utf8(s) << std::flush;
}
static inline void format(std::ostream &ostrm, const char *format, ...)
{
va_list args;
va_start(args, format);
std::string s = vformat(format, args);
va_end(args);
ostrm << s << std::flush;
}
#ifdef __cpp_char8_t
static inline void format(std::ostream &ostrm, const char8_t *format, ...)
{
va_list args;
va_start(args, format);
std::u8string s = vformat(format, args);
va_end(args);
ostrm << char8_to_utf8(s) << std::flush;
}
#endif
static inline std::string formatA(const wchar_t *format, ...)
{
va_list args;
va_start(args, format);
std::wstring s = vformat(format, args);
va_end(args);
return wide_to_ansi(s);
}
static inline std::string formatA(const char *format, ...)
{
va_list args;
va_start(args, format);
std::string s = vformat(format, args);
va_end(args);
return utf8_to_ansi(s);
}
#ifdef __cpp_char8_t
static inline std::string formatA(const char8_t *format, ...)
{
va_list args;
va_start(args, format);
std::u8string s = vformat(format, args);
va_end(args);
return char8_to_ansi(s);
}
#endif
static inline void formatA(std::ostream &ostrm, const wchar_t *format, ...)
{
va_list args;
va_start(args, format);
std::wstring s = vformat(format, args);
va_end(args);
ostrm << wide_to_ansi(s) << std::flush;
}
static inline void formatA(std::ostream &ostrm, const char *format, ...)
{
va_list args;
va_start(args, format);
std::string s = vformat(format, args);
va_end(args);
ostrm << utf8_to_ansi(s) << std::flush;
}
#ifdef __cpp_char8_t
static inline void formatA(std::ostream &ostrm, const char8_t *format, ...)
{
va_list args;
va_start(args, format);
std::u8string s = vformat(format, args);
va_end(args);
ostrm << char8_to_ansi(s) << std::flush;
}
#endif
static inline void dbgmsg(const wchar_t *title, const wchar_t *format, ...)
{
va_list args;
va_start(args, format);
std::wstring s = vformat(format, args);
va_end(args);
MessageBoxW(0, s.c_str(), title, MB_OK);
}
static inline void dbgmsg(const char *title, const char *format, ...)
{
va_list args;
va_start(args, format);
std::string s = vformat(format, args);
va_end(args);
MessageBoxW(0, utf8_to_wide(s).c_str(), utf8_to_wide(title).c_str(), MB_OK);
}
#ifdef __cpp_char8_t
static inline void dbgmsg(const char8_t *title, const char8_t *format, ...)
{
va_list args;
va_start(args, format);
std::u8string s = vformat(format, args);
va_end(args);
MessageBoxW(0, char8_to_wide(s).c_str(), char8_to_wide(title).c_str(), MB_OK);
}
#endif
static inline HANDLE handle_for_ostream(std::ostream &ostrm)
{
if (&ostrm == &std::cout)
{
return GetStdHandle(STD_OUTPUT_HANDLE);
}
else if (&ostrm == &std::cerr)
{
return GetStdHandle(STD_ERROR_HANDLE);
}
return INVALID_HANDLE_VALUE;
}
static inline void dbgout(std::ostream &ostrm, const wchar_t *format, ...)
{
va_list args;
va_start(args, format);
std::wstring ws = vformat(format, args);
va_end(args);
HANDLE h = handle_for_ostream(ostrm);
if (h == INVALID_HANDLE_VALUE)
{
return;
}
DWORD dwNumberOfCharsWrite;
if (GetFileType(h) != FILE_TYPE_CHAR)
{
std::string s = wide_to_cp(ws, GetConsoleOutputCP());
WriteFile(h, s.c_str(), (DWORD)s.size(), &dwNumberOfCharsWrite, NULL);
}
else
{
WriteConsoleW(h,
ws.c_str(),
(DWORD)ws.size(),
&dwNumberOfCharsWrite,
NULL);
}
}
static inline void dbgout(std::ostream &ostrm, const char *format, ...)
{
va_list args;
va_start(args, format);
std::string s = vformat(format, args);
va_end(args);
HANDLE h = handle_for_ostream(ostrm);
if (h == INVALID_HANDLE_VALUE)
{
return;
}
DWORD dwNumberOfCharsWrite;
if (GetFileType(h) != FILE_TYPE_CHAR)
{
s = utf8_to_cp(s, GetConsoleOutputCP());
WriteFile(h, s.c_str(), (DWORD)s.size(), &dwNumberOfCharsWrite, NULL);
}
else
{
std::wstring ws = utf8_to_wide(s);
WriteConsoleW(h,
ws.c_str(),
(DWORD)ws.size(),
&dwNumberOfCharsWrite,
NULL);
}
}
#ifdef __cpp_char8_t
static inline void dbgout(std::ostream &ostrm, const char8_t *format, ...)
{
va_list args;
va_start(args, format);
std::u8string s = vformat(format, args);
va_end(args);
HANDLE h = handle_for_ostream(ostrm);
if (h == INVALID_HANDLE_VALUE)
{
return;
}
DWORD dwNumberOfCharsWrite;
if (GetFileType(h) != FILE_TYPE_CHAR)
{
std::string str = char8_to_cp(s, GetConsoleOutputCP());
WriteFile(h, (const char *)str.c_str(), (DWORD)str.size(), &dwNumberOfCharsWrite, NULL);
}
else
{
std::wstring ws = char8_to_wide(s);
WriteConsoleW(h,
ws.c_str(),
(DWORD)ws.size(),
&dwNumberOfCharsWrite,
NULL);
}
}
#endif
class unicode_ostream
{
private:
std::ostream *m_ostrm;
UINT m_target_cp;
bool is_ascii(const std::string &s)
{
for (std::size_t i = 0; i < s.size(); i++)
{
unsigned char c = (unsigned char)s[i];
if (c > 0x7f)
return false;
}
return true;
}
public:
unicode_ostream(std::ostream &ostrm, UINT target_cp = CP_ACP) : m_ostrm(&ostrm), m_target_cp(target_cp) {}
std::ostream &stream() { return *m_ostrm; }
void stream(std::ostream &ostrm) { m_ostrm = &ostrm; }
UINT target_cp() { return m_target_cp; }
void target_cp(UINT cp) { m_target_cp = cp; }
template <typename T>
unicode_ostream &operator<<(const T &x)
{
std::ostringstream oss;
oss << x;
std::string output = oss.str();
if (is_ascii(output))
{
(*m_ostrm) << x;
}
else
{
(*m_ostrm) << utf8_to_cp(output, m_target_cp);
}
return *this;
}
unicode_ostream &operator<<(const std::wstring &x)
{
(*m_ostrm) << wide_to_cp(x, m_target_cp);
return *this;
}
unicode_ostream &operator<<(const wchar_t *x)
{
(*m_ostrm) << wide_to_cp(x, m_target_cp);
return *this;
}
unicode_ostream &operator<<(const std::string &x)
{
(*m_ostrm) << utf8_to_cp(x, m_target_cp);
return *this;
}
unicode_ostream &operator<<(const char *x)
{
(*m_ostrm) << utf8_to_cp(x, m_target_cp);
return *this;
}
#ifdef __cpp_char8_t
unicode_ostream &operator<<(const std::u8string &x)
{
(*m_ostrm) << char8_to_cp(x, m_target_cp);
return *this;
}
unicode_ostream &operator<<(const char8_t *x)
{
(*m_ostrm) << char8_to_cp(x, m_target_cp);
return *this;
}
#endif
unicode_ostream &operator<<(std::ostream &(*pf)(std::ostream &)) // For manipulators...
{
(*m_ostrm) << pf;
return *this;
}
unicode_ostream &operator<<(std::basic_ios<char> &(*pf)(std::basic_ios<char> &)) // For manipulators...
{
(*m_ostrm) << pf;
return *this;
}
};
#define U8(X) ((const char *)u8##X)
#define WIDE(X) (L##X)
#endif /* STRCONV_H */