commit 344af390d5b80f9b9a14cd107588c9d8258ad31b Author: 呈祥 Date: Mon May 18 13:32:51 2026 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..6738224 --- /dev/null +++ b/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + - platform: android + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + - platform: ios + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0aa2653 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 The Luban Imager Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..acd36bb --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Luban Imager / 鲁班压图 + +Luban Imager 是一个基于 Flutter 的移动端图片压缩 App,目标是把优秀的 Luban 图片压缩能力包装成一个简单、直接、适合日常使用的小工具。 + +## 致谢 + +首先感谢上游 [Curzibn/Luban](https://github.com/Curzibn/Luban) 项目。Luban 提供了接近微信朋友圈压缩策略的 Android 图片压缩能力,本项目的 Android 端基于 `top.zibin:luban:2.0.1` 工作,并在此基础上补充了 Flutter 界面、系统图片选择/分享、压缩结果预览、覆盖原图等 App 侧能力。 + +本项目不是 Luban 官方 App。如果你需要压缩算法本身、Android 库接入方式或上游实现细节,请优先参考 Luban 原仓库。 + +## 主要功能 + +- 智能压缩:选择图片后自动执行 Luban 风格压缩,不需要手动填写质量、尺寸等参数。 +- 多入口导入:支持从 App 内选择图片,也支持从系统分享菜单接收图片。 +- 压缩结果预览:展示原图、压缩图、前后对比三种视图,并显示尺寸、文件大小和节省比例。 +- 安全回退:如果压缩结果比原图更大,会自动使用原图,避免负优化。 +- 分享导出:压缩完成后可通过系统分享面板导出压缩图片。 +- 原位覆盖:对于系统授权可写入的图片来源,支持覆盖原图;覆盖前会进行两次确认。 + +## 平台支持 + +| 平台 | 状态 | 说明 | +| --- | --- | --- | +| Android | 已支持 | 使用上游 Luban Android 库执行压缩,并已接入选择、分享、覆盖等能力。 | +| iOS | 理论支持,未实机测试 | iOS 端包含基于 ImageIO/UIKit 的 Luban 风格压缩实现,但作者目前没有 iPhone 设备,因此还没有经过真实设备验证。 | + +## 技术实现 + +- UI 使用 Flutter / Material 3。 +- Android 通过 `MethodChannel` 调用原生 Kotlin 代码,并使用 `top.zibin:luban:2.0.1` 压缩图片。 +- iOS 通过 `MethodChannel` 调用 Swift 代码,使用 ImageIO/UIKit 读取、缩放和编码图片。 +- 图片选择、分享和覆盖均尽量走系统能力,压缩过程在本地完成。 + +## 开发环境 + +建议使用 Flutter stable 版本。当前项目由 Flutter 模板生成,`pubspec.yaml` 中 Dart SDK 约束为: + +```sh +sdk: ^3.11.5 +``` + +准备依赖: + +```sh +flutter pub get +``` + +运行测试: + +```sh +flutter test +``` + +本地运行: + +```sh +flutter run +``` + +## 编译 + +### Android + +调试安装包: + +```sh +flutter build apk --debug +``` + +发布安装包: + +```sh +flutter build apk --release +``` + +按 CPU 架构拆分发布安装包: + +```sh +flutter build apk --release --split-per-abi +``` + +发布 AAB: + +```sh +flutter build appbundle --release +``` + +发布前建议修改 `android/app/build.gradle.kts` 中的 `applicationId`,并配置自己的 release 签名。当前工程为了方便本地运行,release 构建仍使用 debug signing config。 + +### iOS + +iOS 构建需要 macOS、Xcode 和有效签名配置: + +```sh +flutter build ios --release +``` + +注意:iOS 端目前只是理论支持,代码路径已经实现,但尚未在真实 iPhone 设备上验证。 + +## License + +本项目采用 Apache-2.0 License,见 [LICENSE](./LICENSE)。 + +上游 Luban 项目同样采用 Apache-2.0 License。Android 端依赖 Luban 时,请保留并遵守上游项目许可证要求。 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..7a37a76 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.luban_imager" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.luban_imager" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} + +dependencies { + implementation("androidx.core:core:1.13.1") + implementation("top.zibin:luban:2.0.1") +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..064306f --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/luban_imager/MainActivity.kt b/android/app/src/main/kotlin/com/example/luban_imager/MainActivity.kt new file mode 100644 index 0000000..7d9c354 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/luban_imager/MainActivity.kt @@ -0,0 +1,515 @@ +package com.example.luban_imager + +import android.app.Activity +import android.content.ClipData +import android.content.ContentValues +import android.content.Intent +import android.graphics.BitmapFactory +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.OpenableColumns +import android.provider.MediaStore +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.io.File +import java.io.FileOutputStream +import java.util.UUID +import top.zibin.luban.api.Luban +import top.zibin.luban.api.OnCompressListener + +class MainActivity : FlutterActivity() { + private val channelName = "luban_imager/native_images" + private val pickImagesRequest = 4101 + + private var channel: MethodChannel? = null + private var pendingPickResult: MethodChannel.Result? = null + private val pendingSharedImages = mutableListOf>() + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) + channel?.setMethodCallHandler { call, result -> + when (call.method) { + "pickImages" -> pickImages(result) + "takeSharedImages" -> takeSharedImages(result) + "compressImage" -> compressImage(call.arguments, result) + "overwriteOriginal" -> overwriteOriginal(call.arguments, result) + "saveToGallery" -> saveToGallery(call.arguments, result) + "shareImage" -> shareImage(call.arguments, result) + else -> result.notImplemented() + } + } + consumeSharedIntent(intent, notifyFlutter = false) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + consumeSharedIntent(intent, notifyFlutter = true) + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + when (requestCode) { + pickImagesRequest -> finishPickImages(resultCode, data) + } + } + + private fun pickImages(result: MethodChannel.Result) { + if (pendingPickResult != null) { + result.error("busy", "正在选择图片", null) + return + } + + pendingPickResult = result + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "image/*" + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + } + + try { + startActivityForResult(intent, pickImagesRequest) + } catch (error: Exception) { + pendingPickResult = null + result.error("picker_unavailable", error.message, null) + } + } + + private fun finishPickImages(resultCode: Int, data: Intent?) { + val result = pendingPickResult ?: return + pendingPickResult = null + + if (resultCode != Activity.RESULT_OK || data == null) { + result.success(emptyList>()) + return + } + + val flags = data.flags and + (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + val payload = mutableListOf>() + + try { + val uri = extractUri(data) + if (uri != null) { + try { + if (flags != 0) { + contentResolver.takePersistableUriPermission(uri, flags) + } + } catch (_: SecurityException) { + // Some providers grant temporary access only. + } + payload.add(buildPickedImage(uri)) + } + result.success(payload) + } catch (error: Exception) { + result.error("pick_failed", error.message, null) + } + } + + private fun extractUri(data: Intent): Uri? { + return data.data ?: data.clipData?.getItemAt(0)?.uri + } + + private fun takeSharedImages(result: MethodChannel.Result) { + val images = pendingSharedImages.toList() + pendingSharedImages.clear() + result.success(images) + } + + private fun consumeSharedIntent(intent: Intent?, notifyFlutter: Boolean) { + val uris = extractSharedUris(intent) + if (uris.isEmpty()) { + return + } + + val payload = uris.mapNotNull { uri -> + try { + buildSharedImage(uri) + } catch (_: Exception) { + null + } + } + if (payload.isEmpty()) { + return + } + + pendingSharedImages.clear() + pendingSharedImages.addAll(payload) + if (notifyFlutter) { + channel?.invokeMethod("sharedImages", payload) + } + } + + @Suppress("DEPRECATION") + private fun extractSharedUris(intent: Intent?): List { + if (intent == null || intent.type?.startsWith("image/") != true) { + return emptyList() + } + + return when (intent.action) { + Intent.ACTION_SEND -> { + val stream = intent.getParcelableExtra(Intent.EXTRA_STREAM) + listOfNotNull(stream ?: intent.data) + } + Intent.ACTION_SEND_MULTIPLE -> { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.toList() + ?: emptyList() + } + else -> emptyList() + } + } + + private fun buildSharedImage(uri: Uri): Map { + val displayName = queryDisplayName(uri) ?: "image" + val previewFile = copyUriToCache(uri, displayName, "shared-originals") + val dimensions = readDimensionsFromFile(previewFile) + val originalSize = previewFile.length() + + return mapOf( + "sourceHandle" to Uri.fromFile(previewFile).toString(), + "displayName" to displayName, + "previewPath" to previewFile.absolutePath, + "originalSize" to originalSize, + "width" to dimensions.first, + "height" to dimensions.second, + "canOverwrite" to false, + ) + } + + private fun buildPickedImage(uri: Uri): Map { + val displayName = queryDisplayName(uri) ?: "image" + val previewFile = copyUriToCache(uri, displayName, "originals") + val dimensions = readDimensionsFromUri(uri).takeIf { it.first > 0 && it.second > 0 } + ?: readDimensionsFromFile(previewFile) + val originalSize = querySize(uri).takeIf { it > 0 } ?: previewFile.length() + + return mapOf( + "sourceHandle" to uri.toString(), + "displayName" to displayName, + "previewPath" to previewFile.absolutePath, + "originalSize" to originalSize, + "width" to dimensions.first, + "height" to dimensions.second, + "canOverwrite" to canOverwrite(uri), + ) + } + + private fun compressImage(arguments: Any?, result: MethodChannel.Result) { + val args = arguments as? Map<*, *> + val source = args?.get("sourceHandle") as? String + val previewPath = args?.get("previewPath") as? String + val originalSize = (args?.get("originalSize") as? Number)?.toLong() ?: -1L + val originalWidth = (args?.get("originalWidth") as? Number)?.toInt() ?: 0 + val originalHeight = (args?.get("originalHeight") as? Number)?.toInt() ?: 0 + if (source.isNullOrBlank()) { + result.error("bad_args", "缺少 sourceHandle", null) + return + } + + val outputDir = File(cacheDir, "compressed").apply { mkdirs() } + val uri = Uri.parse(source) + + try { + Luban.with(this) + .load(uri) + .setTargetDir(outputDir) + .setCompressListener(object : OnCompressListener { + override fun onStart() = Unit + + override fun onSuccess(file: File) { + val previewFile = previewPath?.let { File(it) } + val shouldUseOriginal = originalSize > 0 && + file.length() >= originalSize && + previewFile?.exists() == true + val outputFile = if (shouldUseOriginal) previewFile!! else file + val dimensions = if (shouldUseOriginal && + originalWidth > 0 && + originalHeight > 0 + ) { + Pair(originalWidth, originalHeight) + } else { + readDimensionsFromFile(outputFile) + } + val outputSize = if (shouldUseOriginal && originalSize > 0) { + originalSize + } else { + outputFile.length() + } + runOnUiThread { + result.success( + mapOf( + "path" to outputFile.absolutePath, + "outputSize" to outputSize, + "width" to dimensions.first, + "height" to dimensions.second, + "passthrough" to shouldUseOriginal, + ), + ) + } + } + + override fun onError(e: Throwable) { + runOnUiThread { + result.error("compress_failed", e.message, null) + } + } + }) + .launch() + } catch (error: Exception) { + result.error("compress_failed", error.message, null) + } + } + + private fun overwriteOriginal(arguments: Any?, result: MethodChannel.Result) { + val args = arguments as? Map<*, *> + val source = args?.get("sourceHandle") as? String + val compressedPath = args?.get("compressedPath") as? String + if (source.isNullOrBlank() || compressedPath.isNullOrBlank()) { + result.error("bad_args", "缺少覆盖参数", null) + return + } + + try { + copyFileToUri(File(compressedPath), Uri.parse(source)) + result.success(mapOf("overwritten" to true, "target" to source)) + } catch (error: Exception) { + result.error("overwrite_failed", error.message, null) + } + } + + private fun shareImage(arguments: Any?, result: MethodChannel.Result) { + val args = arguments as? Map<*, *> + val compressedPath = args?.get("compressedPath") as? String + val suggestedName = args?.get("suggestedName") as? String ?: "luban-image.jpg" + if (compressedPath.isNullOrBlank()) { + result.error("bad_args", "缺少 compressedPath", null) + return + } + + try { + val shareFile = makeShareCopy(File(compressedPath), suggestedName) + val shareUri = FileProvider.getUriForFile( + this, + "$packageName.fileprovider", + shareFile, + ) + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = mimeTypeForName(shareFile.name) + putExtra(Intent.EXTRA_STREAM, shareUri) + clipData = ClipData.newUri(contentResolver, shareFile.name, shareUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(Intent.createChooser(shareIntent, getString(R.string.share_image_title))) + result.success(mapOf("shared" to true)) + } catch (error: Exception) { + result.error("share_failed", error.message, null) + } + } + + private fun saveToGallery(arguments: Any?, result: MethodChannel.Result) { + val args = arguments as? Map<*, *> + val compressedPath = args?.get("compressedPath") as? String + val suggestedName = args?.get("suggestedName") as? String ?: "luban-image.jpg" + if (compressedPath.isNullOrBlank()) { + result.error("bad_args", "缺少 compressedPath", null) + return + } + + try { + val savedUri = writeImageToGallery(File(compressedPath), suggestedName) + result.success(mapOf("savedTo" to savedUri.toString())) + } catch (error: Exception) { + result.error("save_failed", error.message, null) + } + } + + private fun copyUriToCache(uri: Uri, displayName: String, child: String): File { + val dir = File(cacheDir, child).apply { mkdirs() } + val safeName = sanitizeFileName(displayName) + val target = File(dir, "${UUID.randomUUID()}-$safeName") + contentResolver.openInputStream(uri).use { input -> + requireNotNull(input) { "无法读取图片" } + FileOutputStream(target).use { output -> input.copyTo(output) } + } + return target + } + + private fun writeImageToGallery(source: File, suggestedName: String): Uri { + require(source.exists()) { "压缩文件不存在" } + val safeName = sanitizeFileName(suggestedName) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + writeImageToMediaStore(source, safeName) + } else { + writeImageToPublicPictures(source, safeName) + } + } + + private fun writeImageToMediaStore(source: File, safeName: String): Uri { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, safeName) + put(MediaStore.Images.Media.MIME_TYPE, mimeTypeForName(safeName)) + put( + MediaStore.Images.Media.RELATIVE_PATH, + "${Environment.DIRECTORY_PICTURES}/Luban Imager", + ) + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val collection = MediaStore.Images.Media.getContentUri( + MediaStore.VOLUME_EXTERNAL_PRIMARY, + ) + val uri = requireNotNull(contentResolver.insert(collection, values)) { + "无法创建相册图片" + } + + try { + copyFileToUri(source, uri) + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + contentResolver.update(uri, values, null, null) + return uri + } catch (error: Exception) { + contentResolver.delete(uri, null, null) + throw error + } + } + + @Suppress("DEPRECATION") + private fun writeImageToPublicPictures(source: File, safeName: String): Uri { + val dir = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + "Luban Imager", + ).apply { mkdirs() } + val target = uniqueFile(dir, safeName) + source.inputStream().use { input -> + FileOutputStream(target).use { output -> input.copyTo(output) } + } + MediaScannerConnection.scanFile( + this, + arrayOf(target.absolutePath), + arrayOf(mimeTypeForName(target.name)), + null, + ) + return Uri.fromFile(target) + } + + private fun uniqueFile(dir: File, name: String): File { + val dot = name.lastIndexOf('.') + val baseName = if (dot > 0) name.substring(0, dot) else name + val extension = if (dot > 0) name.substring(dot) else "" + var index = 0 + var candidate = File(dir, name) + + while (candidate.exists()) { + index += 1 + candidate = File(dir, "${baseName}_$index$extension") + } + return candidate + } + + private fun makeShareCopy(source: File, suggestedName: String): File { + require(source.exists()) { "压缩文件不存在" } + val dir = File(cacheDir, "shared").apply { mkdirs() } + val target = File(dir, sanitizeFileName(suggestedName)) + if (target.exists()) { + target.delete() + } + source.inputStream().use { input -> + FileOutputStream(target).use { output -> input.copyTo(output) } + } + return target + } + + private fun copyFileToUri(source: File, targetUri: Uri) { + require(source.exists()) { "压缩文件不存在" } + source.inputStream().use { input -> + if (targetUri.scheme == "file") { + val path = requireNotNull(targetUri.path) { "目标路径无效" } + FileOutputStream(File(path)).use { output -> input.copyTo(output) } + } else { + contentResolver.openOutputStream(targetUri, "wt").use { output -> + requireNotNull(output) { "无法写入目标文件" } + input.copyTo(output) + } + } + } + } + + private fun queryDisplayName(uri: Uri): String? { + contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0) { + return cursor.getString(index) + } + } + } + return uri.lastPathSegment + } + + private fun querySize(uri: Uri): Long { + contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndex(OpenableColumns.SIZE) + if (index >= 0) { + return cursor.getLong(index) + } + } + } + return -1L + } + + private fun readDimensionsFromUri(uri: Uri): Pair { + return try { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + contentResolver.openInputStream(uri).use { input -> + BitmapFactory.decodeStream(input, null, options) + } + Pair(options.outWidth.coerceAtLeast(0), options.outHeight.coerceAtLeast(0)) + } catch (_: Exception) { + Pair(0, 0) + } + } + + private fun readDimensionsFromFile(file: File): Pair { + return try { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, options) + Pair(options.outWidth.coerceAtLeast(0), options.outHeight.coerceAtLeast(0)) + } catch (_: Exception) { + Pair(0, 0) + } + } + + private fun canOverwrite(uri: Uri): Boolean { + return try { + if (uri.scheme == "file") { + val path = uri.path ?: return false + return File(path).canWrite() + } + contentResolver.openFileDescriptor(uri, "rw")?.use { true } ?: false + } catch (_: Exception) { + false + } + } + + private fun sanitizeFileName(name: String): String { + val safe = name.replace(Regex("[^A-Za-z0-9._-]"), "_") + return safe.ifBlank { "image.jpg" } + } + + private fun mimeTypeForName(name: String): String { + val extension = name.substringAfterLast('.', "").lowercase() + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "image/jpeg" + } +} diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..5b07e89 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..6e339b6 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..f87fe32 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..ced1385 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..1627258 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml new file mode 100644 index 0000000..e46869a --- /dev/null +++ b/android/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,5 @@ + + + 鲁班压图 + 分享图片 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..f441e30 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Luban Imager + Share image + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..da52945 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/assets/icon/luban_imager_icon.svg b/assets/icon/luban_imager_icon.svg new file mode 100644 index 0000000..778a7e1 --- /dev/null +++ b/assets/icon/luban_imager_icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icon/luban_imager_icon_1024.png b/assets/icon/luban_imager_icon_1024.png new file mode 100644 index 0000000..5e53906 Binary files /dev/null and b/assets/icon/luban_imager_icon_1024.png differ diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..2917eca --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,638 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 5E7A01B32ECBC56A00123456 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5E7A01B72ECBC56A00123456 /* InfoPlist.strings */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 5E7A01B42ECBC56A00123456 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; + 5E7A01B52ECBC56A00123456 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; + 5E7A01B62ECBC56A00123456 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 5E7A01B72ECBC56A00123456 /* InfoPlist.strings */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + "zh-Hans", + "zh-Hant", + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 5E7A01B32ECBC56A00123456 /* InfoPlist.strings in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 5E7A01B72ECBC56A00123456 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 5E7A01B42ECBC56A00123456 /* en */, + 5E7A01B52ECBC56A00123456 /* zh-Hans */, + 5E7A01B62ECBC56A00123456 /* zh-Hant */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.lubanImager; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.lubanImager.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.lubanImager.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.lubanImager.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.lubanImager; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.lubanImager; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -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) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..0e5d040 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..57ebb21 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..84ae4cc Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..1c1cb16 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..acb0064 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..965f22c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..1b5cf04 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..d758ecb Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..d713566 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..f89ee65 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..f89ee65 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..cfea5b3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..f797149 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..02a0ee6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..8c9a69f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -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" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -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. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..a44ca61 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,89 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Luban Imager + CFBundleDocumentTypes + + + CFBundleTypeName + Image + LSHandlerRank + Alternate + LSItemContentTypes + + public.image + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Luban Imager + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSPhotoLibraryAddUsageDescription + 保存压缩图片到系统相册。 + NSPhotoLibraryUsageDescription + 保存压缩图片到系统相册。 + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..e127ed8 --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -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) { + 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, + 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 + } + } +} diff --git a/ios/Runner/en.lproj/InfoPlist.strings b/ios/Runner/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..8585b55 --- /dev/null +++ b/ios/Runner/en.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"CFBundleDisplayName" = "Luban Imager"; +"CFBundleName" = "Luban Imager"; diff --git a/ios/Runner/zh-Hans.lproj/InfoPlist.strings b/ios/Runner/zh-Hans.lproj/InfoPlist.strings new file mode 100644 index 0000000..5a08a67 --- /dev/null +++ b/ios/Runner/zh-Hans.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"CFBundleDisplayName" = "鲁班压图"; +"CFBundleName" = "鲁班压图"; diff --git a/ios/Runner/zh-Hant.lproj/InfoPlist.strings b/ios/Runner/zh-Hant.lproj/InfoPlist.strings new file mode 100644 index 0000000..5a08a67 --- /dev/null +++ b/ios/Runner/zh-Hant.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"CFBundleDisplayName" = "鲁班压图"; +"CFBundleName" = "鲁班压图"; diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..e3451de --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,1170 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void main() { + runApp(const LubanImagerApp()); +} + +class LubanImagerApp extends StatefulWidget { + const LubanImagerApp({super.key}); + + @override + State createState() => _LubanImagerAppState(); +} + +class _LubanImagerAppState extends State + with WidgetsBindingObserver { + late String _appName; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _appName = AppName.forLocales( + WidgetsBinding.instance.platformDispatcher.locales, + ); + } + + @override + void didChangeLocales(List? locales) { + setState(() { + _appName = AppName.forLocales( + locales ?? WidgetsBinding.instance.platformDispatcher.locales, + ); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: _appName, + theme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF2F6F73), + brightness: Brightness.light, + ), + scaffoldBackgroundColor: const Color(0xFFF7F8F6), + appBarTheme: const AppBarTheme( + centerTitle: false, + backgroundColor: Color(0xFFF7F8F6), + foregroundColor: Color(0xFF172122), + elevation: 0, + ), + ), + home: ImagerHomePage(appName: _appName), + ); + } +} + +class AppName { + static const english = 'Luban Imager'; + static const chinese = '鲁班压图'; + + static String forLocales(List locales) { + final locale = locales.isNotEmpty + ? locales.first + : WidgetsBinding.instance.platformDispatcher.locale; + return forLocale(locale); + } + + static String forLocale(Locale locale) { + return locale.languageCode.toLowerCase() == 'zh' ? chinese : english; + } +} + +class ImagerHomePage extends StatefulWidget { + const ImagerHomePage({required this.appName, super.key}); + + final String appName; + + @override + State createState() => _ImagerHomePageState(); +} + +class _ImagerHomePageState extends State { + static const MethodChannel _channel = MethodChannel( + 'luban_imager/native_images', + ); + + ImageJob? _job; + var _previewMode = PreviewMode.original; + var _compareFraction = 0.5; + var _isPicking = false; + OverlayEntry? _toastEntry; + Timer? _toastTimer; + + @override + void initState() { + super.initState(); + _channel.setMethodCallHandler(_handleNativeCall); + WidgetsBinding.instance.addPostFrameCallback((_) { + _takeSharedImages(); + }); + } + + @override + void dispose() { + _channel.setMethodCallHandler(null); + _removeToast(); + super.dispose(); + } + + Future _handleNativeCall(MethodCall call) async { + switch (call.method) { + case 'sharedImages': + await _handleNativeImages(call.arguments); + return null; + default: + throw MissingPluginException('No handler for ${call.method}'); + } + } + + Future _takeSharedImages() async { + try { + final response = await _channel.invokeMethod>( + 'takeSharedImages', + ); + await _handleNativeImages(response); + } on MissingPluginException { + // Widget tests and unsupported platforms do not provide the native bridge. + } on PlatformException catch (error) { + _showMessage(error.message ?? '读取分享图片失败'); + } + } + + Future _handleNativeImages(Object? response) async { + final images = response is List ? response : const []; + if (!mounted || images.isEmpty) { + return; + } + + final first = images.first; + if (first is! Map) { + return; + } + + await _startImageJob( + NativeImage.fromMap(Map.from(first)), + message: '已接收分享图片,正在压缩', + ); + } + + Future _startImageJob(NativeImage image, {String? message}) async { + if (!mounted) { + return; + } + + setState(() { + _job = ImageJob(image); + _previewMode = PreviewMode.original; + _compareFraction = 0.5; + }); + if (message != null) { + _showMessage(message); + } + await _compressSelected(); + } + + Future _pickImages() async { + if (_isPicking) { + return; + } + + NativeImage? picked; + setState(() { + _isPicking = true; + }); + + try { + final response = await _channel.invokeMethod>('pickImages'); + final images = response ?? []; + + if (!mounted || images.isEmpty) { + return; + } + + picked = NativeImage.fromMap( + Map.from(images.first as Map), + ); + } on PlatformException catch (error) { + _showMessage(error.message ?? '选择图片失败'); + } finally { + if (mounted) { + setState(() { + _isPicking = false; + }); + } + } + + if (picked != null && mounted) { + await _startImageJob(picked); + } + } + + Future _compressSelected() async { + final job = _job; + if (job == null) { + return; + } + if (job.isCompressing) { + return; + } + + setState(() { + job + ..isCompressing = true + ..error = null; + }); + + try { + final response = await _channel + .invokeMethod>('compressImage', { + 'sourceHandle': job.original.sourceHandle, + 'displayName': job.original.displayName, + 'previewPath': job.original.previewPath, + 'originalSize': job.original.originalSize, + 'originalWidth': job.original.width, + 'originalHeight': job.original.height, + }); + if (response == null) { + throw const FormatException('native compressor returned no data'); + } + final compressed = CompressedImage.fromMap(response); + + if (!mounted) { + return; + } + setState(() { + job + ..compressed = compressed + ..isCompressing = false + ..overwritten = false; + _previewMode = PreviewMode.compare; + }); + _showMessage( + compressed.passthrough + ? '压缩后比原图更大,已使用原图' + : '压缩完成,节省 ${_savingLabel(job)}', + ); + } on Object catch (error) { + if (!mounted) { + return; + } + setState(() { + job + ..isCompressing = false + ..error = _errorText(error); + }); + _showMessage(job.error ?? '压缩失败'); + } + } + + Future _saveSelected() async { + final job = _job; + final compressed = job?.compressed; + if (job == null || compressed == null) { + return; + } + + try { + await _channel.invokeMethod>('saveToGallery', { + 'compressedPath': compressed.path, + 'suggestedName': _suggestedName(job.original.displayName), + }); + _showMessage('已保存到相册'); + } on PlatformException catch (error) { + _showMessage(error.message ?? '保存失败'); + } + } + + Future _shareSelected() async { + await _openShareSheet(); + } + + Future _openShareSheet() async { + final job = _job; + final compressed = job?.compressed; + if (job == null || compressed == null) { + return; + } + + try { + await _channel.invokeMethod>('shareImage', { + 'compressedPath': compressed.path, + 'suggestedName': _suggestedName(job.original.displayName), + }); + } on PlatformException catch (error) { + _showMessage(error.message ?? '分享失败'); + } + } + + Future _overwriteSelected() async { + final job = _job; + final compressed = job?.compressed; + if (job == null || compressed == null) { + return; + } + if (!job.original.canOverwrite) { + _showMessage('当前来源不支持原位覆盖,请使用保存'); + return; + } + + final confirmed = await _confirmOverwrite(job.original.displayName); + if (!confirmed || !mounted) { + return; + } + + try { + await _channel.invokeMethod>('overwriteOriginal', { + 'sourceHandle': job.original.sourceHandle, + 'compressedPath': compressed.path, + }); + if (!mounted) { + return; + } + setState(() { + job.overwritten = true; + }); + _showMessage('已覆盖'); + } on PlatformException catch (error) { + _showMessage(error.message ?? '覆盖失败'); + } + } + + Future _confirmOverwrite(String name) async { + final first = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('覆盖'), + content: Text('将用压缩后的图片替换「$name」,此操作无法从 App 内撤销。'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('继续'), + ), + ], + ), + ); + if (first != true || !mounted) { + return false; + } + + final second = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('再次确认'), + content: const Text('确认覆盖?建议先保存备份。'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('取消'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('覆盖'), + ), + ], + ), + ); + return second == true; + } + + void _clearImage() { + setState(() { + _job = null; + _previewMode = PreviewMode.original; + _compareFraction = 0.5; + }); + } + + void _showMessage(String message) { + if (!mounted) { + return; + } + + _removeToast(); + + final overlay = Overlay.maybeOf(context, rootOverlay: true); + if (overlay == null) { + return; + } + + final colorScheme = Theme.of(context).colorScheme; + final textStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onInverseSurface, + fontWeight: FontWeight.w700, + ); + + _toastEntry = OverlayEntry( + builder: (context) { + final top = MediaQuery.of(context).padding.top + kToolbarHeight + 8; + return Positioned( + left: 16, + right: 16, + top: top, + child: IgnorePointer( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Material( + color: colorScheme.inverseSurface, + elevation: 8, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + child: Text( + message, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: textStyle, + ), + ), + ), + ), + ), + ), + ); + }, + ); + overlay.insert(_toastEntry!); + _toastTimer = Timer(const Duration(seconds: 1), () { + _toastEntry?.remove(); + _toastEntry = null; + _toastTimer = null; + }); + } + + void _removeToast() { + _toastTimer?.cancel(); + _toastTimer = null; + _toastEntry?.remove(); + _toastEntry = null; + } + + @override + Widget build(BuildContext context) { + final job = _job; + + return Scaffold( + appBar: AppBar( + title: Text(widget.appName), + actions: job == null + ? null + : [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: TextButton.icon( + onPressed: _isPicking ? null : _pickImages, + icon: const Icon(Icons.add_photo_alternate_outlined), + label: const Text('重新选择'), + ), + ), + ], + ), + body: SafeArea( + child: job == null ? _buildEmptyState() : _buildPreviewPanel(job), + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.image_search_outlined, + size: 72, + color: Color(0xFF5D6F70), + ), + const SizedBox(height: 20), + Text( + '选择图片开始压缩', + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _isPicking ? null : _pickImages, + icon: const Icon(Icons.add_photo_alternate_outlined), + label: const Text('选择图片'), + ), + ], + ), + ), + ); + } + + Widget _buildPreviewPanel(ImageJob job) { + final compressed = job.compressed; + final canCompare = compressed != null; + final mode = canCompare ? _previewMode : PreviewMode.original; + + return LayoutBuilder( + builder: (context, constraints) { + final showCompareSlider = mode == PreviewMode.compare && canCompare; + final showWarning = compressed?.passthrough == true; + const minPreviewHeight = 200.0; + const baseChromeHeight = 360.0; + const compareSliderHeight = 52.0; + const warningHeight = 58.0; + // Keep the panel fixed-height while the preview can stay at least 200px. + final chromeHeight = + baseChromeHeight + + (showWarning ? warningHeight : 0.0) - + (showCompareSlider ? 0.0 : compareSliderHeight); + final availablePreviewHeight = constraints.maxHeight - chromeHeight; + final previewHeight = math.max( + minPreviewHeight, + availablePreviewHeight, + ); + + List buildContent() { + return [ + SizedBox( + height: 40, + child: Row( + children: [ + Expanded( + child: Text( + job.original.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + tooltip: '移除', + visualDensity: VisualDensity.compact, + onPressed: _clearImage, + icon: const Icon(Icons.close), + ), + ], + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _MetricBlock( + label: '原图', + value: _formatSize(job.original.originalSize), + detail: '${job.original.width}×${job.original.height}', + ), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBlock( + label: '压缩后', + value: compressed == null + ? '--' + : _formatSize(compressed.outputSize), + detail: compressed == null + ? '--' + : '${compressed.width}×${compressed.height}', + ), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBlock( + label: '节省', + value: compressed == null ? '--' : _savingLabel(job), + detail: compressed?.passthrough == true ? '压缩后更大' : '', + ), + ), + ], + ), + if (showWarning) ...[ + const SizedBox(height: 10), + const _WarningStrip(message: '压缩结果比原图更大,已自动使用原图。'), + ], + const SizedBox(height: 14), + SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment( + value: PreviewMode.original, + icon: Icon(Icons.image_outlined), + label: Text('原图'), + ), + ButtonSegment( + value: PreviewMode.compressed, + icon: Icon(Icons.photo_size_select_large_outlined), + label: Text('压缩'), + ), + ButtonSegment( + value: PreviewMode.compare, + icon: Icon(Icons.compare_outlined), + label: Text('对比'), + ), + ], + selected: {mode}, + onSelectionChanged: canCompare + ? (selection) { + setState(() { + _previewMode = selection.first; + }); + } + : null, + ), + const SizedBox(height: 14), + SizedBox( + height: previewHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFFEDEFEA), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFDADFD8)), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: _buildImagePreview(job, mode), + ), + ), + ), + if (showCompareSlider) ...[ + const SizedBox(height: 4), + Slider( + value: _compareFraction, + min: 0.05, + max: 0.95, + onChanged: (value) { + setState(() { + _compareFraction = value; + }); + }, + ), + ], + const SizedBox(height: 10), + Wrap( + spacing: 10, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + FilledButton.icon( + onPressed: compressed == null || job.isCompressing + ? null + : _saveSelected, + icon: const Icon(Icons.save_alt_outlined), + label: const Text('保存'), + ), + FilledButton.tonalIcon( + onPressed: compressed == null || job.isCompressing + ? null + : _shareSelected, + icon: const Icon(Icons.ios_share_outlined), + label: const Text('分享'), + ), + FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + onPressed: compressed == null ? null : _overwriteSelected, + icon: const Icon(Icons.warning_amber_rounded), + label: const Text('覆盖'), + ), + ], + ), + ]; + } + + final child = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: buildContent(), + ); + if (availablePreviewHeight >= minPreviewHeight) { + return Padding(padding: const EdgeInsets.all(16), child: child); + } + + return Scrollbar( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: child, + ), + ); + }, + ); + } + + Widget _buildImagePreview(ImageJob job, PreviewMode mode) { + final compressedPath = job.compressed?.path; + if (mode == PreviewMode.compressed && compressedPath != null) { + return _ZoomablePreview( + width: job.compressed?.width ?? job.original.width, + height: job.compressed?.height ?? job.original.height, + child: _PreviewImage(path: compressedPath), + ); + } + if (mode == PreviewMode.compare && compressedPath != null) { + return _ZoomablePreview( + width: job.original.width, + height: job.original.height, + overlay: const Stack( + children: [ + Positioned(left: 12, top: 12, child: _PreviewBadge(label: '原图')), + Positioned(right: 12, top: 12, child: _PreviewBadge(label: '压缩')), + ], + ), + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( + fit: StackFit.expand, + children: [ + _PreviewImage(path: compressedPath), + ClipRect( + clipper: _CompareClipper(_compareFraction), + child: _PreviewImage(path: job.original.previewPath), + ), + Positioned( + left: constraints.maxWidth * _compareFraction - 1, + top: 0, + bottom: 0, + child: Container(width: 2, color: Colors.white), + ), + ], + ); + }, + ), + ); + } + return _ZoomablePreview( + width: job.original.width, + height: job.original.height, + child: _PreviewImage(path: job.original.previewPath), + ); + } + + String _savingLabel(ImageJob job) { + final compressed = job.compressed; + if (compressed == null || job.original.originalSize <= 0) { + return '--'; + } + if (compressed.passthrough) { + return '使用原图'; + } + final saved = job.original.originalSize - compressed.outputSize; + if (saved <= 0) { + return '0%'; + } + return '${(saved / job.original.originalSize * 100).toStringAsFixed(1)}%'; + } + + String _suggestedName(String originalName) { + final dot = originalName.lastIndexOf('.'); + if (dot <= 0) { + return '${originalName}_luban.jpg'; + } + return '${originalName.substring(0, dot)}_luban${originalName.substring(dot)}'; + } + + String _formatSize(int bytes) { + if (bytes <= 0) { + return '0 B'; + } + const units = ['B', 'KB', 'MB', 'GB']; + var value = bytes.toDouble(); + var unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + final digits = unit == 0 ? 0 : 1; + return '${value.toStringAsFixed(digits)} ${units[unit]}'; + } + + String _errorText(Object error) { + if (error is PlatformException) { + return error.message ?? error.code; + } + return error.toString(); + } +} + +enum PreviewMode { original, compressed, compare } + +class NativeImage { + NativeImage({ + required this.sourceHandle, + required this.displayName, + required this.previewPath, + required this.originalSize, + required this.width, + required this.height, + required this.canOverwrite, + }); + + factory NativeImage.fromMap(Map map) { + return NativeImage( + sourceHandle: map['sourceHandle'] as String, + displayName: map['displayName'] as String? ?? 'image', + previewPath: map['previewPath'] as String, + originalSize: (map['originalSize'] as num?)?.toInt() ?? 0, + width: (map['width'] as num?)?.toInt() ?? 0, + height: (map['height'] as num?)?.toInt() ?? 0, + canOverwrite: map['canOverwrite'] as bool? ?? false, + ); + } + + final String sourceHandle; + final String displayName; + final String previewPath; + final int originalSize; + final int width; + final int height; + final bool canOverwrite; +} + +class CompressedImage { + CompressedImage({ + required this.path, + required this.outputSize, + required this.width, + required this.height, + required this.passthrough, + }); + + factory CompressedImage.fromMap(Map map) { + return CompressedImage( + path: map['path'] as String, + outputSize: (map['outputSize'] as num?)?.toInt() ?? 0, + width: (map['width'] as num?)?.toInt() ?? 0, + height: (map['height'] as num?)?.toInt() ?? 0, + passthrough: map['passthrough'] as bool? ?? false, + ); + } + + final String path; + final int outputSize; + final int width; + final int height; + final bool passthrough; +} + +class ImageJob { + ImageJob(this.original); + + final NativeImage original; + CompressedImage? compressed; + bool isCompressing = false; + bool overwritten = false; + String? error; +} + +class _WarningStrip extends StatelessWidget { + const _WarningStrip({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.error; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.28)), + ), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, size: 18, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: TextStyle(color: color, fontWeight: FontWeight.w700), + ), + ), + ], + ), + ); + } +} + +class _MetricBlock extends StatelessWidget { + const _MetricBlock({ + required this.label, + required this.value, + required this.detail, + }); + + final String label; + final String value; + final String detail; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFDADFD8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: const Color(0xFF5D6F70)), + ), + const SizedBox(height: 7), + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 2), + Text( + detail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } +} + +class _ZoomablePreview extends StatefulWidget { + const _ZoomablePreview({ + required this.width, + required this.height, + required this.child, + this.overlay, + }); + + final int width; + final int height; + final Widget child; + final Widget? overlay; + + @override + State<_ZoomablePreview> createState() => _ZoomablePreviewState(); +} + +class _ZoomablePreviewState extends State<_ZoomablePreview> { + static const _minScale = 1.0; + static const _maxScale = 8.0; + + Size _viewportSize = Size.zero; + Size _canvasSize = Size.zero; + var _scale = _minScale; + var _offset = Offset.zero; + var _gestureStartScale = _minScale; + var _gestureStartOffset = Offset.zero; + var _gestureStartScenePoint = Offset.zero; + + @override + void didUpdateWidget(covariant _ZoomablePreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.width != widget.width || oldWidget.height != widget.height) { + _resetTransform(); + } + } + + void _resetTransform() { + _scale = _minScale; + _offset = Offset.zero; + } + + double _clampScale(double scale) { + return scale.clamp(_minScale, _maxScale).toDouble(); + } + + Offset _clampOffset(Offset offset, double scale) { + final maxX = math.max( + 0.0, + (_canvasSize.width * scale - _viewportSize.width) / 2, + ); + final maxY = math.max( + 0.0, + (_canvasSize.height * scale - _viewportSize.height) / 2, + ); + + return Offset( + maxX == 0 ? 0 : offset.dx.clamp(-maxX, maxX).toDouble(), + maxY == 0 ? 0 : offset.dy.clamp(-maxY, maxY).toDouble(), + ); + } + + void _startGesture(ScaleStartDetails details) { + _gestureStartScale = _scale; + _gestureStartOffset = _offset; + final center = _viewportSize.center(Offset.zero); + _gestureStartScenePoint = + (details.localFocalPoint - center - _gestureStartOffset) / _scale; + } + + void _updateGesture(ScaleUpdateDetails details) { + final nextScale = _clampScale(_gestureStartScale * details.scale); + final center = _viewportSize.center(Offset.zero); + final nextOffset = + details.localFocalPoint - center - _gestureStartScenePoint * nextScale; + + setState(() { + _scale = nextScale; + _offset = _clampOffset(nextOffset, nextScale); + }); + } + + void _endGesture(ScaleEndDetails details) { + setState(() { + _scale = _clampScale(_scale); + _offset = _clampOffset(_offset, _scale); + }); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + _viewportSize = Size(constraints.maxWidth, constraints.maxHeight); + + final imageWidth = math.max(1, widget.width).toDouble(); + final imageHeight = math.max(1, widget.height).toDouble(); + final aspectRatio = imageWidth / imageHeight; + var canvasWidth = constraints.maxWidth; + var canvasHeight = canvasWidth / aspectRatio; + + if (canvasHeight > constraints.maxHeight) { + canvasHeight = constraints.maxHeight; + canvasWidth = canvasHeight * aspectRatio; + } + _canvasSize = Size(canvasWidth, canvasHeight); + _scale = _clampScale(_scale); + _offset = _clampOffset(_offset, _scale); + + return ClipRect( + child: Stack( + fit: StackFit.expand, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onScaleStart: _startGesture, + onScaleUpdate: _updateGesture, + onScaleEnd: _endGesture, + child: SizedBox.expand( + child: Center( + child: Transform.translate( + offset: _offset, + child: Transform.scale( + scale: _scale, + child: SizedBox( + width: canvasWidth, + height: canvasHeight, + child: widget.child, + ), + ), + ), + ), + ), + ), + if (widget.overlay != null) + Positioned.fill(child: IgnorePointer(child: widget.overlay)), + ], + ), + ); + }, + ); + } +} + +class _PreviewImage extends StatelessWidget { + const _PreviewImage({required this.path}); + + final String path; + + @override + Widget build(BuildContext context) { + return Image.file( + File(path), + fit: BoxFit.contain, + gaplessPlayback: true, + errorBuilder: (context, error, stackTrace) { + return const Center(child: Icon(Icons.broken_image_outlined, size: 48)); + }, + ); + } +} + +class _PreviewBadge extends StatelessWidget { + const _PreviewBadge({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.62), + borderRadius: BorderRadius.circular(99), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } +} + +class _CompareClipper extends CustomClipper { + const _CompareClipper(this.fraction); + + final double fraction; + + @override + Rect getClip(Size size) { + return Rect.fromLTWH(0, 0, size.width * fraction, size.height); + } + + @override + bool shouldReclip(_CompareClipper oldClipper) { + return oldClipper.fraction != fraction; + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..4173763 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,213 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" +sdks: + dart: ">=3.11.5 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..bc6cd56 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,89 @@ +name: luban_imager +description: "A Flutter image compression app powered by Luban-style smart compression." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.11.5 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..9ab86a4 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,26 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:luban_imager/main.dart'; + +void main() { + testWidgets('uses English app name by default', (tester) async { + await tester.pumpWidget(const LubanImagerApp()); + + expect(find.text(AppName.english), findsOneWidget); + expect(find.text('选择图片'), findsWidgets); + }); + + testWidgets('uses Chinese app name for Chinese locale', (tester) async { + tester.binding.platformDispatcher.localesTestValue = const [ + Locale('zh', 'CN'), + ]; + addTearDown(tester.binding.platformDispatcher.clearLocalesTestValue); + + await tester.pumpWidget(const LubanImagerApp()); + + expect(find.text(AppName.chinese), findsOneWidget); + expect(find.text(AppName.english), findsNothing); + expect(find.text('选择图片'), findsWidgets); + }); +}