Frees the UxFile name for the value type. UxFile is now a minimal
{path} handle returned from anything in package:ux that produces a file
on disk (camera capture today, future writers). The existing
static-method namespace (pick/share/open/withScopedAccess/showInFolder/
videoThumbnail/supportsShowInFolder) becomes UxFiles. UxPickedFile is
unchanged.
Pairs with the banlu commit renaming the 5 app-side callers.
244 lines
8.8 KiB
Dart
244 lines
8.8 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 handle to a file on local disk. Minimal — [path] is the only
|
|
/// guaranteed field. Returned from anything in `package:ux` that produces
|
|
/// a file (camera capture today; future writers).
|
|
class UxFile {
|
|
const UxFile(this.path);
|
|
|
|
/// Absolute path on local disk. Readable through `dart:io File`. Lifetime
|
|
/// is producer-defined — camera capture writes to a temp dir, the
|
|
/// picker grant is held by the OS while the session is alive, etc.
|
|
final String path;
|
|
}
|
|
|
|
/// 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 [UxFiles.withScopedAccess] /
|
|
/// [UxFiles.open] / [UxFiles.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 UxFiles {
|
|
UxFiles._();
|
|
|
|
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);
|
|
}
|
|
}
|