diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index de45ce382..9fa6dccae 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -47,9 +47,13 @@ jobs:
- name: đŠ Check if package is ready for publishing
run: flutter pub publish --dry-run
- - name: đ§Ș Run Flutter tests
+ - name: đ§Ș Run flutter_quill tests
run: flutter test
+ - name: đ§Ș Run flutter_quill_extensions tests
+ run: flutter test
+ working-directory: flutter_quill_extensions
+
- name: đ Check the translations
run: dart ./scripts/translations_check.dart
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc16c74a2..863c13fff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **BREAKING**: The magnifier feature due to buggy behavior [#2413](https://github.com/singerdmx/flutter-quill/pull/2413). See [#2406](https://github.com/singerdmx/flutter-quill/issues/2406) for a list of reasons.
+## [11.0.0-dev.15] - 2024-12-13
+
+### Added
+
+- New localization strings for the image save functionality [#2403](https://github.com/singerdmx/flutter-quill/pull/2403).
+
+### Changed
+
+- Rewrite the image save functionality for [`flutter_quill_extensions`](https://pub.dev/packages/flutter_quill_extensions) [#2403](https://github.com/singerdmx/flutter-quill/pull/2403).
+- Migrate [quill_native_bridge](https://pub.dev/packages/quill_native_bridge) to `11.0.0` [#2403](https://github.com/singerdmx/flutter-quill/pull/2403).
+- Avoid using deprecated APIs in Flutter 3.27.0 [#2416](https://github.com/singerdmx/flutter-quill/pull/2416):
+ - Migrate from `withOpacity` to `withValues` according to [Color wide gamut - Opacity migration](https://docs.flutter.dev/release/breaking-changes/wide-gamut-framework#opacity).
+ - Avoid using the deprecated `Color.value` getter.
+- Ignore `unreachable_switch_default` warning (introduced in Dart 3.6) [#2416](https://github.com/singerdmx/flutter-quill/pull/2416).
+- Update `intl` dependency to support versions `0.19.0` and `0.20.0` [#2416](https://github.com/singerdmx/flutter-quill/pull/2416).
+
+### Fixed
+
+- Avoid using [`url_launcher_string.dart`](https://pub.dev/documentation/url_launcher/latest/url_launcher_string/url_launcher_string-library.html) which is [**strongly discouraged**](https://pub.dev/packages/url_launcher#urls-not-handled-by-uri) [#2403](https://github.com/singerdmx/flutter-quill/pull/2403).
+
## [11.0.0-dev.14] - 2024-11-24
### Changed
@@ -137,7 +157,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Apple-specific font dependency for subscript and superscript functionality from the example.
- **BREAKING**: The [`super_clipboard`](https://pub.dev/packages/super_clipboard) plugin, To restore legacy behavior for `super_clipboard`, use [`flutter_quill_extensions`](https://pub.dev/packages/flutter_quill_extensions) package and `FlutterQuillExtensions.useSuperClipboardPlugin()`.
-[unreleased]: https://github.com/singerdmx/flutter-quill/compare/v11.0.0-dev.14...HEAD
+[unreleased]: https://github.com/singerdmx/flutter-quill/compare/v11.0.0-dev.15...HEAD
+[11.0.0-dev.15]: https://github.com/singerdmx/flutter-quill/compare/v11.0.0-dev.14...v11.0.0-dev.15
[11.0.0-dev.14]: https://github.com/singerdmx/flutter-quill/compare/v11.0.0-dev.13...v11.0.0-dev.14
[11.0.0-dev.13]: https://github.com/singerdmx/flutter-quill/compare/v11.0.0-dev.12...v11.0.0-dev.13
[11.0.0-dev.12]: https://github.com/singerdmx/flutter-quill/compare/v11.0.0-dev.11...v11.0.0-dev.12
diff --git a/analysis_options.yaml b/analysis_options.yaml
index b6065426f..ee1ee99a9 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -1,5 +1,9 @@
include: package:flutter_lints/flutter.yaml
+analyzer:
+# TODO: Included for backward compatibility, remove when the minimum Dart SDK is 3.6.0
+ errors:
+ unreachable_switch_default: ignore
linter:
rules:
always_declare_return_types: true
diff --git a/example/.gitignore b/example/.gitignore
index 12b4e0432..4e689af84 100644
--- a/example/.gitignore
+++ b/example/.gitignore
@@ -5,9 +5,11 @@
*.swp
.DS_Store
.atom/
+.build/
.buildlog/
.history
.svn/
+.swiftpm/
migrate_working_dir/
# IntelliJ related
diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml
index c72122824..b12854181 100644
--- a/example/android/app/src/main/AndroidManifest.xml
+++ b/example/android/app/src/main/AndroidManifest.xml
@@ -2,6 +2,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift
index 7d5b8c182..3bcdb912f 100644
--- a/example/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -6,14 +6,12 @@ import FlutterMacOS
import Foundation
import file_selector_macos
-import gal
import quill_native_bridge_macos
import url_launcher_macos
import video_player_avfoundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
- GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
QuillNativeBridgePlugin.register(with: registry.registrar(forPlugin: "QuillNativeBridgePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock
index b3544367a..ce458c1e1 100644
--- a/example/macos/Podfile.lock
+++ b/example/macos/Podfile.lock
@@ -1,48 +1,16 @@
PODS:
- - file_selector_macos (0.0.1):
- - FlutterMacOS
- FlutterMacOS (1.0.0)
- - gal (1.0.0):
- - Flutter
- - FlutterMacOS
- - quill_native_bridge_macos (0.0.1):
- - FlutterMacOS
- - url_launcher_macos (0.0.1):
- - FlutterMacOS
- - video_player_avfoundation (0.0.1):
- - Flutter
- - FlutterMacOS
DEPENDENCIES:
- - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- - gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`)
- - quill_native_bridge_macos (from `Flutter/ephemeral/.symlinks/plugins/quill_native_bridge_macos/macos`)
- - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- - video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`)
EXTERNAL SOURCES:
- file_selector_macos:
- :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
FlutterMacOS:
:path: Flutter/ephemeral
- gal:
- :path: Flutter/ephemeral/.symlinks/plugins/gal/darwin
- quill_native_bridge_macos:
- :path: Flutter/ephemeral/.symlinks/plugins/quill_native_bridge_macos/macos
- url_launcher_macos:
- :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
- video_player_avfoundation:
- :path: Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin
SPEC CHECKSUMS:
- file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
- gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1
- quill_native_bridge_macos: f90985c5269ac7ba84d933605b463d96e5f544fe
- url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
- video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3
PODFILE CHECKSUM: c2e95c8c0fe03c5c57e438583cae4cc732296009
-COCOAPODS: 1.15.2
+COCOAPODS: 1.16.2
diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj
index 6d907a32c..4d1aed5d2 100644
--- a/example/macos/Runner.xcodeproj/project.pbxproj
+++ b/example/macos/Runner.xcodeproj/project.pbxproj
@@ -27,6 +27,7 @@
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
8E3774B08A93A8D54E597870 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C6627E85DE71088607AABDE /* Pods_RunnerTests.framework */; };
CBFCAD814543F1F46CF7EB02 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B7FF1B5F74BB7EE9AEAADC6 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
@@ -103,6 +104,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
CBFCAD814543F1F46CF7EB02 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -240,7 +242,6 @@
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
- 3C5A23DA7F8245D72F435A4A /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -248,6 +249,9 @@
33CC11202044C79F0003C045 /* PBXTargetDependency */,
);
name = Runner;
+ packageProductDependencies = (
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
+ );
productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* flutter_quill_example.app */;
productType = "com.apple.product-type.application";
@@ -292,6 +296,9 @@
Base,
);
mainGroup = 33CC10E42044A3C60003C045;
+ packageReferences = (
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
+ );
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -361,23 +368,6 @@
shellPath = /bin/sh;
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
};
- 3C5A23DA7F8245D72F435A4A /* [CP] Embed Pods Frameworks */ = {
- isa = PBXShellScriptBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- inputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
- );
- name = "[CP] Embed Pods Frameworks";
- outputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
- );
- runOnlyForDeploymentPostprocessing = 0;
- shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
- showEnvVarsInLog = 0;
- };
ADA7845785713A6AD96ACC83 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -796,6 +786,20 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCSwiftPackageProductDependency section */
};
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
}
diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index 5e42bdff0..8222cc2bd 100644
--- a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
+++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -5,6 +5,24 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/example/macos/Runner/AppDelegate.swift b/example/macos/Runner/AppDelegate.swift
index 8e02df288..b3c176141 100644
--- a/example/macos/Runner/AppDelegate.swift
+++ b/example/macos/Runner/AppDelegate.swift
@@ -6,4 +6,8 @@ class AppDelegate: FlutterAppDelegate {
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
+
+ override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
+ return true
+ }
}
diff --git a/example/macos/Runner/DebugProfile.entitlements b/example/macos/Runner/DebugProfile.entitlements
index ef070abf0..681e73fe2 100644
--- a/example/macos/Runner/DebugProfile.entitlements
+++ b/example/macos/Runner/DebugProfile.entitlements
@@ -10,7 +10,7 @@
com.apple.security.network.client
- com.apple.security.files.user-selected.read-only
+ com.apple.security.files.user-selected.read-write
diff --git a/example/macos/Runner/Release.entitlements b/example/macos/Runner/Release.entitlements
index 389e8f35f..43a8b8c70 100644
--- a/example/macos/Runner/Release.entitlements
+++ b/example/macos/Runner/Release.entitlements
@@ -6,7 +6,7 @@
com.apple.security.network.client
- com.apple.security.files.user-selected.read-only
+ com.apple.security.files.user-selected.read-write
diff --git a/example/pubspec.lock b/example/pubspec.lock
index bb29dba5d..38ffef72a 100644
--- a/example/pubspec.lock
+++ b/example/pubspec.lock
@@ -13,18 +13,18 @@ packages:
dependency: transitive
description:
name: async
- sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
+ sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.dev"
source: hosted
- version: "2.12.0"
+ version: "2.11.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
- sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
+ sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
url: "https://pub.dev"
source: hosted
- version: "2.1.2"
+ version: "2.1.1"
characters:
dependency: transitive
description:
@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: clock
- sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
+ sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
url: "https://pub.dev"
source: hosted
- version: "1.1.2"
+ version: "1.1.1"
collection:
dependency: transitive
description:
@@ -93,10 +93,10 @@ packages:
dependency: transitive
description:
name: fake_async
- sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
+ sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
url: "https://pub.dev"
source: hosted
- version: "1.3.2"
+ version: "1.3.1"
ffi:
dependency: transitive
description:
@@ -109,10 +109,10 @@ packages:
dependency: transitive
description:
name: file_selector_linux
- sha256: b2b91daf8a68ecfa4a01b778a6f52edef9b14ecd506e771488ea0f2e0784198b
+ sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33"
url: "https://pub.dev"
source: hosted
- version: "0.9.3+1"
+ version: "0.9.3+2"
file_selector_macos:
dependency: transitive
description:
@@ -217,15 +217,15 @@ packages:
path: ".."
relative: true
source: path
- version: "11.0.0-dev.14"
+ version: "11.0.0-dev.15"
flutter_quill_delta_from_html:
dependency: transitive
description:
name: flutter_quill_delta_from_html
- sha256: "288f879bd11f9b6857868e7b198e69918530bd63d196ead6d8a9ee780b4b44d2"
+ sha256: "63873b5391b56daa999ce8fa7dd23dfd7d0417a70e00a647ba450f4a8988afd0"
url: "https://pub.dev"
source: hosted
- version: "1.4.2"
+ version: "1.4.3"
flutter_quill_extensions:
dependency: "direct main"
description:
@@ -250,22 +250,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
- gal:
- dependency: transitive
- description:
- name: gal
- sha256: "54c9b72528efce7c66234f3b6dd01cb0304fd8af8196de15571d7bdddb940977"
- url: "https://pub.dev"
- source: hosted
- version: "2.3.0"
- gal_linux:
- dependency: transitive
- description:
- name: gal_linux
- sha256: "0040d61843134cc5a93e4597080a86f2ba073217957e28b2a684b4d8b050873c"
- url: "https://pub.dev"
- source: hosted
- version: "0.1.2"
html:
dependency: transitive
description:
@@ -302,10 +286,10 @@ packages:
dependency: transitive
description:
name: image_picker_android
- sha256: "8faba09ba361d4b246dc0a17cb4289b3324c2b9f6db7b3d457ee69106a86bd32"
+ sha256: fa8141602fde3f7e2f81dbf043613eb44dfa325fa0bcf93c0f142c9f7a2c193e
url: "https://pub.dev"
source: hosted
- version: "0.8.12+17"
+ version: "0.8.12+18"
image_picker_for_web:
dependency: transitive
description:
@@ -366,18 +350,18 @@ packages:
dependency: transitive
description:
name: leak_tracker
- sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
+ sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
url: "https://pub.dev"
source: hosted
- version: "10.0.8"
+ version: "10.0.7"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
- sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
+ sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
url: "https://pub.dev"
source: hosted
- version: "3.0.9"
+ version: "3.0.8"
leak_tracker_testing:
dependency: transitive
description:
@@ -438,10 +422,10 @@ packages:
dependency: "direct main"
description:
name: path
- sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
+ sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
- version: "1.9.1"
+ version: "1.9.0"
photo_view:
dependency: transitive
description:
@@ -462,66 +446,66 @@ packages:
dependency: transitive
description:
name: quill_native_bridge
- sha256: "0b3200c57bb4f1f12d6c764648d42482891f20f12024c75fe3479cafc1e132c9"
+ sha256: bda0f0ad9bc160dcdd4bd2b378a7ae8bdb55c3d4b7301bf739d5e3b065ee5e82
url: "https://pub.dev"
source: hosted
- version: "10.7.11"
+ version: "11.0.0"
quill_native_bridge_android:
dependency: transitive
description:
name: quill_native_bridge_android
- sha256: df94b59081dfded10052b61fd4bb80a013499029841d3f64b753b117c8db9dc3
+ sha256: b75c7e6ede362a7007f545118e756b1f19053994144ec9eda932ce5e54a57569
url: "https://pub.dev"
source: hosted
- version: "0.0.1-dev.5"
+ version: "0.0.1+2"
quill_native_bridge_ios:
dependency: transitive
description:
name: quill_native_bridge_ios
- sha256: "2bec0c95558eea85f422767c6ddab87ef774cf5fe4222e295a436a169d314ecc"
+ sha256: d23de3cd7724d482fe2b514617f8eedc8f296e120fb297368917ac3b59d8099f
url: "https://pub.dev"
source: hosted
- version: "0.0.1-dev.6"
+ version: "0.0.1"
quill_native_bridge_linux:
dependency: transitive
description:
name: quill_native_bridge_linux
- sha256: d226004634b69119007dffb95c88e06698644c1b3adfe984b78024129e223426
+ sha256: "5fcc60cab2ab9079e0746941f05c5ca5fec85cc050b738c8c8b9da7c09da17eb"
url: "https://pub.dev"
source: hosted
- version: "0.0.1-dev.4"
+ version: "0.0.1"
quill_native_bridge_macos:
dependency: transitive
description:
name: quill_native_bridge_macos
- sha256: "9bbe855cc2248f098732794a4861852fede97a384299b02646cb3dbfbc996a87"
+ sha256: "1c0631bd1e2eee765a8b06017c5286a4e829778f4585736e048eb67c97af8a77"
url: "https://pub.dev"
source: hosted
- version: "0.0.1-dev.5"
+ version: "0.0.1"
quill_native_bridge_platform_interface:
dependency: transitive
description:
name: quill_native_bridge_platform_interface
- sha256: fdfc1001c9a152eb5789916f55da97a3de548ccd41fe3cdd0bfcc371bdf41d10
+ sha256: "2d71b6c5106db0a4b1d788640d1b949ccdd0e570b5a5e0384f7b28be9630a94a"
url: "https://pub.dev"
source: hosted
- version: "0.0.1-dev.5"
+ version: "0.0.1"
quill_native_bridge_web:
dependency: transitive
description:
name: quill_native_bridge_web
- sha256: ad8076ad740318b3cf6b9a043d3dc206bb2e1383e5371cbe09711b9c609b64a0
+ sha256: e7e55047d68f1a88574c26dbe3f12988f49d07740590d8fc6280028bbde5b908
url: "https://pub.dev"
source: hosted
- version: "0.0.1-dev.6"
+ version: "0.0.1"
quill_native_bridge_windows:
dependency: transitive
description:
name: quill_native_bridge_windows
- sha256: "62583f68dd426aa9a98a0582631780db44d0c6708625d27f84cc87bf02e49ea3"
+ sha256: "60e50d74238f22ceb43113d9a42b6627451dab9fc27f527b979a32051cf1da45"
url: "https://pub.dev"
source: hosted
- version: "0.0.1-dev.4"
+ version: "0.0.1"
quiver:
dependency: transitive
description:
@@ -563,10 +547,10 @@ packages:
dependency: transitive
description:
name: string_scanner
- sha256: "0bd04f5bb74fcd6ff0606a888a30e917af9bd52820b178eaa464beb11dca84b6"
+ sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
url: "https://pub.dev"
source: hosted
- version: "1.4.0"
+ version: "1.3.0"
term_glyph:
dependency: transitive
description:
@@ -627,10 +611,10 @@ packages:
dependency: transitive
description:
name: url_launcher_ios
- sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e
+ sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626"
url: "https://pub.dev"
source: hosted
- version: "6.3.1"
+ version: "6.3.2"
url_launcher_linux:
dependency: transitive
description:
@@ -643,10 +627,10 @@ packages:
dependency: transitive
description:
name: url_launcher_macos
- sha256: "769549c999acdb42b8bcfa7c43d72bf79a382ca7441ab18a808e101149daf672"
+ sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
url: "https://pub.dev"
source: hosted
- version: "3.2.1"
+ version: "3.2.2"
url_launcher_platform_interface:
dependency: transitive
description:
@@ -699,10 +683,10 @@ packages:
dependency: transitive
description:
name: video_player_avfoundation
- sha256: "0b146e5d82e886ff43e5a46c6bcbe390761b802864a6e2503eb612d69a405dfa"
+ sha256: "33224c19775fd244be2d6e3dbd8e1826ab162877bd61123bf71890772119a2b7"
url: "https://pub.dev"
source: hosted
- version: "2.6.3"
+ version: "2.6.5"
video_player_platform_interface:
dependency: transitive
description:
@@ -723,10 +707,10 @@ packages:
dependency: transitive
description:
name: vm_service
- sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
+ sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
url: "https://pub.dev"
source: hosted
- version: "14.3.1"
+ version: "14.3.0"
web:
dependency: transitive
description:
@@ -739,10 +723,10 @@ packages:
dependency: transitive
description:
name: win32
- sha256: "84ba388638ed7a8cb3445a320c8273136ab2631cd5f2c57888335504ddab1bc2"
+ sha256: "8b338d4486ab3fbc0ba0db9f9b4f5239b6697fcee427939a40e720cbb9ee0a69"
url: "https://pub.dev"
source: hosted
- version: "5.8.0"
+ version: "5.9.0"
sdks:
dart: ">=3.6.0-0 <4.0.0"
flutter: ">=3.24.0"
diff --git a/example/pubspec.yaml b/example/pubspec.yaml
index 5f97bdeb2..8148153c4 100644
--- a/example/pubspec.yaml
+++ b/example/pubspec.yaml
@@ -4,8 +4,8 @@ publish_to: 'none'
version: 0.1.0+1
environment:
- sdk: ^3.0.0
- flutter: ">=3.10.0"
+ sdk: ^3.5.0
+ flutter: ">=3.24.0"
dependencies:
flutter:
diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc
index 236a2f2b3..043a96f01 100644
--- a/example/windows/flutter/generated_plugin_registrant.cc
+++ b/example/windows/flutter/generated_plugin_registrant.cc
@@ -7,14 +7,11 @@
#include "generated_plugin_registrant.h"
#include
-#include
#include
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
- GalPluginCApiRegisterWithRegistrar(
- registry->GetRegistrarForPlugin("GalPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake
index 5d19bf7c2..a95e2673b 100644
--- a/example/windows/flutter/generated_plugins.cmake
+++ b/example/windows/flutter/generated_plugins.cmake
@@ -4,7 +4,6 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
- gal
url_launcher_windows
)
diff --git a/flutter_quill_extensions/CHANGELOG.md b/flutter_quill_extensions/CHANGELOG.md
index 20d7492b3..10f788ab2 100644
--- a/flutter_quill_extensions/CHANGELOG.md
+++ b/flutter_quill_extensions/CHANGELOG.md
@@ -10,6 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [11.0.0-dev.6] - 2024-12-13
+
+### Changed
+
+- Rewrite the image save functionality with support for all platforms [#2403](https://github.com/singerdmx/flutter-quill/pull/2403).
+- Ignore `unreachable_switch_default` warning (introduced in Dart 3.6) [#2416](https://github.com/singerdmx/flutter-quill/pull/2416).
+
+### Removed
+
+- The following packages are no longer dependencies of `flutter_quill_extensions`:
+ * [http](https://pub.dev/packages/http)
+ * [cross_file](https://pub.dev/packages/cross_file)
+
## [11.0.0-dev.4] - 2024-11-24
> [!IMPORTANT]
diff --git a/flutter_quill_extensions/README.md b/flutter_quill_extensions/README.md
index e40a5a294..a814f676d 100644
--- a/flutter_quill_extensions/README.md
+++ b/flutter_quill_extensions/README.md
@@ -44,11 +44,9 @@ dependencies:
The package uses the following plugins:
-1. [`gal`](https://github.com/natsuk4ze/gal) to save images.
- Ensure to follow the [gal setup](https://pub.dev/packages/gal#-get-started) guide as it requires platform-specific setup.
-2. [`image_picker`](https://pub.dev/packages/image_picker) for picking images.
- See the [image_picker installation](https://pub.dev/packages/image_picker#installation) section.
-3. [`video_player`](https://pub.dev/packages/video_player) for playing videos. See the [video_player setup](https://pub.dev/packages/video_player#setup) section.
+1. [`quill_native_bridge`](https://pub.dev/packages/quill_native_bridge) to save images: [Setup](https://pub.dev/packages/quill_native_bridge#-setup)
+2. [`image_picker`](https://pub.dev/packages/image_picker) for picking images: [Setup](https://pub.dev/packages/image_picker#installation)
+3. [`video_player`](https://pub.dev/packages/video_player) for video playback: [Setup](https://pub.dev/packages/video_player#setup)
### Loading Images from the Internet
@@ -62,8 +60,7 @@ The package uses the following plugins:
2. To restrict image and video loading to HTTPS only, configure your app accordingly.
If you need to support HTTP, you must adjust your app settings for release mode. Consult
the [Android Cleartext / Plaintext HTTP](https://developer.android.com/privacy-and-security/risks/cleartext-communications)
- guide
- for more information.
+ guide for more information.
#### macOS
diff --git a/flutter_quill_extensions/analysis_options.yaml b/flutter_quill_extensions/analysis_options.yaml
index 42a46376c..b294310e7 100644
--- a/flutter_quill_extensions/analysis_options.yaml
+++ b/flutter_quill_extensions/analysis_options.yaml
@@ -1,5 +1,9 @@
include: package:flutter_lints/flutter.yaml
+analyzer:
+# TODO: Included for backward compatibility, remove when the minimum Dart SDK is 3.6.0
+ errors:
+ unreachable_switch_default: ignore
linter:
rules:
always_declare_return_types: true
diff --git a/flutter_quill_extensions/lib/src/common/utils/utils.dart b/flutter_quill_extensions/lib/src/common/utils/utils.dart
index ab13830d7..13d754086 100644
--- a/flutter_quill_extensions/lib/src/common/utils/utils.dart
+++ b/flutter_quill_extensions/lib/src/common/utils/utils.dart
@@ -1,10 +1,3 @@
-import 'dart:io' show File;
-
-import 'package:flutter/foundation.dart' show immutable;
-import 'package:gal/gal.dart';
-import 'package:http/http.dart' as http;
-
-import '../../editor/image/widgets/image.dart';
import 'patterns.dart';
bool isBase64(String str) {
@@ -35,71 +28,3 @@ bool isYouTubeUrl(String videoUrl) {
return false;
}
}
-
-enum SaveImageResultMethod { network, localStorage }
-
-@immutable
-class SaveImageResult {
- const SaveImageResult({required this.error, required this.method});
-
- final String? error;
- final SaveImageResultMethod method;
-}
-
-Future saveImage({
- required String imageUrl,
-}) async {
- final imageFile = File(imageUrl);
- final hasPermission = await Gal.hasAccess();
- if (!hasPermission) {
- await Gal.requestAccess();
- }
- final imageExistsLocally = await imageFile.exists();
- if (!imageExistsLocally) {
- try {
- await _saveImageFromNetwork(
- Uri.parse(appendFileExtensionToImageUrl(imageUrl)),
- );
- return const SaveImageResult(
- error: null,
- method: SaveImageResultMethod.network,
- );
- } catch (e) {
- return SaveImageResult(
- error: e.toString(),
- method: SaveImageResultMethod.network,
- );
- }
- } else {
- try {
- await _saveLocalImage(Uri.parse(imageUrl));
- return const SaveImageResult(
- error: null,
- method: SaveImageResultMethod.localStorage,
- );
- } catch (e) {
- return SaveImageResult(
- error: e.toString(),
- method: SaveImageResultMethod.localStorage,
- );
- }
- }
-}
-
-Future _saveImageFromNetwork(Uri imageUrl) async {
- final response = await http.get(
- imageUrl,
- );
- if (response.statusCode != 200) {
- throw Exception('Response to $imageUrl is not successful.');
- }
- final imageBytes = response.bodyBytes;
- await Gal.putImageBytes(imageBytes,
- name: imageUrl.pathSegments.isNotEmpty
- ? imageUrl.pathSegments.last
- : 'image');
-}
-
-Future _saveLocalImage(Uri imageUrl) async {
- await Gal.putImage(imageUrl.toString());
-}
diff --git a/flutter_quill_extensions/lib/src/editor/image/image_load_utils.dart b/flutter_quill_extensions/lib/src/editor/image/image_load_utils.dart
new file mode 100644
index 000000000..a30af954e
--- /dev/null
+++ b/flutter_quill_extensions/lib/src/editor/image/image_load_utils.dart
@@ -0,0 +1,36 @@
+import 'dart:async' show Completer;
+import 'dart:ui' as ui;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+class ImageLoader {
+ static ImageLoader _instance = ImageLoader();
+
+ static ImageLoader get instance => _instance;
+
+ /// Allows overriding the instance for testing
+ @visibleForTesting
+ static set instance(ImageLoader newInstance) => _instance = newInstance;
+
+ // TODO(performance): This will load the image again. In case
+// this is a network image, then this will be inefficient.
+ Future loadImageBytesFromImageProvider({
+ required ImageProvider imageProvider,
+ }) async {
+ final stream = imageProvider.resolve(ImageConfiguration.empty);
+ final completer = Completer();
+
+ ImageStreamListener? listener;
+ listener = ImageStreamListener((info, _) {
+ completer.complete(info.image);
+ stream.removeListener(listener!);
+ });
+
+ stream.addListener(listener);
+
+ final image = await completer.future;
+ final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
+ return byteData?.buffer.asUint8List();
+ }
+}
diff --git a/flutter_quill_extensions/lib/src/editor/image/image_menu.dart b/flutter_quill_extensions/lib/src/editor/image/image_menu.dart
index ab7bd9463..814f79fd1 100644
--- a/flutter_quill_extensions/lib/src/editor/image/image_menu.dart
+++ b/flutter_quill_extensions/lib/src/editor/image/image_menu.dart
@@ -1,17 +1,17 @@
-import 'dart:async' show Completer;
-import 'dart:ui' as ui;
-
import 'package:flutter/cupertino.dart' show showCupertinoModalPopup;
-import 'package:flutter/foundation.dart' show kIsWeb, Uint8List;
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart'
show ImageUrl, QuillController, StyleAttribute, getEmbedNode;
import 'package:flutter_quill/internal.dart';
+import 'package:path/path.dart' as p;
+import 'package:url_launcher/url_launcher.dart';
import '../../common/utils/element_utils/element_utils.dart';
import '../../common/utils/string.dart';
-import '../../common/utils/utils.dart';
import 'config/image_config.dart';
+import 'image_load_utils.dart';
+import 'image_save_utils.dart';
import 'widgets/image.dart' show ImageTapWrapper, getImageStyleString;
import 'widgets/image_resizer.dart' show ImageResizer;
@@ -23,6 +23,7 @@ class ImageOptionsMenu extends StatelessWidget {
required this.imageSize,
required this.readOnly,
required this.imageProvider,
+ this.prefersGallerySave = true,
super.key,
});
@@ -33,6 +34,19 @@ class ImageOptionsMenu extends StatelessWidget {
final bool readOnly;
final ImageProvider imageProvider;
+ // TODO(quill_native_bridge): Update this doc comment once saveImageToGallery()
+ // is supported on Windows too (will be applicable like macOS). See https://pub.dev/packages/quill_native_bridge#-features
+ /// Determines if the image should be saved to the gallery instead of using the
+ /// system file save dialog for platforms that support both.
+ ///
+ /// Currently, the only platform where this applies is macOS.
+ ///
+ /// This is silently ignored on platforms that only support gallery save (Android and iOS)
+ /// or only image save.
+ ///
+ /// For more details, refer to [quill_native_bridge Saving images](https://pub.dev/packages/quill_native_bridge#-saving-images).
+ final bool prefersGallerySave;
+
@override
Widget build(BuildContext context) {
final materialTheme = Theme.of(context);
@@ -90,7 +104,9 @@ class ImageOptionsMenu extends StatelessWidget {
getImageStyleString(controller),
);
- final imageBytes = await _loadImageBytesFromImageProvider();
+ final imageBytes = await ImageLoader.instance
+ .loadImageBytesFromImageProvider(
+ imageProvider: imageProvider);
if (imageBytes != null) {
await ClipboardServiceProvider.instance.copyImage(imageBytes);
}
@@ -126,48 +142,90 @@ class ImageOptionsMenu extends StatelessWidget {
await config.onImageRemovedCallback.call(imageSource);
},
),
- if (!kIsWeb)
- ListTile(
- leading: const Icon(Icons.save),
- title: Text(context.loc.save),
- onTap: () async {
- final messenger = ScaffoldMessenger.of(context);
- final localizations = context.loc;
- Navigator.of(context).pop();
+ ListTile(
+ leading: const Icon(Icons.save),
+ title: Text(context.loc.save),
+ onTap: () async {
+ final messenger = ScaffoldMessenger.of(context);
+ final localizations = context.loc;
+ Navigator.of(context).pop();
- final saveImageResult = await saveImage(
+ SaveImageResult? result;
+ try {
+ result = await ImageSaver.instance.saveImage(
imageUrl: imageSource,
+ imageProvider: imageProvider,
+ prefersGallerySave: prefersGallerySave,
);
- final imageSavedSuccessfully = saveImageResult.error == null;
+ } on GalleryImageSaveAccessDeniedException {
+ messenger.showSnackBar(SnackBar(
+ content: Text(
+ localizations.saveImagePermissionDenied,
+ )));
+ return;
+ }
- messenger.clearSnackBars();
+ if (result == null) {
+ messenger.showSnackBar(SnackBar(
+ content: Text(
+ localizations.errorUnexpectedSavingImage,
+ )));
+ return;
+ }
- if (!imageSavedSuccessfully) {
- messenger.showSnackBar(SnackBar(
- content: Text(
- localizations.errorWhileSavingImage,
- )));
- return;
- }
+ if (kIsWeb) {
+ messenger.showSnackBar(SnackBar(
+ content: Text(localizations.successImageDownloaded)));
+ return;
+ }
- var message = switch (saveImageResult.method) {
- SaveImageResultMethod.network =>
- localizations.savedUsingTheNetwork,
- SaveImageResultMethod.localStorage =>
- localizations.savedUsingLocalStorage,
- };
+ if (result.isGallerySave) {
+ messenger.showSnackBar(SnackBar(
+ content: Text(localizations.successImageSavedGallery),
+ action: SnackBarAction(
+ label: localizations.openGallery,
+ onPressed: () =>
+ QuillNativeProvider.instance.openGalleryApp(),
+ ),
+ ));
+ return;
+ }
- if (isDesktopApp) {
- message = localizations.theImageHasBeenSavedAt(imageSource);
+ if (isDesktopApp) {
+ final imageFilePath = result.imageFilePath;
+ if (imageFilePath == null) {
+ // User canceled the system save dialog.
+ return;
}
messenger.showSnackBar(
SnackBar(
- content: Text(message),
+ content: Text(localizations.successImageSaved),
+ // On macOS the app only has access to the picked file from the system save
+ // dialog and not the directory where it was saved.
+ // Opening the directory of that file requires entitlements on macOS
+ // See https://pub.dev/packages/url_launcher#macos-file-access-configuration
+ // Open the saved image file instead of the directory
+ action: defaultTargetPlatform == TargetPlatform.macOS
+ ? SnackBarAction(
+ label: localizations.openFile,
+ onPressed: () => launchUrl(Uri.file(imageFilePath)),
+ )
+ : SnackBarAction(
+ label: localizations.openFileLocation,
+ onPressed: () => launchUrl(
+ Uri.directory(p.dirname(imageFilePath))),
+ ),
),
);
- },
- ),
+
+ return;
+ }
+
+ throw StateError(
+ 'Image save result is not handled on $defaultTargetPlatform');
+ },
+ ),
ListTile(
leading: const Icon(Icons.zoom_in),
title: Text(context.loc.zoom),
@@ -185,23 +243,4 @@ class ImageOptionsMenu extends StatelessWidget {
),
);
}
-
- // TODO: This will load the image again, in case it was network image
- // then it will send a GET request each time to load the image.
- Future _loadImageBytesFromImageProvider() async {
- final stream = imageProvider.resolve(ImageConfiguration.empty);
- final completer = Completer();
-
- ImageStreamListener? listener;
- listener = ImageStreamListener((info, _) {
- completer.complete(info.image);
- stream.removeListener(listener!);
- });
-
- stream.addListener(listener);
-
- final image = await completer.future;
- final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
- return byteData?.buffer.asUint8List();
- }
}
diff --git a/flutter_quill_extensions/lib/src/editor/image/image_save_utils.dart b/flutter_quill_extensions/lib/src/editor/image/image_save_utils.dart
new file mode 100644
index 000000000..5203c999c
--- /dev/null
+++ b/flutter_quill_extensions/lib/src/editor/image/image_save_utils.dart
@@ -0,0 +1,254 @@
+@internal
+library;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_quill/internal.dart';
+import 'package:meta/meta.dart';
+import 'package:path/path.dart' as p;
+
+import 'image_load_utils.dart';
+
+const defaultImageFileExtension = 'png';
+
+// The [imageSourcePath] could be file, asset path or HTTP image URL.
+String extractImageFileExtensionFromImageSource(String? imageSourcePath) {
+ if (imageSourcePath == null || imageSourcePath.isEmpty) {
+ return defaultImageFileExtension;
+ }
+
+ if (!imageSourcePath.contains('.')) {
+ return defaultImageFileExtension;
+ }
+
+ return p.extension(imageSourcePath).replaceFirst('.', '');
+}
+
+// The [imageSourcePath] could be file, asset path or HTTP image URL.
+String? extractImageNameFromImageSource(String? imageSourcePath) {
+ if (imageSourcePath == null || imageSourcePath.isEmpty) {
+ return null;
+ }
+ final uri = Uri.parse(imageSourcePath);
+ final pathWithoutQuery = uri.path;
+
+ final imageName = p.basenameWithoutExtension(pathWithoutQuery);
+ if (imageName.isEmpty) {
+ return null;
+ }
+ return imageName;
+}
+
+class SaveImageResult {
+ const SaveImageResult({
+ required this.imageFilePath,
+ required this.isGallerySave,
+ });
+
+ /// Returns `null` on web platforms, if [isGallerySave] is `true`
+ /// or in case the user cancels the save operation on desktop platforms.
+ final String? imageFilePath;
+
+ final bool isGallerySave;
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(other, this)) return true;
+ if (other is! SaveImageResult) return false;
+ return other.imageFilePath == imageFilePath &&
+ other.isGallerySave == isGallerySave;
+ }
+
+ @override
+ int get hashCode => Object.hash(imageFilePath, isGallerySave);
+
+ @override
+ String toString() =>
+ 'SaveImageResult(imageFilePath: $imageFilePath, isGallerySave: $isGallerySave)';
+}
+
+const String defaultImageFileNamePrefix = 'IMG';
+
+String getDefaultImageFileName({required bool isGallerySave}) {
+ if (kIsWeb) {
+ // The browser handles name conflicts.
+ return defaultImageFileNamePrefix;
+ }
+ if (isGallerySave) {
+ // The gallery app handles name conflicts.
+ return defaultImageFileNamePrefix;
+ }
+ if (defaultTargetPlatform == TargetPlatform.macOS ||
+ defaultTargetPlatform == TargetPlatform.windows) {
+ // Windows and macOS system native save dialog prompts the user to confirm file overwrite.
+ return defaultImageFileNamePrefix;
+ }
+ final uniqueFileName =
+ '${defaultImageFileNamePrefix}_${DateTime.now().toIso8601String()}';
+ if (defaultTargetPlatform == TargetPlatform.linux) {
+ // IMPORTANT: On Linux, it depends on the desktop environment
+ // and name conflicts may not be handled. Always provide a unique image file name.
+ return uniqueFileName;
+ }
+
+ return uniqueFileName;
+}
+
+Future shouldSaveToGallery({required bool prefersGallerySave}) async {
+ final supportsGallerySave = await QuillNativeProvider.instance
+ .isSupported(QuillNativeBridgeFeature.saveImageToGallery);
+ if (!supportsGallerySave) {
+ return false;
+ }
+ final supportsImageSave = await QuillNativeProvider.instance
+ .isSupported(QuillNativeBridgeFeature.saveImage);
+ if (!supportsImageSave) {
+ return true;
+ }
+
+ return supportsGallerySave && prefersGallerySave;
+}
+
+/// Thrown when the gallery image save operation is denied
+/// due to insufficient or denied permissions.
+class GalleryImageSaveAccessDeniedException implements Exception {
+ GalleryImageSaveAccessDeniedException([this.message]);
+
+ final String? message;
+
+ @override
+ String toString() =>
+ message ??
+ 'Permission to save the image to the gallery was denied or insufficient.';
+}
+
+class ImageSaver {
+ ImageSaver._();
+
+ static ImageSaver _instance = ImageSaver._();
+
+ static ImageSaver get instance => _instance;
+
+ /// Allows overriding the instance for testing
+ @visibleForTesting
+ static set instance(ImageSaver newInstance) => _instance = newInstance;
+
+ /// Saves an image to the user's device based on the platform:
+ ///
+ /// - **Web**: Downloads the image using the browser's download functionality.
+ /// - **Desktop**: Prompts the user to choose a location for the image using
+ /// native save dialog, defaulting to the user's `Pictures` directory. Or
+ /// saves the image to the gallery in case [prefersGallerySave] is `true` and
+ // TODO(quill_native_bridge): Update this doc comment once saveImageToGallery()
+ // is supported on Windows too (will be applicable like macOS). See https://pub.dev/packages/quill_native_bridge#-features
+ /// the gallery is supported (currently only macOS is applicable).
+ /// - **Mobile**: Saves the image to the gallery, requesting permission if needed.
+ ///
+ /// The [imageUrl] could be file or network image URL and is used to extract
+ /// image file extension and the image name.
+ ///
+ /// The [imageProvider] is used to load the image bytes from using [ImageLoader].
+ ///
+ /// Returns `null` on failure.
+ ///
+ /// Throws [GalleryImageSaveAccessDeniedException] in case permission was denied or insuffeicnet.
+ Future saveImage({
+ required String imageUrl,
+ required ImageProvider imageProvider,
+ required bool prefersGallerySave,
+ }) async {
+ assert(() {
+ if (imageUrl.isEmpty) {
+ throw ArgumentError.value(imageUrl, 'imageUrl', 'cannot be empty');
+ }
+ return true;
+ }());
+
+ final imageFileExtension =
+ extractImageFileExtensionFromImageSource(imageUrl);
+ final imageName = extractImageNameFromImageSource(imageUrl);
+
+ final imageBytes = await ImageLoader.instance
+ .loadImageBytesFromImageProvider(imageProvider: imageProvider);
+ if (imageBytes == null || imageBytes.isEmpty) {
+ return null;
+ }
+
+ if (kIsWeb) {
+ await QuillNativeProvider.instance.saveImage(
+ imageBytes,
+ options: ImageSaveOptions(
+ name: imageName ?? getDefaultImageFileName(isGallerySave: false),
+ fileExtension: imageFileExtension),
+ );
+ return const SaveImageResult(
+ imageFilePath: null,
+ isGallerySave: false,
+ );
+ }
+
+ if (await shouldSaveToGallery(prefersGallerySave: prefersGallerySave)) {
+ try {
+ await QuillNativeProvider.instance.saveImageToGallery(
+ imageBytes,
+ options: GalleryImageSaveOptions(
+ name: imageName ?? getDefaultImageFileName(isGallerySave: true),
+ fileExtension: imageFileExtension,
+ // Specifying the album name requires read-write permission
+ // on iOS and macOS on all versions. Pass null to request add-only on
+ // supported versions (previous versions still use read-write).
+ albumName: null,
+ ),
+ );
+
+ return const SaveImageResult(
+ imageFilePath: null,
+ isGallerySave: true,
+ );
+ } on PlatformException catch (e) {
+ // TODO(save-image): Part of https://github.com/FlutterQuill/quill-native-bridge/issues/2
+
+ // Permission request is required only on iOS, macOS and Android API 28 and earlier.
+ if (e.code == 'PERMISSION_DENIED') {
+ // macOS imposes security restrictions when running the app
+ // on sources other than Xcode or the macOS terminal, such as Android Studio or VS Code.
+ // This is not an issue in production. Throwing [GalleryImageSaveAccessDeniedException] will indicate
+ // that the user denied the permission, even though it will always deny the permission even if granted.
+ // Make sure we don't handle that error (it has details) during development to avoid confusion.
+ // For more details, see https://github.com/flutter/flutter/issues/134191#issuecomment-2506248266
+ // and https://pub.dev/packages/quill_native_bridge#-saving-images-to-the-gallery
+
+ final possiblePermissionIssueDuringDevelopmentOnMacOS =
+ kDebugMode && defaultTargetPlatform == TargetPlatform.macOS;
+ if (possiblePermissionIssueDuringDevelopmentOnMacOS) {
+ rethrow;
+ }
+
+ throw GalleryImageSaveAccessDeniedException(e.toString());
+ }
+ rethrow;
+ }
+ }
+
+ if (await QuillNativeProvider.instance
+ .isSupported(QuillNativeBridgeFeature.saveImage)) {
+ assert(!isMobileApp,
+ 'Mobile platforms support saving images to the gallery only');
+
+ final result = await QuillNativeProvider.instance.saveImage(
+ imageBytes,
+ options: ImageSaveOptions(
+ name: imageName ?? getDefaultImageFileName(isGallerySave: false),
+ fileExtension: imageFileExtension,
+ ),
+ );
+ return SaveImageResult(
+ imageFilePath: result.filePath,
+ isGallerySave: false,
+ );
+ }
+
+ throw StateError('Image save is not handled on $defaultTargetPlatform');
+ }
+}
diff --git a/flutter_quill_extensions/pubspec.yaml b/flutter_quill_extensions/pubspec.yaml
index 8c21ef947..740e30384 100644
--- a/flutter_quill_extensions/pubspec.yaml
+++ b/flutter_quill_extensions/pubspec.yaml
@@ -1,6 +1,6 @@
name: flutter_quill_extensions
-description: Embed extensions for flutter_quill including image, video, formula and etc.
-version: 11.0.0-dev.4
+description: Embed extensions for flutter_quill to support loading images and videos
+version: 11.0.0-dev.6
homepage: https://github.com/singerdmx/flutter-quill/tree/master/flutter_quill_extensions/
repository: https://github.com/singerdmx/flutter-quill/tree/master/flutter_quill_extensions/
issue_tracker: https://github.com/singerdmx/flutter-quill/issues/
@@ -32,26 +32,23 @@ dependencies:
sdk: flutter
# Dart Packages
- http: ^1.0.0
- path: ^1.8.0
meta: ^1.7.0
universal_html: ^2.2.4
- cross_file: ^0.3.3+4
+ path: ^1.8.0
- flutter_quill: ^11.0.0-dev.3
+ flutter_quill: ^11.0.0-dev.15
photo_view: ^0.15.0
# Plugins
video_player: ^2.8.0
url_launcher: ^6.2.1
- gal: ^2.3.0
- gal_linux: ^0.1.0
image_picker: ^1.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
+ mocktail: ^1.0.4
flutter:
uses-material-design: true
diff --git a/flutter_quill_extensions/test/editor/image/image_save_utils_test.dart b/flutter_quill_extensions/test/editor/image/image_save_utils_test.dart
new file mode 100644
index 000000000..03d7913a2
--- /dev/null
+++ b/flutter_quill_extensions/test/editor/image/image_save_utils_test.dart
@@ -0,0 +1,958 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter_quill/internal.dart';
+import 'package:flutter_quill_extensions/src/editor/image/image_load_utils.dart';
+import 'package:flutter_quill_extensions/src/editor/image/image_save_utils.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+
+const testImageExtensions = {'png', 'jpeg', 'jpg', 'gif', 'webp'};
+
+void main() {
+ group('extractImageFileExtensionFromImageSource', () {
+ test('defaults to using png', () {
+ expect(defaultImageFileExtension, equals('png'));
+ });
+
+ test('returns $defaultImageFileExtension when file name is null or empty',
+ () {
+ expect(extractImageFileExtensionFromImageSource(null),
+ equals(defaultImageFileExtension));
+ expect(extractImageFileExtensionFromImageSource(''),
+ equals(defaultImageFileExtension));
+ });
+
+ test('returns $defaultImageFileExtension when file name does not have dot',
+ () {
+ expect(extractImageFileExtensionFromImageSource('imagepng'),
+ equals(defaultImageFileExtension));
+ expect(extractImageFileExtensionFromImageSource('image png'),
+ equals(defaultImageFileExtension));
+ expect(extractImageFileExtensionFromImageSource('image'),
+ equals(defaultImageFileExtension));
+ expect(extractImageFileExtensionFromImageSource('png'),
+ equals(defaultImageFileExtension));
+ });
+
+ test('returns the file extension correctly', () {
+ for (final fileExtension in testImageExtensions) {
+ expect(extractImageFileExtensionFromImageSource('image.$fileExtension'),
+ equals(fileExtension));
+ }
+ });
+ });
+ group('extractImageNameFromImageSource', () {
+ test(
+ 'returns the file name without the extension when a valid name is given',
+ () {
+ expect(extractImageNameFromImageSource('image.jpg'), 'image');
+ });
+
+ test('returns null when the input is null or empty', () {
+ expect(extractImageNameFromImageSource(null), isNull);
+ expect(extractImageNameFromImageSource(''), isNull);
+ });
+
+ test('returns the image name correctly', () {
+ for (final fileExtension in testImageExtensions) {
+ expect(
+ extractImageNameFromImageSource('image.$fileExtension'), 'image');
+ }
+ });
+
+ group('HTTP URLs', () {
+ test('extracts image name from a HTTP URL correctly', () {
+ const imageName = 'image';
+ expect(
+ extractImageNameFromImageSource(
+ 'https://example.com/path/to/file/$imageName.png'),
+ equals(imageName),
+ );
+ });
+
+ test('extracts image name from a URL with query parameters', () {
+ const imageName = 'quill_image';
+ expect(
+ extractImageNameFromImageSource(
+ 'https://example.com/path/to/file/$imageName.png?version=1.2.3'),
+ equals(imageName),
+ );
+ });
+
+ test(
+ 'extracts image name from a URL with query parameters and without extension',
+ () {
+ const imageName = 'quill_image';
+ expect(
+ extractImageNameFromImageSource(
+ 'https://example.com/path/to/file/$imageName?version=1.2.3'),
+ equals(imageName),
+ );
+ });
+
+ test('extracts image name from a URL with query parameters', () {
+ const imageName = '2019-Metrology-Events.jpg';
+ expect(
+ extractImageNameFromImageSource(
+ 'https://firebasestorage.googleapis.com/v0/b/eventat-4ba96.appspot.com/o/$imageName.jpg?alt=media&token=bfc47032-5173-4b3f-86bb-9659f46b362a'),
+ equals(imageName),
+ );
+ });
+
+ test('handles a HTTP URL with a trailing slash', () {
+ expect(
+ extractImageNameFromImageSource('https://example.com/path/to/file/'),
+ equals('file'),
+ );
+ });
+
+ test('handles a URL ending with a slash and no file name', () {
+ expect(
+ extractImageNameFromImageSource('https://example.com/path/to/'),
+ equals('to'),
+ );
+ });
+
+ test('handles a URL with multiple slashes in the path', () {
+ const imageName = 'ExampleImage';
+ expect(
+ extractImageNameFromImageSource(
+ 'https://example.com/path/to/extra/level/$imageName.webp'),
+ equals(imageName),
+ );
+ });
+
+ test('returns null for URLs without any path components', () {
+ expect(
+ extractImageNameFromImageSource('https://example.com'),
+ isNull,
+ );
+ });
+
+ test('extracts image name from a URL with special characters', () {
+ const imageName = '2013-report-final_v2';
+ expect(
+ extractImageNameFromImageSource(
+ 'https://example.com/files/$imageName'),
+ equals(imageName),
+ );
+ });
+ });
+
+ group('File paths', () {
+ test('extracts image name from a standard file path', () {
+ const imageName = 'ExampleImage';
+ expect(
+ extractImageNameFromImageSource('/path/to/file/$imageName.gif'),
+ equals(imageName),
+ );
+ });
+
+ test('returns null for a path that ends with a trailing slash', () {
+ const imageName = 'ExampleImage2';
+
+ expect(
+ extractImageNameFromImageSource('/path/to/file/$imageName.webp/'),
+ imageName,
+ );
+ });
+
+ test('returns null for an empty path', () {
+ expect(
+ extractImageNameFromImageSource(''),
+ isNull,
+ );
+ });
+
+ test('handles paths without a file name', () {
+ const imageName = 'emptyfolder';
+ expect(
+ extractImageNameFromImageSource('/path/to/$imageName/'),
+ equals(imageName),
+ );
+ });
+
+ test('extracts file name from a file path with special characters', () {
+ const imageName = '2015-report-final_v2';
+ expect(
+ extractImageNameFromImageSource('/path/to/file/$imageName.png'),
+ equals(imageName),
+ );
+ });
+
+ test(
+ 'returns null for a path that is just a file name with no directories',
+ () {
+ const imageName = 'document';
+
+ expect(
+ extractImageNameFromImageSource('$imageName.png'),
+ equals(imageName),
+ );
+ });
+ test('handles paths that ends with a slash', () {
+ const imageName = 'Image';
+ expect(
+ extractImageNameFromImageSource('/path/to/file/$imageName.png/'),
+ equals(imageName),
+ );
+ });
+ });
+ });
+
+ group('$SaveImageResult', () {
+ test('overrides toString() correctly', () {
+ const imageFilePath = '/path/to/file';
+ const isGallerySave = false;
+ expect(
+ const SaveImageResult(
+ imageFilePath: imageFilePath, isGallerySave: isGallerySave)
+ .toString(),
+ 'SaveImageResult(imageFilePath: $imageFilePath, isGallerySave: $isGallerySave)',
+ );
+ });
+
+ test('implements equality correctly', () {
+ const imageFilePath = '/path/to/file.gif';
+ const isGallerySave = true;
+
+ expect(
+ const SaveImageResult(
+ imageFilePath: imageFilePath, isGallerySave: isGallerySave),
+ const SaveImageResult(
+ imageFilePath: imageFilePath, isGallerySave: isGallerySave),
+ reason: 'two instances with the same values should be equal',
+ );
+ });
+
+ test('overrides hashCode correctly', () {
+ const imageFilePath = '/path/to/file.webp';
+ const isGallerySave = false;
+ expect(
+ const SaveImageResult(
+ imageFilePath: imageFilePath, isGallerySave: isGallerySave)
+ .hashCode,
+ const SaveImageResult(
+ imageFilePath: imageFilePath, isGallerySave: isGallerySave)
+ .hashCode,
+ );
+ });
+ });
+
+ test('defaultImageFileNamePrefix constant is correct', () {
+ expect(defaultImageFileNamePrefix, equals('IMG'));
+ });
+
+ group('getDefaultImageFileName', () {
+ if (kIsWeb) {
+ test('returns default file name prefix when saving on the web', () {
+ // The browser handles name conflicts.
+ for (final isGallerySave in {true, false}) {
+ expect(getDefaultImageFileName(isGallerySave: isGallerySave),
+ defaultImageFileNamePrefix);
+ }
+ });
+ }
+
+ test('returns default file name prefix when saving to gallery', () {
+ // The gallery app handles name conflicts.
+ expect(getDefaultImageFileName(isGallerySave: true),
+ defaultImageFileNamePrefix);
+ });
+
+ test(
+ 'returns default file name prefix for system save dialog on macOS and Windows',
+ () {
+ for (final platform in {TargetPlatform.macOS, TargetPlatform.windows}) {
+ debugDefaultTargetPlatformOverride = platform;
+
+ expect(getDefaultImageFileName(isGallerySave: false),
+ defaultImageFileNamePrefix);
+ }
+ });
+
+ test('returns unique file name for system save dialog image on Linux', () {
+ debugDefaultTargetPlatformOverride = TargetPlatform.linux;
+
+ final imageFileName = getDefaultImageFileName(isGallerySave: false);
+ expect(imageFileName, isNot(equals(defaultImageFileNamePrefix)));
+
+ final imageFileName2 = getDefaultImageFileName(isGallerySave: false);
+ expect(
+ imageFileName2,
+ isNot(equals(imageFileName)),
+ reason: 'File name should be unique',
+ );
+
+ final imageFileName3 = getDefaultImageFileName(isGallerySave: false);
+ expect(
+ imageFileName3,
+ isNot(equals(imageFileName2)),
+ reason: 'File name should be unique',
+ );
+ expect(
+ imageFileName3,
+ isNot(equals(imageFileName)),
+ reason: 'File name should be unique',
+ );
+ });
+
+ test('returns unique file name for other platforms', () {
+ final imageFileName = getDefaultImageFileName(isGallerySave: false);
+ expect(imageFileName, isNot(equals(defaultImageFileNamePrefix)));
+
+ final imageFileName2 = getDefaultImageFileName(isGallerySave: false);
+ expect(
+ imageFileName2,
+ isNot(equals(imageFileName)),
+ reason: 'File name should be unique',
+ );
+
+ final imageFileName3 = getDefaultImageFileName(isGallerySave: false);
+ expect(
+ imageFileName3,
+ isNot(equals(imageFileName2)),
+ reason: 'File name should be unique',
+ );
+ expect(
+ imageFileName3,
+ isNot(equals(imageFileName)),
+ reason: 'File name should be unique',
+ );
+ });
+ });
+
+ group('shouldSaveToGallery', () {
+ late MockQuillNativeBridge mockQuillNativeBridge;
+
+ void mockGallerySaveSupported(bool isSupported) =>
+ when(() => mockQuillNativeBridge
+ .isSupported(QuillNativeBridgeFeature.saveImageToGallery))
+ .thenAnswer((_) async => isSupported);
+
+ void mockImageSaveSupported(bool isSupported) =>
+ when(() => mockQuillNativeBridge
+ .isSupported(QuillNativeBridgeFeature.saveImage))
+ .thenAnswer((_) async => isSupported);
+
+ setUp(() {
+ mockQuillNativeBridge = MockQuillNativeBridge();
+ QuillNativeProvider.instance = mockQuillNativeBridge;
+ });
+
+ test(
+ 'returns false if gallery save not supported regardless of prefersGallerySave',
+ () async {
+ mockGallerySaveSupported(false);
+
+ for (final prefersGallerySave in {true, false}) {
+ final result =
+ await shouldSaveToGallery(prefersGallerySave: prefersGallerySave);
+ expect(result, isFalse);
+ verify(() => mockQuillNativeBridge.isSupported(
+ QuillNativeBridgeFeature.saveImageToGallery)).called(1);
+ verifyNever(() => mockQuillNativeBridge
+ .isSupported(QuillNativeBridgeFeature.saveImage));
+ }
+ });
+
+ test(
+ 'returns false when gallery save is not supported, regardless of if image save is supported',
+ () async {
+ mockGallerySaveSupported(false);
+
+ for (final isImageSupported in {true, false}) {
+ mockImageSaveSupported(isImageSupported);
+
+ final result = await shouldSaveToGallery(prefersGallerySave: true);
+
+ expect(result, isFalse);
+ verify(() => mockQuillNativeBridge.isSupported(
+ QuillNativeBridgeFeature.saveImageToGallery)).called(1);
+ verifyNever(() => mockQuillNativeBridge
+ .isSupported(QuillNativeBridgeFeature.saveImage));
+ }
+ });
+
+ test(
+ 'returns true if gallery save is supported and prefersGallerySave is true',
+ () async {
+ for (final imageSaveSupported in {true, false}) {
+ mockGallerySaveSupported(true);
+ mockImageSaveSupported(imageSaveSupported);
+
+ final result = await shouldSaveToGallery(prefersGallerySave: true);
+
+ expect(result, isTrue);
+ verify(() => mockQuillNativeBridge.isSupported(
+ QuillNativeBridgeFeature.saveImageToGallery)).called(1);
+ verify(() => mockQuillNativeBridge
+ .isSupported(QuillNativeBridgeFeature.saveImage)).called(1);
+ }
+ });
+
+ test(
+ 'returns true when gallery and image save are supported and prefersGallerySave is true',
+ () async {
+ mockGallerySaveSupported(true);
+ mockImageSaveSupported(true);
+
+ final result = await shouldSaveToGallery(prefersGallerySave: true);
+
+ expect(result, isTrue);
+ verify(() => mockQuillNativeBridge
+ .isSupported(QuillNativeBridgeFeature.saveImageToGallery)).called(1);
+ verify(() => mockQuillNativeBridge
+ .isSupported(QuillNativeBridgeFeature.saveImage)).called(1);
+ });
+
+ test(
+ 'returns false when gallery and image save are supported and prefersGallerySave is false',
+ () async {
+ mockGallerySaveSupported(true);
+ mockImageSaveSupported(true);
+
+ final result = await shouldSaveToGallery(prefersGallerySave: false);
+
+ expect(result, isFalse);
+ verify(() => mockQuillNativeBridge
+ .isSupported(QuillNativeBridgeFeature.saveImageToGallery)).called(1);
+ verify(() => mockQuillNativeBridge
+ .isSupported(QuillNativeBridgeFeature.saveImage)).called(1);
+ });
+
+ test(
+ 'returns false when gallery save is not supported and image save is supported regardless of prefersGallerySave',
+ () async {
+ mockGallerySaveSupported(false);
+ mockImageSaveSupported(true);
+
+ for (final prefersGallerySave in {true, false}) {
+ final result =
+ await shouldSaveToGallery(prefersGallerySave: prefersGallerySave);
+
+ expect(result, isFalse);
+ verify(() => mockQuillNativeBridge.isSupported(
+ QuillNativeBridgeFeature.saveImageToGallery)).called(1);
+ verifyNever(() => mockQuillNativeBridge
+ .isSupported(QuillNativeBridgeFeature.saveImage));
+ }
+ });
+
+ test(
+ 'returns true when gallery save supported and image save not supported regardless of prefersGallerySave',
+ () async {
+ mockGallerySaveSupported(true);
+ mockImageSaveSupported(false);
+
+ for (final prefersGallerySave in {true, false}) {
+ final result =
+ await shouldSaveToGallery(prefersGallerySave: prefersGallerySave);
+
+ expect(result, isTrue);
+ verify(() => mockQuillNativeBridge.isSupported(
+ QuillNativeBridgeFeature.saveImageToGallery)).called(1);
+ verify(() => mockQuillNativeBridge
+ .isSupported(QuillNativeBridgeFeature.saveImage));
+ }
+ });
+ });
+
+ group('$ImageSaver', () {
+ test('default instance is $ImageSaver', () {
+ expect(ImageSaver.instance, isA());
+ });
+
+ test('set the instance correctly', () {
+ expect(ImageSaver.instance, isNot(isA()));
+
+ ImageSaver.instance = FakeImageSaver();
+ expect(ImageSaver.instance, isA());
+ });
+ });
+
+ group('saveImage', () {
+ late MockQuillNativeBridge mockQuillNativeBridge;
+ late MockImageLoader mockImageLoader;
+ final imageSaver = ImageSaver.instance;
+
+ void mockGallerySaveSupported(bool isSupported) =>
+ when(() => mockQuillNativeBridge
+ .isSupported(QuillNativeBridgeFeature.saveImageToGallery))
+ .thenAnswer((_) async => isSupported);
+
+ void mockImageSaveSupported(bool isSupported) =>
+ when(() => mockQuillNativeBridge
+ .isSupported(QuillNativeBridgeFeature.saveImage))
+ .thenAnswer((_) async => isSupported);
+
+ Future mockShouldSaveToGallery(bool shouldSaveToGalleryValue) async {
+ if (shouldSaveToGalleryValue) {
+ mockGallerySaveSupported(true);
+ mockImageSaveSupported(false);
+ } else {
+ mockGallerySaveSupported(false);
+ mockImageSaveSupported(true);
+ }
+ expect(
+ await shouldSaveToGallery(prefersGallerySave: false),
+ shouldSaveToGalleryValue,
+ reason:
+ 'calling shouldSaveToGallery should return the value specified by mockShouldSaveToGallery',
+ );
+ }
+
+ void mockLoadImageBytesValue(Uint8List? imageBytes) =>
+ when(() => mockImageLoader.loadImageBytesFromImageProvider(
+ imageProvider: any(named: 'imageProvider'),
+ )).thenAnswer((_) async => imageBytes);
+
+ setUp(() {
+ mockQuillNativeBridge = MockQuillNativeBridge();
+ QuillNativeProvider.instance = mockQuillNativeBridge;
+
+ mockImageLoader = MockImageLoader();
+ ImageLoader.instance = mockImageLoader;
+
+ mockGallerySaveSupported(false);
+ mockImageSaveSupported(false);
+ when(() =>
+ mockQuillNativeBridge.saveImage(any(),
+ options: any(named: 'options'))).thenAnswer(
+ (_) async => const ImageSaveResult(blobUrl: null, filePath: null));
+
+ when(() => mockQuillNativeBridge.saveImageToGallery(any(),
+ options: any(named: 'options'))).thenAnswer((_) async {});
+ mockLoadImageBytesValue(null);
+ });
+
+ setUpAll(() {
+ registerFallbackValue(Uint8List.fromList([]));
+ registerFallbackValue(
+ const ImageSaveOptions(fileExtension: '', name: ''));
+ registerFallbackValue(
+ const GalleryImageSaveOptions(
+ albumName: '', name: '', fileExtension: ''),
+ );
+ registerFallbackValue(FakeImageProvider());
+ });
+
+ test('throws $ArgumentError when image URL is empty', () async {
+ await expectLater(
+ imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: '',
+ prefersGallerySave: false,
+ ),
+ throwsA(isA()),
+ );
+ });
+
+ test('does not throw $ArgumentError when the image URL is not empty',
+ () async {
+ await imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: '/foo/bar',
+ prefersGallerySave: false,
+ );
+ await expectLater(
+ imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: '/foo/bar',
+ prefersGallerySave: false,
+ ),
+ completes,
+ );
+ });
+
+ test('calls $ImageLoader to load the image bytes from the $ImageProvider',
+ () async {
+ await imageSaver.saveImage(
+ imageUrl: 'imageUrl',
+ imageProvider: FakeImageProvider(),
+ prefersGallerySave: false,
+ );
+ verify(
+ () => mockImageLoader.loadImageBytesFromImageProvider(
+ imageProvider: any(named: 'imageProvider')),
+ ).called(1);
+ });
+
+ test(
+ 'returns null when image bytes are null or empty',
+ () async {
+ await mockShouldSaveToGallery(true);
+
+ for (final imageBytes in {Uint8List.fromList([]), null}) {
+ mockLoadImageBytesValue(imageBytes);
+
+ final result = await imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: '/foo/bar',
+ prefersGallerySave: false,
+ );
+ expect(result, isNull);
+
+ verify(
+ () => mockImageLoader.loadImageBytesFromImageProvider(
+ imageProvider: any(named: 'imageProvider')),
+ ).called(1);
+ }
+ },
+ );
+
+ test(
+ 'calls saveImageToGallery from $QuillNativeBridge when shouldSaveToGallery is true',
+ () async {
+ await mockShouldSaveToGallery(true);
+
+ mockLoadImageBytesValue(Uint8List.fromList([1, 0, 1]));
+ await imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: '/foo/bar',
+ prefersGallerySave: false,
+ );
+ verify(
+ () => mockQuillNativeBridge.saveImageToGallery(any(),
+ options: any(named: 'options')),
+ ).called(1);
+ },
+ );
+
+ test(
+ 'does not call saveImageToGallery from $QuillNativeBridge when shouldSaveToGallery is false',
+ () async {
+ await mockShouldSaveToGallery(false);
+
+ mockLoadImageBytesValue(Uint8List.fromList([1, 0, 1]));
+ await imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: '/foo/bar',
+ prefersGallerySave: false,
+ );
+ verifyNever(
+ () => mockQuillNativeBridge.saveImageToGallery(any(),
+ options: any(named: 'options')),
+ );
+ },
+ );
+
+ test(
+ 'calls saveImageToGallery from $QuillNativeBridge when should save to the gallery and image bytes are not null',
+ () async {
+ await mockShouldSaveToGallery(true);
+
+ mockLoadImageBytesValue(Uint8List.fromList([1, 2, 2]));
+
+ final result = await imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: '/foo/bar',
+ prefersGallerySave: false,
+ );
+ expect(
+ result,
+ const SaveImageResult(
+ isGallerySave: true,
+ imageFilePath: null,
+ ),
+ );
+ verify(
+ () => mockQuillNativeBridge.saveImageToGallery(any(),
+ options: any(named: 'options')),
+ ).called(1);
+ },
+ );
+
+ test(
+ 'throws $GalleryImageSaveAccessDeniedException in case permission is denied',
+ () async {
+ await mockShouldSaveToGallery(true);
+
+ mockLoadImageBytesValue(Uint8List.fromList([1, 2, 2]));
+
+ final platformException = PlatformException(code: 'PERMISSION_DENIED');
+ when(() => mockQuillNativeBridge.saveImageToGallery(any(),
+ options: any(named: 'options'))).thenThrow(platformException);
+
+ await expectLater(
+ imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: '/foo/bar',
+ prefersGallerySave: false,
+ ),
+ throwsA(
+ isA().having(
+ (e) => e.message, 'message', platformException.toString()),
+ ),
+ );
+ });
+
+ test(
+ 'rethrows the $PlatformException in case permission is denied on macOS in debug-builds only (known macOS issue)',
+ () async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
+
+ await mockShouldSaveToGallery(true);
+
+ mockLoadImageBytesValue(Uint8List.fromList([1, 2, 2]));
+
+ final platformException = PlatformException(
+ code: 'PERMISSION_DENIED', message: 'A known macOS issue');
+ when(() => mockQuillNativeBridge.saveImageToGallery(any(),
+ options: any(named: 'options'))).thenThrow(platformException);
+
+ await expectLater(
+ imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: '/foo/bar',
+ prefersGallerySave: false,
+ ),
+ throwsA(
+ isA()
+ .having((e) => e.code, 'code', platformException.code)
+ .having((e) => e.message, 'message', platformException.message)
+ .having((e) => e.details, 'details', platformException.details),
+ ),
+ );
+ }, skip: kReleaseMode);
+
+ test(
+ 'rethrows the $PlatformException from $QuillNativeBridge if not handled',
+ () async {
+ // Currently, that's the expected behavior but it is subject to changes for improvements.
+ // See https://github.com/FlutterQuill/quill-native-bridge/issues/2
+
+ await mockShouldSaveToGallery(true);
+
+ mockLoadImageBytesValue(Uint8List.fromList([1, 2, 2]));
+
+ final exception = PlatformException(
+ code: 'EXAMPLE_CODE_${DateTime.now().toIso8601String()}',
+ message: 'An example exception that is not handled',
+ );
+ when(() => mockQuillNativeBridge.saveImageToGallery(any(),
+ options: any(named: 'options'))).thenThrow(exception);
+
+ await expectLater(
+ imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: '/foo/bar',
+ prefersGallerySave: false,
+ ),
+ throwsA(equals(exception)),
+ );
+ },
+ );
+
+ test(
+ 'calls isSupported from $QuillNativeBridge to check if image save supported when gallery save skipped',
+ () async {
+ await mockShouldSaveToGallery(false);
+
+ mockLoadImageBytesValue(Uint8List.fromList([1, 2, 2]));
+
+ await imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: '/foo/bar',
+ prefersGallerySave: false,
+ );
+
+ verify(() => mockQuillNativeBridge
+ .isSupported(QuillNativeBridgeFeature.saveImage)).called(1);
+ },
+ );
+
+ test(
+ 'calls saveImage from $QuillNativeBridge when supported and should not use gallery save',
+ () async {
+ await mockShouldSaveToGallery(false);
+
+ mockLoadImageBytesValue(Uint8List.fromList([1, 2, 2]));
+
+ mockImageSaveSupported(true);
+
+ await imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: '/foo/bar',
+ prefersGallerySave: false,
+ );
+
+ verify(() => mockQuillNativeBridge.saveImage(any(),
+ options: any(named: 'options'))).called(1);
+ },
+ );
+
+ test(
+ 'does not calls saveImage from $QuillNativeBridge when unsupported and should not use gallery save',
+ () async {
+ await mockShouldSaveToGallery(false);
+
+ mockLoadImageBytesValue(Uint8List.fromList([1, 2, 2]));
+
+ mockImageSaveSupported(false);
+
+ try {
+ await imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: '/foo/bar',
+ prefersGallerySave: false,
+ );
+ } on StateError catch (_) {
+ // Skip since another test handles it
+ }
+
+ verifyNever(() => mockQuillNativeBridge.saveImage(any(),
+ options: any(named: 'options')));
+ },
+ );
+
+ test(
+ 'passes the arugments correctly to saveImageToGallery from $QuillNativeBridge',
+ () async {
+ for (final imageUrl in {
+ 'path/to/file.png',
+ 'http://flutter-quill.org/file.png'
+ }) {
+ await mockShouldSaveToGallery(true);
+
+ final imageBytes = Uint8List.fromList([1, 0, 1]);
+ mockLoadImageBytesValue(imageBytes);
+
+ await imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: imageUrl,
+ prefersGallerySave: false,
+ );
+
+ final imageFileExtension =
+ extractImageFileExtensionFromImageSource(imageUrl);
+ final imageName = extractImageNameFromImageSource(imageUrl);
+
+ verify(
+ () => mockQuillNativeBridge.saveImageToGallery(
+ imageBytes,
+ options: GalleryImageSaveOptions(
+ name: imageName ?? getDefaultImageFileName(isGallerySave: true),
+ fileExtension: imageFileExtension,
+ albumName: null,
+ ),
+ ),
+ ).called(1);
+ }
+ },
+ );
+
+ test(
+ 'passes the arugments correctly to saveImage from $QuillNativeBridge',
+ () async {
+ for (final imageUrl in {
+ 'path/to/file.png',
+ 'http://flutter-quill.org/file.png'
+ }) {
+ await mockShouldSaveToGallery(false);
+
+ final imageBytes = Uint8List.fromList([1, 0, 1]);
+ mockLoadImageBytesValue(imageBytes);
+
+ mockImageSaveSupported(true);
+
+ await imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: imageUrl,
+ prefersGallerySave: false,
+ );
+
+ final imageFileExtension =
+ extractImageFileExtensionFromImageSource(imageUrl);
+ final imageName = extractImageNameFromImageSource(imageUrl);
+
+ verify(
+ () => mockQuillNativeBridge.saveImage(
+ imageBytes,
+ options: ImageSaveOptions(
+ name:
+ imageName ?? getDefaultImageFileName(isGallerySave: false),
+ fileExtension: imageFileExtension,
+ ),
+ ),
+ ).called(1);
+ }
+ },
+ );
+
+ test(
+ 'returns the $SaveImageResult correctly for image save',
+ () async {
+ await mockShouldSaveToGallery(false);
+
+ final imageBytes = Uint8List.fromList([1, 0, 1]);
+ mockLoadImageBytesValue(imageBytes);
+
+ mockImageSaveSupported(true);
+
+ const inputImagePath = 'path/to/example_file.png';
+
+ const savedImagePath = '/path/to/saved/example_file.png';
+
+ when(
+ () => mockQuillNativeBridge.saveImage(imageBytes,
+ options: any(named: 'options')),
+ ).thenAnswer((_) async =>
+ const ImageSaveResult(filePath: savedImagePath, blobUrl: null));
+
+ final result = await imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: inputImagePath,
+ prefersGallerySave: false,
+ );
+
+ expect(
+ result,
+ const SaveImageResult(
+ imageFilePath: savedImagePath, isGallerySave: false),
+ );
+ },
+ );
+
+ test(
+ 'throws $StateError when both image and gallery unsupported',
+ () async {
+ await mockShouldSaveToGallery(false);
+
+ mockLoadImageBytesValue(Uint8List.fromList([1, 2, 2]));
+
+ mockImageSaveSupported(false);
+
+ await expectLater(
+ imageSaver.saveImage(
+ imageProvider: FakeImageProvider(),
+ imageUrl: '/foo/bar',
+ prefersGallerySave: false,
+ ),
+ throwsA(isA().having((e) => e.message, 'message',
+ 'Image save is not handled on $defaultTargetPlatform')),
+ );
+ },
+ );
+ });
+}
+
+class MockQuillNativeBridge extends Mock implements QuillNativeBridge {}
+
+class MockImageLoader extends Mock implements ImageLoader {}
+
+class FakeImageProvider extends ImageProvider {
+ @override
+ Future