init
@@ -0,0 +1,16 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
@@ -0,0 +1,5 @@
|
||||
# Launch Screen Assets
|
||||
|
||||
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
||||
|
||||
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Luban Imager</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Image</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.image</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Luban Imager</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>保存压缩图片到系统相册。</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>保存压缩图片到系统相册。</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
@@ -0,0 +1,644 @@
|
||||
import Flutter
|
||||
import ImageIO
|
||||
import Photos
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
private var imageBridge: NativeImageBridge?
|
||||
|
||||
override func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||
|
||||
if let controller = window?.rootViewController as? FlutterViewController {
|
||||
let bridge = NativeImageBridge(controller: controller)
|
||||
imageBridge = bridge
|
||||
bridge.handleOpenURLContexts(connectionOptions.urlContexts, notifyFlutter: false)
|
||||
}
|
||||
}
|
||||
|
||||
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
super.scene(scene, openURLContexts: URLContexts)
|
||||
imageBridge?.handleOpenURLContexts(URLContexts, notifyFlutter: true)
|
||||
}
|
||||
}
|
||||
|
||||
private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate {
|
||||
private enum PendingOperation {
|
||||
case pick(FlutterResult)
|
||||
}
|
||||
|
||||
private weak var controller: FlutterViewController?
|
||||
private var channel: FlutterMethodChannel?
|
||||
private var pendingOperation: PendingOperation?
|
||||
private var pendingShareResult: FlutterResult?
|
||||
private var pendingSharedImages: [[String: Any]] = []
|
||||
private var sources: [String: URL] = [:]
|
||||
|
||||
init(controller: FlutterViewController) {
|
||||
self.controller = controller
|
||||
super.init()
|
||||
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "luban_imager/native_images",
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
)
|
||||
self.channel = channel
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
self?.handle(call: call, result: result)
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "pickImages":
|
||||
pickImages(result: result)
|
||||
case "takeSharedImages":
|
||||
takeSharedImages(result: result)
|
||||
case "compressImage":
|
||||
compressImage(arguments: call.arguments, result: result)
|
||||
case "overwriteOriginal":
|
||||
overwriteOriginal(arguments: call.arguments, result: result)
|
||||
case "saveToGallery":
|
||||
saveToGallery(arguments: call.arguments, result: result)
|
||||
case "shareImage":
|
||||
shareImage(arguments: call.arguments, result: result)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
func handleOpenURLContexts(
|
||||
_ URLContexts: Set<UIOpenURLContext>,
|
||||
notifyFlutter: Bool
|
||||
) {
|
||||
let payload = URLContexts.compactMap { context -> [String: Any]? in
|
||||
do {
|
||||
return try buildSharedImage(sourceURL: context.url)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
guard !payload.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
pendingSharedImages = payload
|
||||
if notifyFlutter {
|
||||
channel?.invokeMethod("sharedImages", arguments: payload)
|
||||
}
|
||||
}
|
||||
|
||||
private func takeSharedImages(result: @escaping FlutterResult) {
|
||||
let images = pendingSharedImages
|
||||
pendingSharedImages.removeAll()
|
||||
result(images)
|
||||
}
|
||||
|
||||
private func pickImages(result: @escaping FlutterResult) {
|
||||
guard pendingOperation == nil else {
|
||||
result(FlutterError(code: "busy", message: "正在选择图片", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let picker: UIDocumentPickerViewController
|
||||
if #available(iOS 14.0, *) {
|
||||
picker = UIDocumentPickerViewController(
|
||||
forOpeningContentTypes: [UTType.image],
|
||||
asCopy: false
|
||||
)
|
||||
} else {
|
||||
picker = UIDocumentPickerViewController(documentTypes: ["public.image"], in: .open)
|
||||
}
|
||||
picker.allowsMultipleSelection = false
|
||||
picker.delegate = self
|
||||
pendingOperation = .pick(result)
|
||||
controller?.present(picker, animated: true)
|
||||
}
|
||||
|
||||
private func compressImage(arguments: Any?, result: @escaping FlutterResult) {
|
||||
guard
|
||||
let args = arguments as? [String: Any],
|
||||
let sourceHandle = args["sourceHandle"] as? String,
|
||||
let sourceURL = sources[sourceHandle]
|
||||
else {
|
||||
result(FlutterError(code: "bad_args", message: "缺少 sourceHandle", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let payload = try self.compress(sourceURL: sourceURL)
|
||||
DispatchQueue.main.async { result(payload) }
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "compress_failed",
|
||||
message: error.localizedDescription,
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func overwriteOriginal(arguments: Any?, result: @escaping FlutterResult) {
|
||||
guard
|
||||
let args = arguments as? [String: Any],
|
||||
let sourceHandle = args["sourceHandle"] as? String,
|
||||
let compressedPath = args["compressedPath"] as? String,
|
||||
let sourceURL = sources[sourceHandle]
|
||||
else {
|
||||
result(FlutterError(code: "bad_args", message: "缺少覆盖参数", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let compressedURL = URL(fileURLWithPath: compressedPath)
|
||||
do {
|
||||
let data = try Data(contentsOf: compressedURL)
|
||||
let scoped = sourceURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if scoped {
|
||||
sourceURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
try data.write(to: sourceURL)
|
||||
result(["overwritten": true, "target": sourceHandle])
|
||||
} catch {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "overwrite_failed",
|
||||
message: error.localizedDescription,
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func shareImage(arguments: Any?, result: @escaping FlutterResult) {
|
||||
guard
|
||||
let args = arguments as? [String: Any],
|
||||
let compressedPath = args["compressedPath"] as? String
|
||||
else {
|
||||
result(FlutterError(code: "bad_args", message: "缺少 compressedPath", details: nil))
|
||||
return
|
||||
}
|
||||
guard pendingShareResult == nil else {
|
||||
result(FlutterError(code: "busy", message: "正在分享", details: nil))
|
||||
return
|
||||
}
|
||||
guard let controller = controller else {
|
||||
result(FlutterError(code: "share_unavailable", message: "无法打开分享面板", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let sourceURL = URL(fileURLWithPath: compressedPath)
|
||||
let suggestedName = args["suggestedName"] as? String ?? sourceURL.lastPathComponent
|
||||
let exportURL = try makeExportCopy(sourceURL: sourceURL, suggestedName: suggestedName)
|
||||
|
||||
let activityController = UIActivityViewController(
|
||||
activityItems: [exportURL],
|
||||
applicationActivities: nil
|
||||
)
|
||||
if let popover = activityController.popoverPresentationController,
|
||||
let view = controller.view
|
||||
{
|
||||
popover.sourceView = view
|
||||
popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.midY, width: 0, height: 0)
|
||||
popover.permittedArrowDirections = []
|
||||
}
|
||||
pendingShareResult = result
|
||||
activityController.completionWithItemsHandler = { [weak self] _, completed, _, error in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
let pendingResult = self.pendingShareResult
|
||||
self.pendingShareResult = nil
|
||||
if let error = error {
|
||||
pendingResult?(
|
||||
FlutterError(code: "share_failed", message: error.localizedDescription, details: nil)
|
||||
)
|
||||
} else {
|
||||
pendingResult?(["shared": completed])
|
||||
}
|
||||
}
|
||||
controller.present(activityController, animated: true)
|
||||
} catch {
|
||||
result(
|
||||
FlutterError(code: "share_failed", message: error.localizedDescription, details: nil)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveToGallery(arguments: Any?, result: @escaping FlutterResult) {
|
||||
guard
|
||||
let args = arguments as? [String: Any],
|
||||
let compressedPath = args["compressedPath"] as? String
|
||||
else {
|
||||
result(FlutterError(code: "bad_args", message: "缺少 compressedPath", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let sourceURL = URL(fileURLWithPath: compressedPath)
|
||||
saveImageToPhotoLibrary(sourceURL: sourceURL, result: result)
|
||||
}
|
||||
|
||||
private func saveImageToPhotoLibrary(
|
||||
sourceURL: URL,
|
||||
result: @escaping FlutterResult
|
||||
) {
|
||||
let saveChanges = {
|
||||
PHPhotoLibrary.shared().performChanges({
|
||||
_ = PHAssetCreationRequest.creationRequestForAssetFromImage(atFileURL: sourceURL)
|
||||
}) { success, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
result(FlutterError(code: "save_failed", message: error.localizedDescription, details: nil))
|
||||
} else if success {
|
||||
result(["saved": true])
|
||||
} else {
|
||||
result(FlutterError(code: "save_failed", message: "保存到相册失败", details: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if #available(iOS 14.0, *) {
|
||||
switch PHPhotoLibrary.authorizationStatus(for: .addOnly) {
|
||||
case .authorized, .limited:
|
||||
saveChanges()
|
||||
case .notDetermined:
|
||||
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
|
||||
if status == .authorized || status == .limited {
|
||||
saveChanges()
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
result(FlutterError(code: "photo_denied", message: "没有相册保存权限", details: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
result(FlutterError(code: "photo_denied", message: "没有相册保存权限", details: nil))
|
||||
}
|
||||
} else {
|
||||
switch PHPhotoLibrary.authorizationStatus() {
|
||||
case .authorized:
|
||||
saveChanges()
|
||||
case .notDetermined:
|
||||
PHPhotoLibrary.requestAuthorization { status in
|
||||
if status == .authorized {
|
||||
saveChanges()
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
result(FlutterError(code: "photo_denied", message: "没有相册保存权限", details: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
result(FlutterError(code: "photo_denied", message: "没有相册保存权限", details: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func documentPicker(
|
||||
_ controller: UIDocumentPickerViewController,
|
||||
didPickDocumentsAt urls: [URL]
|
||||
) {
|
||||
guard let operation = pendingOperation else {
|
||||
return
|
||||
}
|
||||
pendingOperation = nil
|
||||
|
||||
switch operation {
|
||||
case .pick(let result):
|
||||
do {
|
||||
if let url = urls.first {
|
||||
result([try buildPickedImage(sourceURL: url)])
|
||||
} else {
|
||||
result([])
|
||||
}
|
||||
} catch {
|
||||
result(
|
||||
FlutterError(code: "pick_failed", message: error.localizedDescription, details: nil)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
guard let operation = pendingOperation else {
|
||||
return
|
||||
}
|
||||
pendingOperation = nil
|
||||
|
||||
switch operation {
|
||||
case .pick(let result):
|
||||
result([])
|
||||
}
|
||||
}
|
||||
|
||||
private func buildPickedImage(sourceURL: URL) throws -> [String: Any] {
|
||||
let scoped = sourceURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if scoped {
|
||||
sourceURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
let id = UUID().uuidString
|
||||
sources[id] = sourceURL
|
||||
|
||||
let previewURL = try copyToCache(
|
||||
sourceURL: sourceURL,
|
||||
child: "originals",
|
||||
preferredName: sourceURL.lastPathComponent
|
||||
)
|
||||
let dimensions = imageDimensions(for: sourceURL) ?? imageDimensions(for: previewURL)
|
||||
let size = fileSize(sourceURL) ?? fileSize(previewURL) ?? 0
|
||||
|
||||
return [
|
||||
"sourceHandle": id,
|
||||
"displayName": sourceURL.lastPathComponent.isEmpty
|
||||
? "image"
|
||||
: sourceURL.lastPathComponent,
|
||||
"previewPath": previewURL.path,
|
||||
"originalSize": size,
|
||||
"width": dimensions?.width ?? 0,
|
||||
"height": dimensions?.height ?? 0,
|
||||
"canOverwrite": FileManager.default.isWritableFile(atPath: sourceURL.path),
|
||||
]
|
||||
}
|
||||
|
||||
private func buildSharedImage(sourceURL: URL) throws -> [String: Any] {
|
||||
let scoped = sourceURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if scoped {
|
||||
sourceURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
let displayName = sourceURL.lastPathComponent.isEmpty
|
||||
? "image"
|
||||
: sourceURL.lastPathComponent
|
||||
let previewURL = try copyToCache(
|
||||
sourceURL: sourceURL,
|
||||
child: "shared-originals",
|
||||
preferredName: displayName
|
||||
)
|
||||
let id = UUID().uuidString
|
||||
sources[id] = previewURL
|
||||
let dimensions = imageDimensions(for: previewURL)
|
||||
let size = fileSize(previewURL) ?? 0
|
||||
|
||||
return [
|
||||
"sourceHandle": id,
|
||||
"displayName": displayName,
|
||||
"previewPath": previewURL.path,
|
||||
"originalSize": size,
|
||||
"width": dimensions?.width ?? 0,
|
||||
"height": dimensions?.height ?? 0,
|
||||
"canOverwrite": false,
|
||||
]
|
||||
}
|
||||
|
||||
private func compress(sourceURL: URL) throws -> [String: Any] {
|
||||
let scoped = sourceURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if scoped {
|
||||
sourceURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
let originalData = try Data(contentsOf: sourceURL)
|
||||
guard let image = UIImage(data: originalData) else {
|
||||
throw NativeImageError.message("无法解码图片")
|
||||
}
|
||||
|
||||
let originalDimensions = imageDimensions(for: sourceURL)
|
||||
?? PixelSize(
|
||||
width: max(1, Int(image.size.width * image.scale)),
|
||||
height: max(1, Int(image.size.height * image.scale))
|
||||
)
|
||||
let targetDimensions = Self.targetSize(for: originalDimensions)
|
||||
let hasAlpha = Self.hasAlpha(image)
|
||||
let rendered = Self.render(image: image, target: targetDimensions, opaque: !hasAlpha)
|
||||
let preservePng = hasAlpha && originalData.count <= 1_500_000
|
||||
let quality = Self.jpegQuality(for: targetDimensions)
|
||||
|
||||
let encodedData = preservePng
|
||||
? rendered.pngData()
|
||||
: rendered.jpegData(compressionQuality: quality)
|
||||
guard let encoded = encodedData else {
|
||||
throw NativeImageError.message("无法生成压缩图片")
|
||||
}
|
||||
|
||||
var outputData = encoded
|
||||
var outputDimensions = targetDimensions
|
||||
var outputExtension = preservePng ? "png" : "jpg"
|
||||
var passthrough = false
|
||||
|
||||
if encoded.count >= originalData.count {
|
||||
outputData = originalData
|
||||
outputDimensions = originalDimensions
|
||||
outputExtension = sourceURL.pathExtension.isEmpty ? outputExtension : sourceURL.pathExtension
|
||||
passthrough = true
|
||||
}
|
||||
|
||||
let outputURL = try makeCompressedURL(
|
||||
originalName: sourceURL.deletingPathExtension().lastPathComponent,
|
||||
extensionName: outputExtension
|
||||
)
|
||||
try outputData.write(to: outputURL, options: .atomic)
|
||||
|
||||
return [
|
||||
"path": outputURL.path,
|
||||
"outputSize": outputData.count,
|
||||
"width": outputDimensions.width,
|
||||
"height": outputDimensions.height,
|
||||
"passthrough": passthrough,
|
||||
]
|
||||
}
|
||||
|
||||
private func copyToCache(
|
||||
sourceURL: URL,
|
||||
child: String,
|
||||
preferredName: String
|
||||
) throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
child,
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let targetURL = directory.appendingPathComponent(
|
||||
"\(UUID().uuidString)-\(Self.sanitizeFileName(preferredName))"
|
||||
)
|
||||
|
||||
do {
|
||||
try FileManager.default.copyItem(at: sourceURL, to: targetURL)
|
||||
} catch {
|
||||
let data = try Data(contentsOf: sourceURL)
|
||||
try data.write(to: targetURL, options: .atomic)
|
||||
}
|
||||
return targetURL
|
||||
}
|
||||
|
||||
private func makeCompressedURL(originalName: String, extensionName: String) throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"compressed",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let safeName = Self.sanitizeFileName(originalName.isEmpty ? "image" : originalName)
|
||||
return directory.appendingPathComponent("\(UUID().uuidString)-\(safeName).\(extensionName)")
|
||||
}
|
||||
|
||||
private func makeExportCopy(sourceURL: URL, suggestedName: String) throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"exports",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let targetURL = directory.appendingPathComponent(Self.sanitizeFileName(suggestedName))
|
||||
if FileManager.default.fileExists(atPath: targetURL.path) {
|
||||
try FileManager.default.removeItem(at: targetURL)
|
||||
}
|
||||
try FileManager.default.copyItem(at: sourceURL, to: targetURL)
|
||||
return targetURL
|
||||
}
|
||||
|
||||
private func imageDimensions(for url: URL) -> PixelSize? {
|
||||
guard
|
||||
let source = CGImageSourceCreateWithURL(url as CFURL, [
|
||||
kCGImageSourceShouldCache: false
|
||||
] as CFDictionary),
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
||||
let width = properties[kCGImagePropertyPixelWidth] as? NSNumber,
|
||||
let height = properties[kCGImagePropertyPixelHeight] as? NSNumber
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return PixelSize(width: width.intValue, height: height.intValue)
|
||||
}
|
||||
|
||||
private func fileSize(_ url: URL) -> Int? {
|
||||
if let values = try? url.resourceValues(forKeys: [.fileSizeKey]),
|
||||
let size = values.fileSize
|
||||
{
|
||||
return size
|
||||
}
|
||||
if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let size = attrs[.size] as? NSNumber
|
||||
{
|
||||
return size.intValue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func targetSize(for source: PixelSize) -> PixelSize {
|
||||
let width = max(1, source.width)
|
||||
let height = max(1, source.height)
|
||||
let longSide = max(width, height)
|
||||
let shortSide = min(width, height)
|
||||
let aspect = Double(shortSide) / Double(longSide)
|
||||
var scale = 1.0
|
||||
|
||||
if longSide > 10_800 && aspect > 0.25 {
|
||||
scale = min(scale, 1440.0 / Double(longSide))
|
||||
} else if shortSide > 1440 {
|
||||
scale = min(scale, 1440.0 / Double(shortSide))
|
||||
}
|
||||
|
||||
let sourcePixels = Double(width) * Double(height)
|
||||
if sourcePixels > 40_960_000 {
|
||||
scale = min(scale, 0.5)
|
||||
}
|
||||
|
||||
let scaledPixels = sourcePixels * scale * scale
|
||||
if scaledPixels > 10_240_000 {
|
||||
scale = min(scale, sqrt(10_240_000 / sourcePixels))
|
||||
}
|
||||
|
||||
return PixelSize(
|
||||
width: max(1, Int((Double(width) * scale).rounded(.down))),
|
||||
height: max(1, Int((Double(height) * scale).rounded(.down)))
|
||||
)
|
||||
}
|
||||
|
||||
private static func jpegQuality(for size: PixelSize) -> CGFloat {
|
||||
let megapixels = Double(size.width * size.height) / 1_000_000
|
||||
if megapixels < 0.5 {
|
||||
return 0.92
|
||||
}
|
||||
if megapixels < 1.0 {
|
||||
return 0.88
|
||||
}
|
||||
if megapixels < 3.0 {
|
||||
return 0.8
|
||||
}
|
||||
return 0.72
|
||||
}
|
||||
|
||||
private static func render(image: UIImage, target: PixelSize, opaque: Bool) -> UIImage {
|
||||
let size = CGSize(width: CGFloat(target.width), height: CGFloat(target.height))
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = 1
|
||||
format.opaque = opaque
|
||||
return UIGraphicsImageRenderer(size: size, format: format).image { _ in
|
||||
image.draw(in: CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
||||
|
||||
private static func hasAlpha(_ image: UIImage) -> Bool {
|
||||
guard let alpha = image.cgImage?.alphaInfo else {
|
||||
return false
|
||||
}
|
||||
switch alpha {
|
||||
case .first, .last, .premultipliedFirst, .premultipliedLast:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func sanitizeFileName(_ name: String) -> String {
|
||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
|
||||
let sanitized = name.unicodeScalars
|
||||
.map { allowed.contains($0) ? String($0) : "_" }
|
||||
.joined()
|
||||
return sanitized.isEmpty ? "image.jpg" : sanitized
|
||||
}
|
||||
}
|
||||
|
||||
private struct PixelSize {
|
||||
let width: Int
|
||||
let height: Int
|
||||
}
|
||||
|
||||
private enum NativeImageError: LocalizedError {
|
||||
case message(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .message(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
"CFBundleDisplayName" = "Luban Imager";
|
||||
"CFBundleName" = "Luban Imager";
|
||||
@@ -0,0 +1,2 @@
|
||||
"CFBundleDisplayName" = "鲁班压图";
|
||||
"CFBundleName" = "鲁班压图";
|
||||
@@ -0,0 +1,2 @@
|
||||
"CFBundleDisplayName" = "鲁班压图";
|
||||
"CFBundleName" = "鲁班压图";
|
||||