Files
ux/lib/src/file.dart
2026-05-05 23:37:34 +03:00

232 lines
8.3 KiB
Dart

import 'dart:io' show Platform;
import 'dart:typed_data';
import 'dart:ui' show Rect;
import 'package:flutter/services.dart';
/// A single frame extracted from a video file. [pngBytes] is the encoded
/// PNG ready to embed in a thumbnail proto / paint via `Image.memory`;
/// [width] / [height] describe the encoded image, which may be smaller
/// than the source video due to the `maxWidth` constraint at extraction.
class UxVideoThumbnail {
const UxVideoThumbnail({
required this.pngBytes,
required this.width,
required this.height,
});
final Uint8List pngBytes;
final int width;
final int height;
}
/// A file the user picked. [path] is on local disk and readable by
/// `dart:io File` while the picker session's grant is active.
///
/// On macOS / iOS the picker returns the user's original location (no
/// temp-dir copy); access across cold restarts is preserved by storing
/// [bookmark] and re-acquiring scope via [UxFile.withScopedAccess] /
/// [UxFile.open] / [UxFile.showInFolder]. On Android the native side
/// stream-copies a `content://` source into the app cache (since
/// `dart:io` can't open content URIs); [bookmark] holds the source URI
/// as UTF-8 bytes for symmetry but isn't required for reads.
class UxPickedFile {
const UxPickedFile({
required this.path,
this.name,
this.mimeType,
this.size,
this.bookmark,
});
final String path;
final String? name;
final String? mimeType;
final int? size;
/// macOS / iOS scoped bookmark, or Android URI bytes. Persist alongside
/// [path] so future opens can re-acquire access. Null on platforms that
/// don't need it (or when the platform supplied no bookmark).
final Uint8List? bookmark;
}
class UxFile {
UxFile._();
static const _channel = MethodChannel('ux/file');
/// Present the native share sheet for a file on disk.
///
/// [sourceRect] is the originating widget's rect in Flutter logical
/// pixels (global coordinates). Required on iPad and macOS (anchor for
/// the popover); ignored on iPhone and Android. Pass null on iPad/macOS
/// to center the popover — typical fallback when the originating widget
/// has been disposed by the time bytes are ready.
///
/// [mimeType] hints `Intent.setType` on Android. Ignored on Apple
/// platforms (the share sheet infers from the file extension).
///
/// [title] populates the subject hint (Mail subject on iOS/macOS,
/// chooser title on Android).
///
/// Returns true if the sheet was presented. Returns false if the host
/// couldn't present it (no activity on Android, no window on macOS).
/// Run [body] with scoped access to a file. On macOS / iOS, when
/// [bookmark] is non-null, the plugin resolves the bookmark into a
/// security-scoped URL, starts access, hands the resolved path to
/// [body], and stops access in `finally`. On all other platforms (and
/// when [bookmark] is null) [body] is invoked with [path] directly.
///
/// The plugin maintains a per-path begin counter so nested calls are
/// safe. Access is always released in `finally`, including on errors.
static Future<T> withScopedAccess<T>(
String path,
Uint8List? bookmark,
Future<T> Function(String path) body,
) async {
if (bookmark == null || !(Platform.isMacOS || Platform.isIOS)) {
return body(path);
}
final result = await _channel.invokeMapMethod<String, Object?>(
'beginScopedAccess',
{'bookmark': bookmark},
);
final resolvedPath = result?['path'] as String? ?? path;
try {
return await body(resolvedPath);
} finally {
await _channel.invokeMethod<void>('endScopedAccess', {'path': resolvedPath});
}
}
static Future<bool> share({
required String path,
String? title,
String? mimeType,
Rect? sourceRect,
}) async {
final result = await _channel.invokeMethod<bool>('share', {
'path': path,
if (title != null) 'title': title,
if (mimeType != null) 'mimeType': mimeType,
if (sourceRect != null)
'sourceRect': {
'x': sourceRect.left,
'y': sourceRect.top,
'w': sourceRect.width,
'h': sourceRect.height,
},
});
return result ?? false;
}
/// Open a file on disk with the system's default viewer.
///
/// - iOS: Quick Look preview (`QLPreviewController`) — modal with native
/// preview for common types. The built-in toolbar still exposes Share.
/// - macOS: hands off to the system default app (`NSWorkspace.open`).
/// - Android: `Intent.ACTION_VIEW` — launches the default viewer app.
///
/// [mimeType] hints the viewer on Android. Ignored on Apple platforms
/// (the framework infers from the file extension).
///
/// Returns true if the viewer opened. Returns false when no viewer is
/// available (Android: no app registered for the MIME; macOS: no
/// associated app).
static Future<bool> open({
required String path,
String? mimeType,
Uint8List? bookmark,
}) async {
final result = await _channel.invokeMethod<bool>('open', {
'path': path,
if (mimeType != null) 'mimeType': mimeType,
if (bookmark != null) 'bookmark': bookmark,
});
return result ?? false;
}
/// Present the system file picker. Returns the picked file's local-disk
/// path (and optional metadata), or null if the user cancelled.
///
/// File content is **never** marshalled across the platform channel —
/// the native side only ships back the path. Use `dart:io` to read the
/// file: `File(picked.path).openRead()` etc.
///
/// [mimeTypes] filters the picker. Each entry can be a concrete type
/// (`image/png`), a wildcard (`image/*`), or `*/*`. Null = `[*/*]`.
/// Note: Apple platforms map MIME → UTType internally; common types
/// (`image/*`, `video/*`, `application/pdf`) work on all three. For
/// Apple-specific types prefer concrete MIME like `image/jpeg` over
/// wildcards.
static Future<UxPickedFile?> pick({
List<String>? mimeTypes,
}) async {
final result = await _channel.invokeMapMethod<String, Object?>('pick', {
if (mimeTypes != null) 'mimeTypes': mimeTypes,
});
if (result == null) return null;
final path = result['path'] as String?;
if (path == null) return null;
return UxPickedFile(
path: path,
name: result['name'] as String?,
mimeType: result['mimeType'] as String?,
size: (result['size'] as num?)?.toInt(),
bookmark: result['bookmark'] as Uint8List?,
);
}
/// Reveal a file on disk in the system's file browser.
///
/// - macOS: opens (or surfaces) a Finder window with the file selected
/// (`NSWorkspace.activateFileViewerSelecting`).
/// - All other platforms: no-op, returns false.
///
/// Returns true on macOS when Finder accepted the request.
static Future<bool> showInFolder({
required String path,
Uint8List? bookmark,
}) async {
if (!Platform.isMacOS) return false;
final result = await _channel.invokeMethod<bool>('showInFolder', {
'path': path,
if (bookmark != null) 'bookmark': bookmark,
});
return result ?? false;
}
/// Whether the host platform supports [showInFolder]. Currently macOS
/// only — call sites can use this to gate UI affordances ("Show in
/// Finder" buttons) so they don't appear where they're inert.
static bool get supportsShowInFolder => Platform.isMacOS;
/// Extract a single frame from the video at [path]. Returns null if the
/// platform's media decoder couldn't open the file (unsupported codec /
/// corrupt / not actually a video).
///
/// [atMs] picks the frame timestamp in milliseconds (default 0 = first
/// available keyframe). [maxWidth] caps the output's longer edge while
/// preserving aspect ratio.
static Future<UxVideoThumbnail?> videoThumbnail({
required String path,
int atMs = 0,
int maxWidth = 320,
}) async {
final result = await _channel.invokeMapMethod<String, Object?>(
'videoThumbnail',
<String, Object?>{
'path': path,
'atMs': atMs,
'maxWidth': maxWidth,
},
);
if (result == null) return null;
final bytes = result['png'] as Uint8List?;
final width = (result['width'] as num?)?.toInt();
final height = (result['height'] as num?)?.toInt();
if (bytes == null || width == null || height == null) return null;
return UxVideoThumbnail(pngBytes: bytes, width: width, height: height);
}
}