This commit is contained in:
agra
2026-04-22 22:42:53 +03:00
parent 2113537078
commit 7e0b9a6330
17 changed files with 552 additions and 19 deletions

View File

@@ -1,3 +1,27 @@
### 0.6.0
- `UxFile`: new module for handing files to the OS
- `UxFile.share(path, {title, mimeType, sourceRect})` — iOS `UIActivityViewController`,
macOS `NSSharingServicePicker`, Android `Intent.ACTION_SEND` via FileProvider.
`sourceRect` anchors the popover on iPad / macOS.
- `UxFile.open(path, {mimeType})` — in-app preview where possible:
iOS `QLPreviewController`, macOS `QLPreviewPanel` (preview on top of the app,
no foreground loss), Android `Intent.ACTION_VIEW` with MIME inference from
`MimeTypeMap` + a fallback set of text/code extensions so unknown types
still resolve to `text/plain` instead of `application/octet-stream`.
- **macOS**: new platform. Plugin registry + `FilePlugin`; keyboard and sensor
plugins remain iOS-only.
- Android: ships a FileProvider under `${applicationId}.ux.fileprovider`
scoped to `ux_share/` in the app cache — host apps don't need manifest
plumbing.
- iOS: `UxWindow` helper (`keyWindow` / `topViewController`) in
`NativePlugin.swift`, shared between keyboard and file plugins.
### 0.5.0
- `UxSensor.orientation`: accelerometer-driven physical device rotation,
independent of the OS auto-rotate setting or UI orientation lock
- `AppInfo`: version + buildNumber surface
- `UxKeyboard`: focus tracking integration
### 0.4.0
- `package:ux/testing.dart`: new entry point for test-only utilities
- `matchesTextGolden(path, {update})`: `matchesGoldenFile`-style matcher for

View File

@@ -1,3 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.swipelab.ux">
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.ux.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/ux_file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,131 @@
package io.swipelab.ux
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
class FilePlugin : NativePlugin, MethodChannel.MethodCallHandler {
private var methodChannel: MethodChannel? = null
private var context: Context? = null
private var activity: Activity? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
methodChannel = MethodChannel(binding.binaryMessenger, "ux/file").also {
it.setMethodCallHandler(this)
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
context = null
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
}
override fun onDetachedFromActivity() {
activity = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"share" -> handleShare(call, result)
"open" -> handleOpen(call, result)
else -> result.notImplemented()
}
}
private fun handleShare(call: MethodCall, result: MethodChannel.Result) {
val ctx = context ?: return result.error("no_context", "no application context", null)
val act = activity ?: return result.error("no_activity", "plugin not attached to an activity", null)
val path = call.argument<String>("path")
?: return result.error("bad_args", "path is required", null)
val title = call.argument<String>("title")
val mimeType = call.argument<String>("mimeType")
val authority = "${ctx.packageName}.ux.fileprovider"
val uri = try {
FileProvider.getUriForFile(ctx, authority, File(path))
} catch (e: IllegalArgumentException) {
return result.error("bad_path", "file is outside FileProvider paths: ${e.message}", null)
}
val intent = Intent(Intent.ACTION_SEND).apply {
type = mimeType ?: "*/*"
putExtra(Intent.EXTRA_STREAM, uri)
title?.let { putExtra(Intent.EXTRA_SUBJECT, it) }
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(intent, title).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
act.startActivity(chooser)
result.success(true)
}
private fun handleOpen(call: MethodCall, result: MethodChannel.Result) {
val ctx = context ?: return result.error("no_context", "no application context", null)
val act = activity ?: return result.error("no_activity", "plugin not attached to an activity", null)
val path = call.argument<String>("path")
?: return result.error("bad_args", "path is required", null)
val mimeType = inferMime(call.argument<String>("mimeType"), path)
val authority = "${ctx.packageName}.ux.fileprovider"
val uri = try {
FileProvider.getUriForFile(ctx, authority, File(path))
} catch (e: IllegalArgumentException) {
return result.error("bad_path", "file is outside FileProvider paths: ${e.message}", null)
}
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
act.startActivity(intent)
result.success(true)
} catch (_: ActivityNotFoundException) {
result.success(false)
}
}
private fun inferMime(supplied: String?, path: String): String {
if (!supplied.isNullOrBlank() && supplied != "application/octet-stream") {
return supplied
}
val dot = path.lastIndexOf('.')
val ext = if (dot in 0 until path.length - 1) path.substring(dot + 1).lowercase() else ""
if (ext.isNotEmpty()) {
val guessed = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
if (!guessed.isNullOrBlank()) return guessed
if (ext in textExtensions) return "text/plain"
}
return supplied ?: "*/*"
}
companion object {
private val textExtensions = setOf(
"dart", "swift", "kt", "kts", "java", "scala", "groovy",
"py", "rb", "php", "pl", "sh", "bash", "zsh", "fish",
"ts", "tsx", "jsx", "mjs", "cjs",
"go", "rs", "c", "h", "cpp", "hpp", "cc", "hh", "m", "mm",
"lua", "tcl", "r", "ex", "exs", "elm", "hs", "clj", "cljs", "scm",
"proto", "thrift", "sql",
"yml", "yaml", "toml", "ini", "conf", "cfg", "env", "properties",
"gradle", "lock", "diff", "patch",
"md", "markdown", "log", "txt",
)
}
}

View File

@@ -8,6 +8,7 @@ class UxPlugin : FlutterPlugin, ActivityAware {
private val plugins: List<NativePlugin> = listOf(
KeyboardPlugin(),
SensorPlugin(),
FilePlugin(),
)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="ux_share" path="ux_share/" />
<external-cache-path name="ux_share_ext" path="ux_share/" />
</paths>

View File

@@ -0,0 +1,115 @@
import Flutter
import QuickLook
import UIKit
public class FilePlugin: NSObject, NativePlugin {
private var channel: FlutterMethodChannel?
private var previewDataSource: FilePreviewDataSource?
public func register(with registrar: FlutterPluginRegistrar) {
let c = FlutterMethodChannel(name: "ux/file", binaryMessenger: registrar.messenger())
c.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
channel = c
}
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "share": handleShare(call, result: result)
case "open": handleOpen(call, result: result)
default: result(FlutterMethodNotImplemented)
}
}
private func handleShare(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let path = args["path"] as? String else {
return result(FlutterError(code: "bad_args", message: "path is required", details: nil))
}
guard let topVC = UxWindow.topViewController else {
return result(FlutterError(code: "no_view", message: "no top view controller", details: nil))
}
let url = URL(fileURLWithPath: path)
let title = args["title"] as? String
let items: [Any] = title.map { [FileActivityItemSource(url: url, title: $0)] } ?? [url]
let vc = UIActivityViewController(activityItems: items, applicationActivities: nil)
if UIDevice.current.userInterfaceIdiom == .pad,
let popover = vc.popoverPresentationController {
popover.sourceView = topVC.view
if let r = args["sourceRect"] as? [String: Any],
let x = (r["x"] as? NSNumber)?.doubleValue,
let y = (r["y"] as? NSNumber)?.doubleValue,
let w = (r["w"] as? NSNumber)?.doubleValue,
let h = (r["h"] as? NSNumber)?.doubleValue {
popover.sourceRect = CGRect(x: x, y: y, width: w, height: h)
} else {
let b = topVC.view.bounds
popover.sourceRect = CGRect(x: b.midX, y: b.midY, width: 1, height: 1)
popover.permittedArrowDirections = []
}
}
topVC.present(vc, animated: true) {
result(true)
}
}
private func handleOpen(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let path = args["path"] as? String else {
return result(FlutterError(code: "bad_args", message: "path is required", details: nil))
}
guard let topVC = UxWindow.topViewController else {
return result(FlutterError(code: "no_view", message: "no top view controller", details: nil))
}
let url = URL(fileURLWithPath: path)
let ds = FilePreviewDataSource(url: url)
// QLPreviewController requires a strong-retained data source.
previewDataSource = ds
let vc = QLPreviewController()
vc.dataSource = ds
topVC.present(vc, animated: true) {
result(true)
}
}
}
private final class FilePreviewDataSource: NSObject, QLPreviewControllerDataSource {
let url: URL
init(url: URL) { self.url = url }
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
url as QLPreviewItem
}
}
private final class FileActivityItemSource: NSObject, UIActivityItemSource {
let url: URL
let title: String
init(url: URL, title: String) {
self.url = url
self.title = title
}
func activityViewControllerPlaceholderItem(_ controller: UIActivityViewController) -> Any {
url
}
func activityViewController(_ controller: UIActivityViewController, itemForActivityType type: UIActivity.ActivityType?) -> Any? {
url
}
func activityViewController(_ controller: UIActivityViewController, subjectForActivityType type: UIActivity.ActivityType?) -> String {
title
}
}

View File

@@ -267,22 +267,8 @@ public class KeyboardPlugin: NSObject, NativePlugin {
}
private func setupPanGesture() {
var targetView: UIView?
if let window = UIApplication.shared.delegate?.window ?? nil {
targetView = window.rootViewController?.view ?? window
} else {
for scene in UIApplication.shared.connectedScenes {
if let ws = scene as? UIWindowScene {
for window in ws.windows where window.isKeyWindow {
targetView = window.rootViewController?.view ?? window
break
}
}
}
}
guard let view = targetView else { return }
guard let window = UxWindow.keyWindow else { return }
let view: UIView = window.rootViewController?.view ?? window
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
pan.cancelsTouchesInView = false

View File

@@ -1,5 +1,23 @@
import Flutter
import UIKit
public protocol NativePlugin {
func register(with registrar: FlutterPluginRegistrar)
}
public enum UxWindow {
public static var keyWindow: UIWindow? {
if let w = UIApplication.shared.delegate?.window ?? nil { return w }
for scene in UIApplication.shared.connectedScenes {
guard let ws = scene as? UIWindowScene else { continue }
for window in ws.windows where window.isKeyWindow { return window }
}
return nil
}
public static var topViewController: UIViewController? {
var vc = keyWindow?.rootViewController
while let presented = vc?.presentedViewController { vc = presented }
return vc
}
}

View File

@@ -8,6 +8,7 @@ public class UxPlugin: NSObject, FlutterPlugin {
plugins = [
KeyboardPlugin(),
SensorPlugin(),
FilePlugin(),
]
for plugin in plugins {
plugin.register(with: registrar)

View File

@@ -1,7 +1,7 @@
Pod::Spec.new do |s|
s.name = 'ux'
s.version = '0.2.0'
s.summary = 'UX Kit Flutter plugin with keyboard tracking and interactive dismiss.'
s.version = '0.6.0'
s.summary = 'UX Kit Flutter plugin: keyboard, sensor, and file share/open.'
s.homepage = 'https://swipelab.co/ux.html'
s.license = { :file => '../LICENSE' }
s.author = { 'Swipelab' => 'hello@swipelab.co' }

70
lib/src/file.dart Normal file
View File

@@ -0,0 +1,70 @@
import 'dart:ui' show Rect;
import 'package:flutter/services.dart';
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).
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,
}) async {
final result = await _channel.invokeMethod<bool>('open', {
'path': path,
if (mimeType != null) 'mimeType': mimeType,
});
return result ?? false;
}
}

View File

@@ -8,5 +8,6 @@ export 'src/app_info.dart';
export 'src/bend_box.dart';
export 'src/json_extension.dart';
export 'src/bezier.dart';
export 'src/file.dart';
export 'src/keyboard.dart';
export 'src/sensor.dart';

View File

@@ -0,0 +1,117 @@
import FlutterMacOS
import AppKit
import Quartz
public class FilePlugin: NSObject, NativePlugin {
private var channel: FlutterMethodChannel?
public func register(with registrar: FlutterPluginRegistrar) {
let c = FlutterMethodChannel(name: "ux/file", binaryMessenger: registrar.messenger)
c.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
channel = c
}
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "share": handleShare(call, result: result)
case "open": handleOpen(call, result: result)
default: result(FlutterMethodNotImplemented)
}
}
private func handleShare(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let path = args["path"] as? String else {
return result(FlutterError(code: "bad_args", message: "path is required", details: nil))
}
guard let view = UxWindow.flutterView else {
return result(FlutterError(code: "no_view", message: "no Flutter view", details: nil))
}
let url = URL(fileURLWithPath: path)
let picker = NSSharingServicePicker(items: [url])
let rect: NSRect
if let r = args["sourceRect"] as? [String: Any],
let x = (r["x"] as? NSNumber)?.doubleValue,
let y = (r["y"] as? NSNumber)?.doubleValue,
let w = (r["w"] as? NSNumber)?.doubleValue,
let h = (r["h"] as? NSNumber)?.doubleValue {
if view.isFlipped {
rect = NSRect(x: x, y: y, width: w, height: h)
} else {
rect = NSRect(x: x, y: view.bounds.height - y - h, width: w, height: h)
}
} else {
rect = NSRect(x: view.bounds.midX, y: view.bounds.midY, width: 1, height: 1)
}
picker.show(relativeTo: rect, of: view, preferredEdge: .minY)
result(true)
}
private func handleOpen(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let path = args["path"] as? String else {
return result(FlutterError(code: "bad_args", message: "path is required", details: nil))
}
let url = URL(fileURLWithPath: path)
// Prefer in-app Quick Look (keeps Banlu in the foreground).
// Fall back to NSWorkspace.open if there's no window to host the panel.
if let flutterView = UxWindow.flutterView,
let window = flutterView.window,
let panel = QLPreviewPanel.shared() {
let responder = UxQLPreviewResponder(url: url, window: window)
flutterView.addSubview(responder)
window.makeFirstResponder(responder)
panel.updateController()
panel.makeKeyAndOrderFront(nil)
result(true)
return
}
result(NSWorkspace.shared.open(url))
}
}
private final class UxQLPreviewResponder: NSView, QLPreviewPanelDataSource {
let url: URL
private weak var previousFirstResponder: NSResponder?
private weak var previousWindow: NSWindow?
init(url: URL, window: NSWindow) {
self.url = url
self.previousWindow = window
self.previousFirstResponder = window.firstResponder
super.init(frame: .zero)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override var acceptsFirstResponder: Bool { true }
override func acceptsPreviewPanelControl(_ panel: QLPreviewPanel) -> Bool { true }
override func beginPreviewPanelControl(_ panel: QLPreviewPanel) {
panel.dataSource = self
}
override func endPreviewPanelControl(_ panel: QLPreviewPanel) {
panel.dataSource = nil
let win = previousWindow
let prev = previousFirstResponder
DispatchQueue.main.async { [weak self] in
win?.makeFirstResponder(prev)
self?.removeFromSuperview()
}
}
func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { 1 }
func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> QLPreviewItem! {
url as QLPreviewItem
}
}

View File

@@ -0,0 +1,18 @@
import FlutterMacOS
import AppKit
public protocol NativePlugin {
func register(with registrar: FlutterPluginRegistrar)
}
public enum UxWindow {
public static var keyWindow: NSWindow? {
NSApp.keyWindow ?? NSApp.mainWindow
}
/// FlutterViewController's view. Used as the anchor for
/// `NSSharingServicePicker` popovers.
public static var flutterView: NSView? {
keyWindow?.contentViewController?.view
}
}

View File

@@ -0,0 +1,19 @@
import FlutterMacOS
import AppKit
public class UxPlugin: NSObject, FlutterPlugin {
private static var plugins: [NativePlugin] = []
public static func register(with registrar: FlutterPluginRegistrar) {
plugins = [
FilePlugin(),
]
for plugin in plugins {
plugin.register(with: registrar)
}
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
result(FlutterMethodNotImplemented)
}
}

13
macos/ux.podspec Normal file
View File

@@ -0,0 +1,13 @@
Pod::Spec.new do |s|
s.name = 'ux'
s.version = '0.6.0'
s.summary = 'UX Kit — Flutter plugin: file share/open via Quick Look.'
s.homepage = 'https://swipelab.co/ux.html'
s.license = { :file => '../LICENSE' }
s.author = { 'Swipelab' => 'hello@swipelab.co' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*.swift'
s.dependency 'FlutterMacOS'
s.osx.deployment_target = '10.15'
s.swift_version = '5.0'
end

View File

@@ -3,7 +3,7 @@ description: >-
Flutter toolkit for fluid, native-feeling UIs. Includes frame-accurate
keyboard height tracking via FFI with interactive dismiss, bezier utilities,
and layout primitives.
version: 0.5.0
version: 0.6.0
homepage: https://swipelab.co/ux.html
repository: https://github.com/swipelab/ux
issue_tracker: https://github.com/swipelab/ux/issues
@@ -35,3 +35,5 @@ flutter:
android:
package: io.swipelab.ux
pluginClass: UxPlugin
macos:
pluginClass: UxPlugin