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
|
||||
- `package:ux/testing.dart`: new entry point for test-only utilities
|
||||
- `matchesTextGolden(path, {update})`: `matchesGoldenFile`-style matcher for
|
||||
|
||||
@@ -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>
|
||||
|
||||
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(
|
||||
KeyboardPlugin(),
|
||||
SensorPlugin(),
|
||||
FilePlugin(),
|
||||
)
|
||||
|
||||
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() {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ public class UxPlugin: NSObject, FlutterPlugin {
|
||||
plugins = [
|
||||
KeyboardPlugin(),
|
||||
SensorPlugin(),
|
||||
FilePlugin(),
|
||||
]
|
||||
for plugin in plugins {
|
||||
plugin.register(with: registrar)
|
||||
|
||||
@@ -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
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/json_extension.dart';
|
||||
export 'src/bezier.dart';
|
||||
export 'src/file.dart';
|
||||
export 'src/keyboard.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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user