files
This commit is contained in:
24
CHANGELOG.md
24
CHANGELOG.md
@@ -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
|
### 0.4.0
|
||||||
- `package:ux/testing.dart`: new entry point for test-only utilities
|
- `package:ux/testing.dart`: new entry point for test-only utilities
|
||||||
- `matchesTextGolden(path, {update})`: `matchesGoldenFile`-style matcher for
|
- `matchesTextGolden(path, {update})`: `matchesGoldenFile`-style matcher for
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="io.swipelab.ux">
|
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>
|
</manifest>
|
||||||
|
|||||||
131
android/src/main/kotlin/io/swipelab/ux/FilePlugin.kt
Normal file
131
android/src/main/kotlin/io/swipelab/ux/FilePlugin.kt
Normal 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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ class UxPlugin : FlutterPlugin, ActivityAware {
|
|||||||
private val plugins: List<NativePlugin> = listOf(
|
private val plugins: List<NativePlugin> = listOf(
|
||||||
KeyboardPlugin(),
|
KeyboardPlugin(),
|
||||||
SensorPlugin(),
|
SensorPlugin(),
|
||||||
|
FilePlugin(),
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =
|
||||||
|
|||||||
5
android/src/main/res/xml/ux_file_paths.xml
Normal file
5
android/src/main/res/xml/ux_file_paths.xml
Normal 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>
|
||||||
115
ios/Classes/FilePlugin.swift
Normal file
115
ios/Classes/FilePlugin.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -267,22 +267,8 @@ public class KeyboardPlugin: NSObject, NativePlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func setupPanGesture() {
|
private func setupPanGesture() {
|
||||||
var targetView: UIView?
|
guard let window = UxWindow.keyWindow else { return }
|
||||||
|
let view: UIView = window.rootViewController?.view ?? window
|
||||||
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 }
|
|
||||||
|
|
||||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
||||||
pan.cancelsTouchesInView = false
|
pan.cancelsTouchesInView = false
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
public protocol NativePlugin {
|
public protocol NativePlugin {
|
||||||
func register(with registrar: FlutterPluginRegistrar)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public class UxPlugin: NSObject, FlutterPlugin {
|
|||||||
plugins = [
|
plugins = [
|
||||||
KeyboardPlugin(),
|
KeyboardPlugin(),
|
||||||
SensorPlugin(),
|
SensorPlugin(),
|
||||||
|
FilePlugin(),
|
||||||
]
|
]
|
||||||
for plugin in plugins {
|
for plugin in plugins {
|
||||||
plugin.register(with: registrar)
|
plugin.register(with: registrar)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
Pod::Spec.new do |s|
|
Pod::Spec.new do |s|
|
||||||
s.name = 'ux'
|
s.name = 'ux'
|
||||||
s.version = '0.2.0'
|
s.version = '0.6.0'
|
||||||
s.summary = 'UX Kit – Flutter plugin with keyboard tracking and interactive dismiss.'
|
s.summary = 'UX Kit — Flutter plugin: keyboard, sensor, and file share/open.'
|
||||||
s.homepage = 'https://swipelab.co/ux.html'
|
s.homepage = 'https://swipelab.co/ux.html'
|
||||||
s.license = { :file => '../LICENSE' }
|
s.license = { :file => '../LICENSE' }
|
||||||
s.author = { 'Swipelab' => 'hello@swipelab.co' }
|
s.author = { 'Swipelab' => 'hello@swipelab.co' }
|
||||||
|
|||||||
70
lib/src/file.dart
Normal file
70
lib/src/file.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,5 +8,6 @@ export 'src/app_info.dart';
|
|||||||
export 'src/bend_box.dart';
|
export 'src/bend_box.dart';
|
||||||
export 'src/json_extension.dart';
|
export 'src/json_extension.dart';
|
||||||
export 'src/bezier.dart';
|
export 'src/bezier.dart';
|
||||||
|
export 'src/file.dart';
|
||||||
export 'src/keyboard.dart';
|
export 'src/keyboard.dart';
|
||||||
export 'src/sensor.dart';
|
export 'src/sensor.dart';
|
||||||
|
|||||||
117
macos/Classes/FilePlugin.swift
Normal file
117
macos/Classes/FilePlugin.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
18
macos/Classes/NativePlugin.swift
Normal file
18
macos/Classes/NativePlugin.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
19
macos/Classes/UxPlugin.swift
Normal file
19
macos/Classes/UxPlugin.swift
Normal 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
13
macos/ux.podspec
Normal 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
|
||||||
@@ -3,7 +3,7 @@ description: >-
|
|||||||
Flutter toolkit for fluid, native-feeling UIs. Includes frame-accurate
|
Flutter toolkit for fluid, native-feeling UIs. Includes frame-accurate
|
||||||
keyboard height tracking via FFI with interactive dismiss, bezier utilities,
|
keyboard height tracking via FFI with interactive dismiss, bezier utilities,
|
||||||
and layout primitives.
|
and layout primitives.
|
||||||
version: 0.5.0
|
version: 0.6.0
|
||||||
homepage: https://swipelab.co/ux.html
|
homepage: https://swipelab.co/ux.html
|
||||||
repository: https://github.com/swipelab/ux
|
repository: https://github.com/swipelab/ux
|
||||||
issue_tracker: https://github.com/swipelab/ux/issues
|
issue_tracker: https://github.com/swipelab/ux/issues
|
||||||
@@ -35,3 +35,5 @@ flutter:
|
|||||||
android:
|
android:
|
||||||
package: io.swipelab.ux
|
package: io.swipelab.ux
|
||||||
pluginClass: UxPlugin
|
pluginClass: UxPlugin
|
||||||
|
macos:
|
||||||
|
pluginClass: UxPlugin
|
||||||
|
|||||||
Reference in New Issue
Block a user