react-native-customizable-image-crop-picker 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/LICENSE +44 -0
  2. package/README.md +479 -0
  3. package/RNCustomizableImageCropPicker.podspec +26 -0
  4. package/android/.gradle/8.9/checksums/checksums.lock +0 -0
  5. package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
  6. package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
  7. package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
  8. package/android/.gradle/8.9/gc.properties +0 -0
  9. package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  10. package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
  11. package/android/.gradle/vcs-1/gc.properties +0 -0
  12. package/android/build/.transforms/a34a6297c7e9b65c987429ecd018e9a9/results.bin +1 -0
  13. package/android/build/.transforms/a34a6297c7e9b65c987429ecd018e9a9/transformed/classes/classes_dex/classes.dex +0 -0
  14. package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/results.bin +1 -0
  15. package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/rncustomizableimagecroppicker/BuildConfig.dex +0 -0
  16. package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/rncustomizableimagecroppicker/NativeImageCropperActivity$Companion.dex +0 -0
  17. package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/rncustomizableimagecroppicker/NativeImageCropperActivity.dex +0 -0
  18. package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/rncustomizableimagecroppicker/NativeImageCropperModule$Companion.dex +0 -0
  19. package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/rncustomizableimagecroppicker/NativeImageCropperModule.dex +0 -0
  20. package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/rncustomizableimagecroppicker/NativeImageCropperPackage.dex +0 -0
  21. package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/rncustomizableimagecroppicker/NativeImagePickerFileProvider.dex +0 -0
  22. package/android/build/.transforms/d07257a7807cb64bf14f5adb0f98ee25/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin +0 -0
  23. package/android/build/generated/source/buildConfig/debug/com/rncustomizableimagecroppicker/BuildConfig.java +10 -0
  24. package/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml +26 -0
  25. package/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json +18 -0
  26. package/android/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties +6 -0
  27. package/android/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json +1 -0
  28. package/android/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar +0 -0
  29. package/android/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar +0 -0
  30. package/android/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt +1 -0
  31. package/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/xml_nativeimagepicker_file_paths.xml.flat +0 -0
  32. package/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties +2 -0
  33. package/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml +2 -0
  34. package/android/build/intermediates/incremental/mergeDebugAssets/merger.xml +2 -0
  35. package/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml +2 -0
  36. package/android/build/intermediates/incremental/mergeDebugShaders/merger.xml +2 -0
  37. package/android/build/intermediates/java_res/debug/processDebugJavaRes/out/META-INF/react-native-customizable-image-crop-picker_debug.kotlin_module +0 -0
  38. package/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/rncustomizableimagecroppicker/BuildConfig.class +0 -0
  39. package/android/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt +3 -0
  40. package/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt +41 -0
  41. package/android/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml +26 -0
  42. package/android/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json +1 -0
  43. package/android/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt +1 -0
  44. package/android/build/intermediates/packaged_res/debug/packageDebugResources/xml/nativeimagepicker_file_paths.xml +6 -0
  45. package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/META-INF/react-native-customizable-image-crop-picker_debug.kotlin_module +0 -0
  46. package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/rncustomizableimagecroppicker/BuildConfig.class +0 -0
  47. package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/rncustomizableimagecroppicker/NativeImageCropperActivity$Companion.class +0 -0
  48. package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/rncustomizableimagecroppicker/NativeImageCropperActivity.class +0 -0
  49. package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/rncustomizableimagecroppicker/NativeImageCropperModule$Companion.class +0 -0
  50. package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/rncustomizableimagecroppicker/NativeImageCropperModule.class +0 -0
  51. package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/rncustomizableimagecroppicker/NativeImageCropperPackage.class +0 -0
  52. package/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/rncustomizableimagecroppicker/NativeImagePickerFileProvider.class +0 -0
  53. package/android/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar +0 -0
  54. package/android/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt +2 -0
  55. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab +0 -0
  56. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream +0 -0
  57. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream.len +0 -0
  58. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.len +0 -0
  59. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at +0 -0
  60. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i +0 -0
  61. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i.len +0 -0
  62. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab +0 -0
  63. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream +0 -0
  64. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream.len +0 -0
  65. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.len +0 -0
  66. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at +0 -0
  67. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i +0 -0
  68. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i.len +0 -0
  69. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab +0 -0
  70. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream +0 -0
  71. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream.len +0 -0
  72. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.len +0 -0
  73. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at +0 -0
  74. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i +0 -0
  75. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i.len +0 -0
  76. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab +0 -0
  77. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab.keystream +0 -0
  78. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab.keystream.len +0 -0
  79. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab.len +0 -0
  80. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab.values +0 -0
  81. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab.values.at +0 -0
  82. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab.values.s +1 -0
  83. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab_i +0 -0
  84. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/constants.tab_i.len +0 -0
  85. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab +0 -0
  86. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream +0 -0
  87. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream.len +0 -0
  88. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.len +0 -0
  89. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at +0 -0
  90. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i +0 -0
  91. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i.len +0 -0
  92. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab +0 -0
  93. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream +0 -0
  94. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream.len +0 -0
  95. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.len +0 -0
  96. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values +0 -0
  97. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at +0 -0
  98. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.s +1 -0
  99. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i +0 -0
  100. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i.len +0 -0
  101. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab +0 -0
  102. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream +0 -0
  103. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream.len +0 -0
  104. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.len +0 -0
  105. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at +0 -0
  106. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i +0 -0
  107. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i.len +0 -0
  108. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab +0 -0
  109. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream +0 -0
  110. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream.len +0 -0
  111. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.len +0 -0
  112. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at +0 -0
  113. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i +0 -0
  114. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i.len +0 -0
  115. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab +0 -0
  116. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream +0 -0
  117. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream.len +0 -0
  118. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.len +0 -0
  119. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at +0 -0
  120. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i +0 -0
  121. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i.len +0 -0
  122. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/counters.tab +2 -0
  123. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab +0 -0
  124. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream +0 -0
  125. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream.len +0 -0
  126. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.len +0 -0
  127. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at +0 -0
  128. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i +0 -0
  129. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i.len +0 -0
  130. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab +0 -0
  131. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream +0 -0
  132. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len +0 -0
  133. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len +0 -0
  134. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at +0 -0
  135. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i +0 -0
  136. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i.len +0 -0
  137. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab +0 -0
  138. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream +0 -0
  139. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len +0 -0
  140. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.len +0 -0
  141. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values +0 -0
  142. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at +0 -0
  143. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.s +1 -0
  144. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i +0 -0
  145. package/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i.len +0 -0
  146. package/android/build/kotlin/compileDebugKotlin/cacheable/last-build.bin +0 -0
  147. package/android/build/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin +0 -0
  148. package/android/build/kotlin/compileDebugKotlin/local-state/build-history.bin +0 -0
  149. package/android/build/outputs/logs/manifest-merger-debug-report.txt +46 -0
  150. package/android/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin +0 -0
  151. package/android/build/tmp/kotlin-classes/debug/META-INF/react-native-customizable-image-crop-picker_debug.kotlin_module +0 -0
  152. package/android/build/tmp/kotlin-classes/debug/com/rncustomizableimagecroppicker/NativeImageCropperActivity$Companion.class +0 -0
  153. package/android/build/tmp/kotlin-classes/debug/com/rncustomizableimagecroppicker/NativeImageCropperActivity.class +0 -0
  154. package/android/build/tmp/kotlin-classes/debug/com/rncustomizableimagecroppicker/NativeImageCropperModule$Companion.class +0 -0
  155. package/android/build/tmp/kotlin-classes/debug/com/rncustomizableimagecroppicker/NativeImageCropperModule.class +0 -0
  156. package/android/build/tmp/kotlin-classes/debug/com/rncustomizableimagecroppicker/NativeImageCropperPackage.class +0 -0
  157. package/android/build/tmp/kotlin-classes/debug/com/rncustomizableimagecroppicker/NativeImagePickerFileProvider.class +0 -0
  158. package/android/build.gradle +49 -0
  159. package/android/consumer-rules.pro +3 -0
  160. package/android/src/main/AndroidManifest.xml +23 -0
  161. package/android/src/main/java/com/rncustomizableimagecroppicker/NativeImageCropperActivity.kt +1009 -0
  162. package/android/src/main/java/com/rncustomizableimagecroppicker/NativeImageCropperModule.kt +684 -0
  163. package/android/src/main/java/com/rncustomizableimagecroppicker/NativeImageCropperPackage.kt +17 -0
  164. package/android/src/main/java/com/rncustomizableimagecroppicker/NativeImagePickerFileProvider.kt +6 -0
  165. package/android/src/main/res/xml/nativeimagepicker_file_paths.xml +6 -0
  166. package/ios/NativeImageCropperModule.m +13 -0
  167. package/ios/NativeImageCropperModule.swift +1400 -0
  168. package/package.json +116 -0
  169. package/react-native.config.js +13 -0
  170. package/src/api.ts +41 -0
  171. package/src/errors.ts +39 -0
  172. package/src/index.ts +4 -0
  173. package/src/native/NativeImageCropperModule.ts +34 -0
  174. package/src/native/mapOptions.ts +164 -0
  175. package/src/types.ts +258 -0
  176. package/src/ui/ImageCropPickerModal.tsx +322 -0
@@ -0,0 +1,1400 @@
1
+ //
2
+ // NativeImageCropperModule.swift
3
+ //
4
+ import AVFoundation
5
+ import CoreText
6
+ import Foundation
7
+ import ImageIO
8
+ import PhotosUI
9
+ import React
10
+ import TOCropViewController
11
+ import UniformTypeIdentifiers
12
+ import UIKit
13
+
14
+ @objc(NativeImageCropperModule)
15
+ class NativeImageCropperModule: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate, PHPickerViewControllerDelegate, TOCropViewControllerDelegate {
16
+ private let ePickerCancelled = "E_PICKER_CANCELLED"
17
+ private let ePickerCancelledMsg = "User cancelled image selection"
18
+ private let ePickerError = "E_PICKER_ERROR"
19
+ private let ePermissionMissing = "E_PERMISSION_MISSING"
20
+ private let eNoImageDataFound = "E_NO_IMAGE_DATA_FOUND"
21
+ private let eNoAppAvailable = "E_NO_APP_AVAILABLE"
22
+ private let sourceCamera = "camera"
23
+
24
+ private var pendingResolve: RCTPromiseResolveBlock?
25
+ private var pendingReject: RCTPromiseRejectBlock?
26
+ private var cropperConfig = CropperConfig()
27
+ private var isFlowInProgress = false
28
+
29
+ deinit {
30
+ cleanupFlow()
31
+ }
32
+
33
+ @objc
34
+ static func requiresMainQueueSetup() -> Bool {
35
+ true
36
+ }
37
+
38
+ @objc
39
+ func openImagePreview(
40
+ _ options: NSDictionary,
41
+ resolver resolve: @escaping RCTPromiseResolveBlock,
42
+ rejecter reject: @escaping RCTPromiseRejectBlock
43
+ ) {
44
+ DispatchQueue.main.async { [weak self] in
45
+ guard let self else { return }
46
+ guard !self.isFlowInProgress else {
47
+ reject(self.ePickerError, "Image picker is already in progress", nil)
48
+ return
49
+ }
50
+ guard let presenter = self.topViewController() else {
51
+ reject(self.ePickerError, "Unable to find visible view controller", nil)
52
+ return
53
+ }
54
+
55
+ self.isFlowInProgress = true
56
+ self.pendingResolve = resolve
57
+ self.pendingReject = reject
58
+ self.cropperConfig = CropperConfig.parse(from: options)
59
+
60
+ if self.cropperConfig.pickerSource == self.sourceCamera {
61
+ self.launchCamera(from: presenter)
62
+ } else {
63
+ self.launchGallery(from: presenter)
64
+ }
65
+ }
66
+ }
67
+
68
+ private func launchCamera(from presenter: UIViewController) {
69
+ guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
70
+ rejectPending(code: eNoAppAvailable, message: "Camera is not available on this device")
71
+ return
72
+ }
73
+
74
+ let cameraPermission = AVCaptureDevice.authorizationStatus(for: .video)
75
+ if cameraPermission == .denied || cameraPermission == .restricted {
76
+ rejectPending(code: ePermissionMissing, message: "Camera permission is not granted")
77
+ return
78
+ }
79
+ if cameraPermission == .notDetermined {
80
+ AVCaptureDevice.requestAccess(for: .video) { [weak self, weak presenter] granted in
81
+ DispatchQueue.main.async {
82
+ guard let self, let presenter else { return }
83
+ if !granted {
84
+ self.rejectPending(code: self.ePermissionMissing, message: "Camera permission is not granted")
85
+ return
86
+ }
87
+ self.launchCamera(from: presenter)
88
+ }
89
+ }
90
+ return
91
+ }
92
+
93
+ let picker = UIImagePickerController()
94
+ picker.delegate = self
95
+ picker.sourceType = .camera
96
+ picker.mediaTypes = ["public.image"]
97
+ picker.modalPresentationStyle = .fullScreen
98
+ presenter.present(picker, animated: true)
99
+ }
100
+
101
+ private func launchGallery(from presenter: UIViewController) {
102
+ if #available(iOS 14.0, *) {
103
+ var configuration = PHPickerConfiguration()
104
+ configuration.filter = .images
105
+ configuration.selectionLimit = 1
106
+
107
+ let picker = PHPickerViewController(configuration: configuration)
108
+ picker.delegate = self
109
+ picker.modalPresentationStyle = .fullScreen
110
+ presenter.present(picker, animated: true)
111
+ return
112
+ }
113
+
114
+ let picker = UIImagePickerController()
115
+ picker.delegate = self
116
+ picker.sourceType = .photoLibrary
117
+ picker.mediaTypes = ["public.image"]
118
+ picker.modalPresentationStyle = .fullScreen
119
+ // iOS 13 and below uses UIImagePickerController which requires photo library permission.
120
+ if #available(iOS 14.0, *) {
121
+ presenter.present(picker, animated: true)
122
+ return
123
+ }
124
+ let status = PHPhotoLibrary.authorizationStatus()
125
+ if status == .denied || status == .restricted {
126
+ rejectPending(code: ePermissionMissing, message: "Photo library permission is not granted")
127
+ return
128
+ }
129
+ if status == .notDetermined {
130
+ PHPhotoLibrary.requestAuthorization { [weak self, weak presenter] newStatus in
131
+ DispatchQueue.main.async {
132
+ guard let self, let presenter else { return }
133
+ if newStatus == .authorized {
134
+ presenter.present(picker, animated: true)
135
+ } else {
136
+ self.rejectPending(code: self.ePermissionMissing, message: "Photo library permission is not granted")
137
+ }
138
+ }
139
+ }
140
+ return
141
+ }
142
+ presenter.present(picker, animated: true)
143
+ }
144
+
145
+ private func presentCropper(for image: UIImage) {
146
+ guard let presenter = topViewController() else {
147
+ rejectPending(code: ePickerError, message: "Unable to show cropper screen")
148
+ return
149
+ }
150
+ guard let preparedImage = prepareImageForCropper(image) else {
151
+ rejectPending(code: eNoImageDataFound, message: "Selected image is invalid for cropping")
152
+ return
153
+ }
154
+
155
+ let cropper = NativeImageCropperViewController(
156
+ image: preparedImage,
157
+ config: cropperConfig,
158
+ colorParser: { [weak self] hex, fallback in
159
+ self?.parseColor(hex, fallback: fallback) ?? fallback
160
+ }
161
+ )
162
+ cropper.delegate = self
163
+ cropper.modalPresentationStyle = .fullScreen
164
+ if presenter.isBeingDismissed || presenter.isBeingPresented {
165
+ rejectPending(code: ePickerError, message: "Unable to present cropper right now")
166
+ return
167
+ }
168
+ presenter.present(cropper, animated: false)
169
+ }
170
+
171
+ func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
172
+ picker.dismiss(animated: true) { [weak self] in
173
+ self?.rejectPending(code: self?.ePickerCancelled ?? "E_PICKER_CANCELLED", message: self?.ePickerCancelledMsg ?? "User cancelled image selection")
174
+ }
175
+ }
176
+
177
+ func imagePickerController(
178
+ _ picker: UIImagePickerController,
179
+ didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
180
+ ) {
181
+ let selectedImage = (info[.editedImage] ?? info[.originalImage]) as? UIImage
182
+ picker.dismiss(animated: true) { [weak self] in
183
+ guard let self else { return }
184
+ guard let image = selectedImage else {
185
+ self.rejectPending(code: self.eNoImageDataFound, message: "Cannot resolve selected image")
186
+ return
187
+ }
188
+ self.presentCropper(for: image)
189
+ }
190
+ }
191
+
192
+ @available(iOS 14.0, *)
193
+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
194
+ picker.dismiss(animated: true) { [weak self] in
195
+ guard let self else { return }
196
+ guard let provider = results.first?.itemProvider else {
197
+ self.rejectPending(code: self.ePickerCancelled, message: self.ePickerCancelledMsg)
198
+ return
199
+ }
200
+ guard provider.canLoadObject(ofClass: UIImage.self) else {
201
+ self.rejectPending(code: self.eNoImageDataFound, message: "Selected item is not an image")
202
+ return
203
+ }
204
+
205
+ if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
206
+ provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { [weak self] data, error in
207
+ guard let self else { return }
208
+ if let error {
209
+ self.rejectPending(code: self.ePickerError, message: error.localizedDescription)
210
+ return
211
+ }
212
+ guard let data, let selectedImage = UIImage(data: data) else {
213
+ self.rejectPending(code: self.eNoImageDataFound, message: "Cannot decode selected image")
214
+ return
215
+ }
216
+ DispatchQueue.main.async {
217
+ self.presentCropper(for: selectedImage)
218
+ }
219
+ }
220
+ return
221
+ }
222
+
223
+ provider.loadObject(ofClass: UIImage.self) { [weak self] image, error in
224
+ guard let self else { return }
225
+ if let error {
226
+ self.rejectPending(code: self.ePickerError, message: error.localizedDescription)
227
+ return
228
+ }
229
+ guard let selectedImage = image as? UIImage else {
230
+ self.rejectPending(code: self.eNoImageDataFound, message: "Cannot resolve selected image")
231
+ return
232
+ }
233
+ DispatchQueue.main.async {
234
+ self.presentCropper(for: selectedImage)
235
+ }
236
+ }
237
+ }
238
+ }
239
+
240
+ func cropViewController(_ cropViewController: TOCropViewController, didFinishCancelled cancelled: Bool) {
241
+ cropViewController.dismiss(animated: true) { [weak self] in
242
+ self?.rejectPending(code: self?.ePickerCancelled ?? "E_PICKER_CANCELLED", message: self?.ePickerCancelledMsg ?? "User cancelled image selection")
243
+ }
244
+ }
245
+
246
+ func cropViewController(
247
+ _ cropViewController: TOCropViewController,
248
+ didCropTo image: UIImage,
249
+ with cropRect: CGRect,
250
+ angle: Int
251
+ ) {
252
+ cropViewController.dismiss(animated: true) { [weak self] in
253
+ guard let self else { return }
254
+ self.encodeAndResolve(image: image)
255
+ }
256
+ }
257
+
258
+ private func encodeAndResolve(image: UIImage) {
259
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
260
+ guard let self else { return }
261
+ guard self.isFlowInProgress else { return }
262
+ autoreleasepool {
263
+ guard let (imageData, fileExt) = self.encodeImage(image: image) else {
264
+ self.rejectPending(code: self.eNoImageDataFound, message: "Cannot encode selected image")
265
+ return
266
+ }
267
+ let tempPath = self.writeTempImage(data: imageData, fileExt: fileExt)
268
+ var payload: [String: Any] = [
269
+ "path": tempPath ?? "",
270
+ ]
271
+ if self.cropperConfig.includeBase64 {
272
+ payload["base64"] = imageData.base64EncodedString()
273
+ }
274
+ self.resolvePending(payload: payload)
275
+ }
276
+ }
277
+ }
278
+
279
+ private func encodeImage(image: UIImage) -> (Data, String)? {
280
+ let format = cropperConfig.compressFormat
281
+ let quality = min(max(cropperConfig.compressQuality, 0.0), 1.0)
282
+
283
+ if format == "png" {
284
+ guard let data = image.pngData() else { return nil }
285
+ return (data, "png")
286
+ }
287
+
288
+ if format == "webp" {
289
+ // Best-effort WebP encoding (iOS 14+). Falls back to JPEG if not available.
290
+ if #available(iOS 14.0, *), let cg = image.cgImage {
291
+ let type = UTType("org.webmproject.webp")
292
+ if let type {
293
+ let out = NSMutableData()
294
+ if let dest = CGImageDestinationCreateWithData(out, type.identifier as CFString, 1, nil) {
295
+ let options: [CFString: Any] = [
296
+ kCGImageDestinationLossyCompressionQuality: quality,
297
+ ]
298
+ CGImageDestinationAddImage(dest, cg, options as CFDictionary)
299
+ if CGImageDestinationFinalize(dest) {
300
+ return (out as Data, "webp")
301
+ }
302
+ }
303
+ }
304
+ }
305
+ // fallback
306
+ guard let data = image.jpegData(compressionQuality: CGFloat(quality)) else { return nil }
307
+ return (data, "jpg")
308
+ }
309
+
310
+ // jpeg (default)
311
+ guard let data = image.jpegData(compressionQuality: CGFloat(quality)) else { return nil }
312
+ return (data, "jpg")
313
+ }
314
+
315
+ private func writeTempImage(data: Data, fileExt: String) -> String? {
316
+ do {
317
+ let directory = FileManager.default.temporaryDirectory
318
+ let ext = fileExt.isEmpty ? "jpg" : fileExt
319
+ let url = directory.appendingPathComponent("native-cropped-\(UUID().uuidString).\(ext)")
320
+ try data.write(to: url, options: .atomic)
321
+ return url.path
322
+ } catch {
323
+ return nil
324
+ }
325
+ }
326
+
327
+ private func parseColor(_ value: String, fallback: UIColor) -> UIColor {
328
+ guard value.hasPrefix("#") else { return fallback }
329
+ var hex = String(value.dropFirst())
330
+ if hex.count == 6 {
331
+ hex = "FF" + hex
332
+ }
333
+ guard hex.count == 8, let intVal = UInt64(hex, radix: 16) else {
334
+ return fallback
335
+ }
336
+ let a = CGFloat((intVal >> 24) & 0xFF) / 255.0
337
+ let r = CGFloat((intVal >> 16) & 0xFF) / 255.0
338
+ let g = CGFloat((intVal >> 8) & 0xFF) / 255.0
339
+ let b = CGFloat(intVal & 0xFF) / 255.0
340
+ return UIColor(red: r, green: g, blue: b, alpha: a)
341
+ }
342
+
343
+ private func prepareImageForCropper(_ image: UIImage) -> UIImage? {
344
+ guard image.size.width > 0, image.size.height > 0 else {
345
+ return nil
346
+ }
347
+ if image.cgImage != nil {
348
+ return image
349
+ }
350
+ let format = UIGraphicsImageRendererFormat.default()
351
+ format.scale = image.scale
352
+ let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
353
+ let normalized = renderer.image { _ in
354
+ image.draw(in: CGRect(origin: .zero, size: image.size))
355
+ }
356
+ guard normalized.size.width > 0, normalized.size.height > 0 else {
357
+ return nil
358
+ }
359
+ return normalized
360
+ }
361
+
362
+ private func topViewController() -> UIViewController? {
363
+ let keyWindow: UIWindow? = {
364
+ if #available(iOS 13.0, *) {
365
+ let scenes = UIApplication.shared.connectedScenes
366
+ .compactMap { $0 as? UIWindowScene }
367
+ .filter { $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive }
368
+ for scene in scenes {
369
+ for window in scene.windows where window.isKeyWindow {
370
+ return window
371
+ }
372
+ }
373
+ return scenes.first?.windows.first
374
+ }
375
+ // iOS 12 and below
376
+ return UIApplication.shared.keyWindow
377
+ }()
378
+ var controller = keyWindow?.rootViewController
379
+ while let presented = controller?.presentedViewController {
380
+ controller = presented
381
+ }
382
+ return controller
383
+ }
384
+
385
+ private func resolvePending(payload: [String: Any]) {
386
+ DispatchQueue.main.async { [weak self] in
387
+ guard let self else { return }
388
+ guard self.isFlowInProgress else { return }
389
+ self.pendingResolve?(payload)
390
+ self.cleanupFlow()
391
+ }
392
+ }
393
+
394
+ private func rejectPending(code: String, message: String) {
395
+ DispatchQueue.main.async { [weak self] in
396
+ guard let self else { return }
397
+ guard self.isFlowInProgress else { return }
398
+ self.pendingReject?(code, message, nil)
399
+ self.cleanupFlow()
400
+ }
401
+ }
402
+
403
+ private func cleanupFlow() {
404
+ pendingResolve = nil
405
+ pendingReject = nil
406
+ isFlowInProgress = false
407
+ }
408
+ }
409
+
410
+ private struct HeaderStyleConfig {
411
+ var backgroundColor = "#FFFFFF"
412
+ var color = "#2D2D2D"
413
+ var fontSize = 22
414
+ var fontFamily = ""
415
+ // When controls are in the footer ("bottom"), keep the header compact by default.
416
+ // When controls are in the header ("top"), header is taller to fit the action buttons.
417
+ var height: Int = 56
418
+ var paddingHorizontal: Int = 20
419
+ var paddingTop: Int = 12
420
+ var paddingBottom: Int = 12
421
+ }
422
+
423
+ private struct ButtonContainerStyleConfig {
424
+ var backgroundColor = "#FFFFFF"
425
+ var paddingHorizontal: Int = 20
426
+ var paddingTop: Int = 16
427
+ var paddingBottom: Int = 24
428
+ var gap: Int = 12
429
+ var buttonHeight: Int = 54
430
+ var layout: String = "vertical"
431
+ }
432
+
433
+ private struct ButtonStyleConfig {
434
+ var textColor: String
435
+ var backgroundColor: String
436
+ var borderColor: String
437
+ var borderWidth: Int
438
+ var fontSize: Int
439
+ var fontFamily: String
440
+ var borderRadius: Int = 27
441
+ var content: String = "text" // text | icon | iconText | textIcon (also accepts icon+text, text+icon, TextIcon)
442
+ var iconUri: String = ""
443
+ var iconBase64: String = ""
444
+ var iconTintColor: String = ""
445
+ var iconSize: Int = 18
446
+ var iconGap: Int = 8
447
+ var paddingHorizontal: Int = 12
448
+ var paddingVertical: Int = 0
449
+ }
450
+
451
+ private struct CropperConfig {
452
+ var cropWidth = 100
453
+ var cropHeight = 100
454
+ var freeStyleCropEnabled = false
455
+ var compressQuality: Double = 1.0 // 0..1
456
+ var compressFormat: String = "jpeg" // jpeg | png | webp
457
+ var circularCrop = false
458
+ var rotationEnabled = false
459
+ var cropGridEnabled = false
460
+ var cropGridColor: String = ""
461
+ var dimmedLayerColor: String = "#B3000000"
462
+ var showNativeCropControls = false
463
+ var isDarkTheme = false
464
+ var statusBarColor = "#FFFFFF"
465
+ var includeBase64 = false
466
+ var headerTitle = "Preview Image"
467
+ var headerAlignment = "left" // left | center | right
468
+ var cancelText = "Cancel"
469
+ var uploadText = "Upload"
470
+ // Defaults aligned with the "demo button layout":
471
+ // - Cancel is a light button with dark text
472
+ // - Upload is a black button with white text
473
+ var cancelColor = "#111111"
474
+ var uploadColor = "#111111"
475
+ var pickerSource = "gallery"
476
+ var controlsPlacement = "bottom" // bottom | top
477
+ var topLeftControl = "cancel" // cancel | upload | none
478
+ var topRightControl = "upload" // cancel | upload | none
479
+ var footerButtonOrder = "uploadFirst" // uploadFirst | cancelFirst
480
+ var headerStyle = HeaderStyleConfig()
481
+ var buttonContainerStyle = ButtonContainerStyleConfig()
482
+ var cancelButtonStyle = ButtonStyleConfig(
483
+ textColor: "#111111",
484
+ backgroundColor: "#EFEFF4",
485
+ borderColor: "#EFEFF4",
486
+ borderWidth: 0,
487
+ fontSize: 16,
488
+ fontFamily: ""
489
+ )
490
+ var uploadButtonStyle = ButtonStyleConfig(
491
+ textColor: "#FFFFFF",
492
+ backgroundColor: "#111111",
493
+ borderColor: "#111111",
494
+ borderWidth: 0,
495
+ fontSize: 16,
496
+ fontFamily: ""
497
+ )
498
+
499
+ static func parse(from options: NSDictionary) -> CropperConfig {
500
+ var config = CropperConfig()
501
+ config.cropWidth = max(options["width"] as? Int ?? 100, 1)
502
+ config.cropHeight = max(options["height"] as? Int ?? 100, 1)
503
+ config.freeStyleCropEnabled = options["freeStyleCropEnabled"] as? Bool ?? false
504
+ config.isDarkTheme = options["isDarkTheme"] as? Bool ?? false
505
+ if let q = options["compressQuality"] as? NSNumber {
506
+ config.compressQuality = min(max(q.doubleValue, 0.0), 1.0)
507
+ }
508
+ if let rawFmt = (options["compressFormat"] as? String)?.nonEmpty?.lowercased() {
509
+ config.compressFormat = rawFmt
510
+ }
511
+ if config.compressFormat != "jpeg", config.compressFormat != "png", config.compressFormat != "webp" {
512
+ config.compressFormat = "jpeg"
513
+ }
514
+ config.circularCrop = options["circularCrop"] as? Bool ?? false
515
+ config.rotationEnabled = options["rotationEnabled"] as? Bool ?? false
516
+ config.cropGridEnabled = options["cropGridEnabled"] as? Bool ?? false
517
+ config.cropGridColor = (options["cropGridColor"] as? String)?.nonEmpty ?? ""
518
+ config.showNativeCropControls = options["showNativeCropControls"] as? Bool ?? false
519
+ config.dimmedLayerColor = (options["cropperDimmedLayerColor"] as? String)?.nonEmpty
520
+ ?? (options["cropOverlayColor"] as? String)?.nonEmpty
521
+ ?? (config.isDarkTheme ? "#E0000000" : "#B3000000")
522
+ config.includeBase64 = options["includeBase64"] as? Bool ?? false
523
+ config.statusBarColor = (options["cropperStatusBarColor"] as? String)?.nonEmpty
524
+ ?? (config.isDarkTheme ? "#000000" : "#FFFFFF")
525
+ config.headerTitle = (options["cropperToolbarTitle"] as? String)?.nonEmpty ?? config.headerTitle
526
+ config.headerAlignment = (options["headerAlignment"] as? String)?.nonEmpty ?? config.headerAlignment
527
+ config.cancelText = (options["cropperCancelText"] as? String)?.nonEmpty ?? config.cancelText
528
+ config.uploadText = (options["cropperChooseText"] as? String)?.nonEmpty ?? config.uploadText
529
+ config.cancelColor = (options["cropperCancelColor"] as? String)?.nonEmpty ?? config.cancelColor
530
+ config.uploadColor = (options["cropperChooseColor"] as? String)?.nonEmpty ?? config.uploadColor
531
+ config.pickerSource = (options["pickerSource"] as? String)?.nonEmpty ?? config.pickerSource
532
+ config.controlsPlacement = (options["controlsPlacement"] as? String)?.nonEmpty == "top" ? "top" : "bottom"
533
+ // If native crop controls (toolbar) are visible, we must place action buttons in the header.
534
+ // Otherwise the footer overlaps the toolbar and makes it look "missing" on iOS.
535
+ if config.showNativeCropControls { config.controlsPlacement = "top" }
536
+ config.topLeftControl = (options["topLeftControl"] as? String)?.nonEmpty ?? config.topLeftControl
537
+ config.topRightControl = (options["topRightControl"] as? String)?.nonEmpty ?? config.topRightControl
538
+ config.footerButtonOrder = (options["footerButtonOrder"] as? String)?.nonEmpty ?? config.footerButtonOrder
539
+
540
+ // sanitize
541
+ let allowedControl = Set(["cancel", "upload", "none"])
542
+ if !allowedControl.contains(config.topLeftControl) { config.topLeftControl = "cancel" }
543
+ if !allowedControl.contains(config.topRightControl) { config.topRightControl = "upload" }
544
+ if config.topLeftControl == config.topRightControl { config.topRightControl = "none" }
545
+ if config.footerButtonOrder != "cancelFirst" { config.footerButtonOrder = "uploadFirst" }
546
+ let allowedAlignment = Set(["left", "center", "right"])
547
+ if !allowedAlignment.contains(config.headerAlignment) { config.headerAlignment = "left" }
548
+
549
+ if let headerStyle = options["headerStyle"] as? NSDictionary {
550
+ config.headerStyle.backgroundColor = (headerStyle["backgroundColor"] as? String)?.nonEmpty ?? config.headerStyle.backgroundColor
551
+ config.headerStyle.color = (headerStyle["color"] as? String)?.nonEmpty
552
+ ?? (headerStyle["titleColor"] as? String)?.nonEmpty
553
+ ?? config.headerStyle.color
554
+ config.headerStyle.fontSize = max(
555
+ (headerStyle["fontSize"] as? Int)
556
+ ?? (headerStyle["titleFontSize"] as? Int)
557
+ ?? config.headerStyle.fontSize,
558
+ 10
559
+ )
560
+ config.headerStyle.fontFamily = (headerStyle["fontFamily"] as? String)?.nonEmpty
561
+ ?? (headerStyle["titleFontFamily"] as? String)?.nonEmpty
562
+ ?? config.headerStyle.fontFamily
563
+ let defaultHeaderHeight = (config.controlsPlacement == "top") ? 84 : 56
564
+ config.headerStyle.height = max(headerStyle["height"] as? Int ?? defaultHeaderHeight, 48)
565
+ config.headerStyle.paddingHorizontal = max(headerStyle["paddingHorizontal"] as? Int ?? config.headerStyle.paddingHorizontal, 0)
566
+ let defaultPadTop = (config.controlsPlacement == "top") ? 20 : 12
567
+ let defaultPadBottom = (config.controlsPlacement == "top") ? 20 : 12
568
+ config.headerStyle.paddingTop = max(headerStyle["paddingTop"] as? Int ?? defaultPadTop, 0)
569
+ config.headerStyle.paddingBottom = max(headerStyle["paddingBottom"] as? Int ?? defaultPadBottom, 0)
570
+ } else {
571
+ // Apply compact defaults when controls are in the footer.
572
+ if config.controlsPlacement == "top" {
573
+ config.headerStyle.height = 84
574
+ config.headerStyle.paddingTop = 20
575
+ config.headerStyle.paddingBottom = 20
576
+ } else {
577
+ config.headerStyle.height = 56
578
+ config.headerStyle.paddingTop = 12
579
+ config.headerStyle.paddingBottom = 12
580
+ }
581
+ }
582
+
583
+ if let containerStyle = options["buttonContainerStyle"] as? NSDictionary {
584
+ config.buttonContainerStyle.backgroundColor = (containerStyle["backgroundColor"] as? String)?.nonEmpty ?? config.buttonContainerStyle.backgroundColor
585
+ config.buttonContainerStyle.paddingHorizontal = max(containerStyle["paddingHorizontal"] as? Int ?? config.buttonContainerStyle.paddingHorizontal, 0)
586
+ config.buttonContainerStyle.paddingTop = max(containerStyle["paddingTop"] as? Int ?? config.buttonContainerStyle.paddingTop, 0)
587
+ config.buttonContainerStyle.paddingBottom = max(containerStyle["paddingBottom"] as? Int ?? config.buttonContainerStyle.paddingBottom, 0)
588
+ config.buttonContainerStyle.gap = max(containerStyle["gap"] as? Int ?? config.buttonContainerStyle.gap, 0)
589
+ config.buttonContainerStyle.buttonHeight = max(containerStyle["buttonHeight"] as? Int ?? config.buttonContainerStyle.buttonHeight, 1)
590
+ config.buttonContainerStyle.layout =
591
+ ((containerStyle["layout"] as? String)?.nonEmpty == "horizontal") ? "horizontal" : "vertical"
592
+ }
593
+
594
+ if let cancelStyle = options["cancelButtonStyle"] as? NSDictionary {
595
+ config.cancelButtonStyle.textColor = (cancelStyle["textColor"] as? String)?.nonEmpty ?? config.cancelButtonStyle.textColor
596
+ config.cancelButtonStyle.backgroundColor = (cancelStyle["backgroundColor"] as? String)?.nonEmpty ?? config.cancelButtonStyle.backgroundColor
597
+ config.cancelButtonStyle.borderColor = (cancelStyle["borderColor"] as? String)?.nonEmpty ?? config.cancelButtonStyle.borderColor
598
+ config.cancelButtonStyle.borderWidth = max(cancelStyle["borderWidth"] as? Int ?? config.cancelButtonStyle.borderWidth, 0)
599
+ config.cancelButtonStyle.fontSize = max(cancelStyle["fontSize"] as? Int ?? config.cancelButtonStyle.fontSize, 10)
600
+ config.cancelButtonStyle.fontFamily = (cancelStyle["fontFamily"] as? String)?.nonEmpty ?? config.cancelButtonStyle.fontFamily
601
+ config.cancelButtonStyle.borderRadius = max(cancelStyle["borderRadius"] as? Int ?? config.cancelButtonStyle.borderRadius, 0)
602
+ config.cancelColor = config.cancelButtonStyle.textColor
603
+ config.cancelButtonStyle.content = (cancelStyle["content"] as? String)?.nonEmpty ?? config.cancelButtonStyle.content
604
+ config.cancelButtonStyle.iconUri = (cancelStyle["iconUri"] as? String)?.nonEmpty ?? config.cancelButtonStyle.iconUri
605
+ config.cancelButtonStyle.iconBase64 = (cancelStyle["iconBase64"] as? String)?.nonEmpty ?? config.cancelButtonStyle.iconBase64
606
+ config.cancelButtonStyle.iconTintColor = (cancelStyle["iconTintColor"] as? String)?.nonEmpty ?? config.cancelButtonStyle.iconTintColor
607
+ config.cancelButtonStyle.iconSize = max(cancelStyle["iconSize"] as? Int ?? config.cancelButtonStyle.iconSize, 12)
608
+ config.cancelButtonStyle.iconGap = max(cancelStyle["iconGap"] as? Int ?? config.cancelButtonStyle.iconGap, 0)
609
+ config.cancelButtonStyle.paddingHorizontal = max(cancelStyle["paddingHorizontal"] as? Int ?? config.cancelButtonStyle.paddingHorizontal, 0)
610
+ config.cancelButtonStyle.paddingVertical = max(cancelStyle["paddingVertical"] as? Int ?? config.cancelButtonStyle.paddingVertical, 0)
611
+ }
612
+
613
+ if let uploadStyle = options["uploadButtonStyle"] as? NSDictionary {
614
+ config.uploadButtonStyle.textColor = (uploadStyle["textColor"] as? String)?.nonEmpty ?? config.uploadButtonStyle.textColor
615
+ config.uploadButtonStyle.backgroundColor = (uploadStyle["backgroundColor"] as? String)?.nonEmpty ?? config.uploadButtonStyle.backgroundColor
616
+ config.uploadButtonStyle.borderColor = (uploadStyle["borderColor"] as? String)?.nonEmpty ?? config.uploadButtonStyle.borderColor
617
+ config.uploadButtonStyle.borderWidth = max(uploadStyle["borderWidth"] as? Int ?? config.uploadButtonStyle.borderWidth, 0)
618
+ config.uploadButtonStyle.fontSize = max(uploadStyle["fontSize"] as? Int ?? config.uploadButtonStyle.fontSize, 10)
619
+ config.uploadButtonStyle.fontFamily = (uploadStyle["fontFamily"] as? String)?.nonEmpty ?? config.uploadButtonStyle.fontFamily
620
+ config.uploadButtonStyle.borderRadius = max(uploadStyle["borderRadius"] as? Int ?? config.uploadButtonStyle.borderRadius, 0)
621
+ config.uploadColor = config.uploadButtonStyle.backgroundColor
622
+ config.uploadButtonStyle.content = (uploadStyle["content"] as? String)?.nonEmpty ?? config.uploadButtonStyle.content
623
+ config.uploadButtonStyle.iconUri = (uploadStyle["iconUri"] as? String)?.nonEmpty ?? config.uploadButtonStyle.iconUri
624
+ config.uploadButtonStyle.iconBase64 = (uploadStyle["iconBase64"] as? String)?.nonEmpty ?? config.uploadButtonStyle.iconBase64
625
+ config.uploadButtonStyle.iconTintColor = (uploadStyle["iconTintColor"] as? String)?.nonEmpty ?? config.uploadButtonStyle.iconTintColor
626
+ config.uploadButtonStyle.iconSize = max(uploadStyle["iconSize"] as? Int ?? config.uploadButtonStyle.iconSize, 12)
627
+ config.uploadButtonStyle.iconGap = max(uploadStyle["iconGap"] as? Int ?? config.uploadButtonStyle.iconGap, 0)
628
+ config.uploadButtonStyle.paddingHorizontal = max(uploadStyle["paddingHorizontal"] as? Int ?? config.uploadButtonStyle.paddingHorizontal, 0)
629
+ config.uploadButtonStyle.paddingVertical = max(uploadStyle["paddingVertical"] as? Int ?? config.uploadButtonStyle.paddingVertical, 0)
630
+ }
631
+
632
+ return config
633
+ }
634
+ }
635
+
636
+ private extension String {
637
+ var nonEmpty: String? {
638
+ isEmpty ? nil : self
639
+ }
640
+ }
641
+
642
+ private func defaultLabelColor() -> UIColor {
643
+ if #available(iOS 13.0, *) {
644
+ return .label
645
+ }
646
+ return .black
647
+ }
648
+
649
+ private final class NativeImageCropperViewController: TOCropViewController {
650
+ private static let iconCache = NSCache<NSString, UIImage>()
651
+ private let config: CropperConfig
652
+ private let parseColor: (String, UIColor) -> UIColor
653
+ private var statusBarFillHeightConstraint: NSLayoutConstraint?
654
+ private var footerHeightConstraint: NSLayoutConstraint?
655
+ private var isCommittingCrop = false
656
+ private var circularGridOverlay: CircularGridOverlayView?
657
+
658
+ override var preferredStatusBarStyle: UIStatusBarStyle {
659
+ if #available(iOS 13.0, *) {
660
+ return config.isDarkTheme ? .lightContent : .darkContent
661
+ }
662
+ return config.isDarkTheme ? .lightContent : .default
663
+ }
664
+
665
+ private lazy var headerContainer: UIView = {
666
+ let view = UIView()
667
+ view.translatesAutoresizingMaskIntoConstraints = false
668
+ view.backgroundColor = parseColor(config.headerStyle.backgroundColor, .white)
669
+ return view
670
+ }()
671
+
672
+ private lazy var statusBarFillView: UIView = {
673
+ let view = UIView()
674
+ view.translatesAutoresizingMaskIntoConstraints = false
675
+ view.backgroundColor = parseColor(config.statusBarColor, .white)
676
+ return view
677
+ }()
678
+
679
+ private lazy var headerTitleLabel: UILabel = {
680
+ let label = UILabel()
681
+ label.translatesAutoresizingMaskIntoConstraints = false
682
+ label.text = config.headerTitle
683
+ if config.controlsPlacement == "top" {
684
+ label.textAlignment = .center
685
+ } else {
686
+ switch config.headerAlignment {
687
+ case "center":
688
+ label.textAlignment = .center
689
+ case "right":
690
+ label.textAlignment = .right
691
+ default:
692
+ label.textAlignment = .left
693
+ }
694
+ }
695
+ label.textColor = parseColor(config.headerStyle.color, defaultLabelColor())
696
+ label.font = resolvedFont(
697
+ family: config.headerStyle.fontFamily,
698
+ size: CGFloat(config.headerStyle.fontSize),
699
+ fallback: UIFont.systemFont(ofSize: CGFloat(config.headerStyle.fontSize), weight: .bold)
700
+ )
701
+ return label
702
+ }()
703
+
704
+ private lazy var footerContainer: UIView = {
705
+ let view = UIView()
706
+ view.translatesAutoresizingMaskIntoConstraints = false
707
+ view.backgroundColor = parseColor(config.buttonContainerStyle.backgroundColor, .white)
708
+ return view
709
+ }()
710
+
711
+ private lazy var cancelButton: UIButton = createActionButton(
712
+ title: config.cancelText,
713
+ style: config.cancelButtonStyle
714
+ )
715
+
716
+ private lazy var uploadButton: UIButton = createActionButton(
717
+ title: config.uploadText,
718
+ style: config.uploadButtonStyle
719
+ )
720
+
721
+ init(
722
+ image: UIImage,
723
+ config: CropperConfig,
724
+ colorParser: @escaping (String, UIColor) -> UIColor
725
+ ) {
726
+ self.config = config
727
+ self.parseColor = colorParser
728
+ super.init(croppingStyle: config.circularCrop ? .circular : .default, image: image)
729
+ }
730
+
731
+ required override init(croppingStyle style: TOCropViewCroppingStyle, image: UIImage) {
732
+ self.config = CropperConfig()
733
+ self.parseColor = { _, fallback in fallback }
734
+ super.init(croppingStyle: style, image: image)
735
+ }
736
+
737
+ @available(*, unavailable)
738
+ required init?(coder: NSCoder) {
739
+ fatalError("init(coder:) has not been implemented")
740
+ }
741
+
742
+ override func viewDidLoad() {
743
+ super.viewDidLoad()
744
+ modalPresentationCapturesStatusBarAppearance = true
745
+ setupCropBehavior()
746
+ setupCustomChrome()
747
+ }
748
+
749
+ override func viewWillAppear(_ animated: Bool) {
750
+ super.viewWillAppear(animated)
751
+ setNeedsStatusBarAppearanceUpdate()
752
+ }
753
+
754
+ override func viewDidLayoutSubviews() {
755
+ super.viewDidLayoutSubviews()
756
+ statusBarFillHeightConstraint?.constant = view.safeAreaInsets.top
757
+ footerHeightConstraint?.constant =
758
+ footerBaseHeight() + view.safeAreaInsets.bottom
759
+
760
+ // Ensure pill rounding stays correct after AutoLayout updates button sizes.
761
+ applyButtonRounding(button: cancelButton, style: config.cancelButtonStyle)
762
+ applyButtonRounding(button: uploadButton, style: config.uploadButtonStyle)
763
+
764
+ // Keep circular grid overlay in sync with layout.
765
+ if let grid = circularGridOverlay {
766
+ grid.frame = cropView.foregroundContainerView.bounds
767
+ grid.setNeedsLayout()
768
+ }
769
+ }
770
+
771
+ private func footerBaseHeight() -> CGFloat {
772
+ if config.controlsPlacement == "top" {
773
+ return 0
774
+ }
775
+ let base =
776
+ CGFloat(config.buttonContainerStyle.paddingTop +
777
+ config.buttonContainerStyle.paddingBottom +
778
+ config.buttonContainerStyle.buttonHeight)
779
+ if config.buttonContainerStyle.layout == "horizontal" {
780
+ return base
781
+ }
782
+ return base + CGFloat(config.buttonContainerStyle.gap + config.buttonContainerStyle.buttonHeight)
783
+ }
784
+
785
+ private func setupCropBehavior() {
786
+ doneButtonHidden = true
787
+ cancelButtonHidden = true
788
+ rotateButtonsHidden = !config.rotationEnabled
789
+ resetButtonHidden = !config.rotationEnabled
790
+ aspectRatioPickerButtonHidden = !config.showNativeCropControls
791
+ toolbar.isHidden = !config.showNativeCropControls
792
+ showCancelConfirmationDialog = false
793
+
794
+ setAspectRatioPreset(CGSize(width: config.cropWidth, height: config.cropHeight), animated: false)
795
+ aspectRatioLockEnabled = !config.freeStyleCropEnabled
796
+ // Keep the selected aspect ratio when user taps reset.
797
+ // Otherwise TOCropViewController resets back to the image's original ratio,
798
+ // which looks especially wrong for circular crops.
799
+ resetAspectRatioEnabled = false
800
+ aspectRatioLockDimensionSwapEnabled = true
801
+ cropView.cropBoxResizeEnabled = config.freeStyleCropEnabled
802
+ cropView.overlayView.backgroundColor = parseColor(config.dimmedLayerColor, .black.withAlphaComponent(0.7))
803
+
804
+ let footerBase = footerBaseHeight()
805
+ cropView.cropRegionInsets = UIEdgeInsets(
806
+ top: CGFloat(config.headerStyle.height),
807
+ left: 0,
808
+ bottom: footerBase,
809
+ right: 0
810
+ )
811
+ cropView.performInitialSetup()
812
+
813
+ // Grid overlay:
814
+ // - Rectangle mode: use built-in grid overlay.
815
+ // - Circular mode: TOCropViewController doesn't create the built-in grid view, so we render our own.
816
+ if config.circularCrop {
817
+ circularGridOverlay?.removeFromSuperview()
818
+ circularGridOverlay = nil
819
+ if config.cropGridEnabled {
820
+ let color = config.cropGridColor.nonEmpty.flatMap { UIColor(hex: $0) } ?? UIColor.white.withAlphaComponent(0.9)
821
+ let grid = CircularGridOverlayView(frame: cropView.foregroundContainerView.bounds, lineColor: color)
822
+ grid.isUserInteractionEnabled = false
823
+ cropView.foregroundContainerView.addSubview(grid)
824
+ circularGridOverlay = grid
825
+ }
826
+ } else {
827
+ circularGridOverlay?.removeFromSuperview()
828
+ circularGridOverlay = nil
829
+ cropView.gridOverlayHidden = !config.cropGridEnabled
830
+ cropView.alwaysShowCroppingGrid = config.cropGridEnabled
831
+ cropView.setGridOverlayHidden(!config.cropGridEnabled, animated: false)
832
+ }
833
+ }
834
+
835
+ private func setupCustomChrome() {
836
+ view.addSubview(statusBarFillView)
837
+ view.addSubview(headerContainer)
838
+ headerContainer.addSubview(headerTitleLabel)
839
+ view.addSubview(footerContainer)
840
+ if config.controlsPlacement == "top" {
841
+ headerContainer.addSubview(cancelButton)
842
+ headerContainer.addSubview(uploadButton)
843
+ footerContainer.isHidden = true
844
+ } else {
845
+ footerContainer.addSubview(cancelButton)
846
+ footerContainer.addSubview(uploadButton)
847
+ }
848
+
849
+ cancelButton.addTarget(self, action: #selector(onCancelTapped), for: .touchUpInside)
850
+ uploadButton.addTarget(self, action: #selector(onUploadTapped), for: .touchUpInside)
851
+
852
+ statusBarFillHeightConstraint = statusBarFillView.heightAnchor.constraint(equalToConstant: view.safeAreaInsets.top)
853
+ footerHeightConstraint = footerContainer.heightAnchor.constraint(equalToConstant: footerBaseHeight() + view.safeAreaInsets.bottom)
854
+
855
+ guard let statusHeightConstraint = statusBarFillHeightConstraint,
856
+ let bottomHeightConstraint = footerHeightConstraint else {
857
+ return
858
+ }
859
+
860
+ var constraints: [NSLayoutConstraint] = [
861
+ statusBarFillView.topAnchor.constraint(equalTo: view.topAnchor),
862
+ statusBarFillView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
863
+ statusBarFillView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
864
+ statusHeightConstraint,
865
+
866
+ headerContainer.topAnchor.constraint(equalTo: statusBarFillView.bottomAnchor),
867
+ headerContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
868
+ headerContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
869
+ headerContainer.heightAnchor.constraint(equalToConstant: CGFloat(config.headerStyle.height)),
870
+ headerTitleLabel.topAnchor.constraint(equalTo: headerContainer.topAnchor, constant: CGFloat(config.headerStyle.paddingTop)),
871
+ headerTitleLabel.bottomAnchor.constraint(equalTo: headerContainer.bottomAnchor, constant: -CGFloat(config.headerStyle.paddingBottom)),
872
+
873
+ footerContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
874
+ footerContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
875
+ footerContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor),
876
+ bottomHeightConstraint,
877
+ ]
878
+
879
+ if config.controlsPlacement == "top" {
880
+ let left = config.topLeftControl
881
+ let right = config.topRightControl
882
+ cancelButton.isHidden = !(left == "cancel" || right == "cancel")
883
+ uploadButton.isHidden = !(left == "upload" || right == "upload")
884
+
885
+ let paddingH = CGFloat(config.headerStyle.paddingHorizontal)
886
+ let gap = CGFloat(max(config.buttonContainerStyle.gap, 8))
887
+ let h = CGFloat(min(max(config.buttonContainerStyle.buttonHeight, 36), 44))
888
+
889
+ var leftButton: UIButton?
890
+ var rightButton: UIButton?
891
+
892
+ if left == "cancel" {
893
+ leftButton = cancelButton
894
+ constraints += [
895
+ cancelButton.leadingAnchor.constraint(equalTo: headerContainer.leadingAnchor, constant: paddingH),
896
+ cancelButton.centerYAnchor.constraint(equalTo: headerContainer.centerYAnchor),
897
+ cancelButton.heightAnchor.constraint(equalToConstant: h),
898
+ ]
899
+ } else if left == "upload" {
900
+ leftButton = uploadButton
901
+ constraints += [
902
+ uploadButton.leadingAnchor.constraint(equalTo: headerContainer.leadingAnchor, constant: paddingH),
903
+ uploadButton.centerYAnchor.constraint(equalTo: headerContainer.centerYAnchor),
904
+ uploadButton.heightAnchor.constraint(equalToConstant: h),
905
+ ]
906
+ }
907
+
908
+ if right == "cancel" {
909
+ rightButton = cancelButton
910
+ constraints += [
911
+ cancelButton.trailingAnchor.constraint(equalTo: headerContainer.trailingAnchor, constant: -paddingH),
912
+ cancelButton.centerYAnchor.constraint(equalTo: headerContainer.centerYAnchor),
913
+ cancelButton.heightAnchor.constraint(equalToConstant: h),
914
+ ]
915
+ } else if right == "upload" {
916
+ rightButton = uploadButton
917
+ constraints += [
918
+ uploadButton.trailingAnchor.constraint(equalTo: headerContainer.trailingAnchor, constant: -paddingH),
919
+ uploadButton.centerYAnchor.constraint(equalTo: headerContainer.centerYAnchor),
920
+ uploadButton.heightAnchor.constraint(equalToConstant: h),
921
+ ]
922
+ }
923
+
924
+ // Keep title centered and avoid overlapping the side controls.
925
+ constraints += [
926
+ headerTitleLabel.centerXAnchor.constraint(equalTo: headerContainer.centerXAnchor),
927
+ ]
928
+ if let lb = leftButton {
929
+ constraints.append(headerTitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: lb.trailingAnchor, constant: gap))
930
+ } else {
931
+ constraints.append(headerTitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: headerContainer.leadingAnchor, constant: paddingH))
932
+ }
933
+ if let rb = rightButton {
934
+ constraints.append(headerTitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: rb.leadingAnchor, constant: -gap))
935
+ } else {
936
+ constraints.append(headerTitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: headerContainer.trailingAnchor, constant: -paddingH))
937
+ }
938
+ } else {
939
+ constraints += [
940
+ headerTitleLabel.leadingAnchor.constraint(equalTo: headerContainer.leadingAnchor, constant: CGFloat(config.headerStyle.paddingHorizontal)),
941
+ headerTitleLabel.trailingAnchor.constraint(equalTo: headerContainer.trailingAnchor, constant: -CGFloat(config.headerStyle.paddingHorizontal)),
942
+ ]
943
+
944
+ let paddingH = CGFloat(config.buttonContainerStyle.paddingHorizontal)
945
+ let paddingTop = CGFloat(config.buttonContainerStyle.paddingTop)
946
+ let gap = CGFloat(config.buttonContainerStyle.gap)
947
+ let buttonHeight = CGFloat(config.buttonContainerStyle.buttonHeight)
948
+
949
+ let firstIsCancel = config.footerButtonOrder == "cancelFirst"
950
+ let firstButton = firstIsCancel ? cancelButton : uploadButton
951
+ let secondButton = firstIsCancel ? uploadButton : cancelButton
952
+
953
+ if config.buttonContainerStyle.layout == "horizontal" {
954
+ constraints += [
955
+ firstButton.leadingAnchor.constraint(equalTo: footerContainer.leadingAnchor, constant: paddingH),
956
+ firstButton.topAnchor.constraint(equalTo: footerContainer.topAnchor, constant: paddingTop),
957
+ firstButton.heightAnchor.constraint(equalToConstant: buttonHeight),
958
+
959
+ secondButton.leadingAnchor.constraint(equalTo: firstButton.trailingAnchor, constant: gap),
960
+ secondButton.trailingAnchor.constraint(equalTo: footerContainer.trailingAnchor, constant: -paddingH),
961
+ secondButton.topAnchor.constraint(equalTo: firstButton.topAnchor),
962
+ secondButton.heightAnchor.constraint(equalTo: firstButton.heightAnchor),
963
+
964
+ secondButton.widthAnchor.constraint(equalTo: firstButton.widthAnchor),
965
+ ]
966
+ } else {
967
+ constraints += [
968
+ firstButton.leadingAnchor.constraint(equalTo: footerContainer.leadingAnchor, constant: paddingH),
969
+ firstButton.trailingAnchor.constraint(equalTo: footerContainer.trailingAnchor, constant: -paddingH),
970
+ firstButton.topAnchor.constraint(equalTo: footerContainer.topAnchor, constant: paddingTop),
971
+ firstButton.heightAnchor.constraint(equalToConstant: buttonHeight),
972
+
973
+ secondButton.leadingAnchor.constraint(equalTo: firstButton.leadingAnchor),
974
+ secondButton.trailingAnchor.constraint(equalTo: firstButton.trailingAnchor),
975
+ secondButton.topAnchor.constraint(equalTo: firstButton.bottomAnchor, constant: gap),
976
+ secondButton.heightAnchor.constraint(equalTo: firstButton.heightAnchor),
977
+ ]
978
+ }
979
+ }
980
+
981
+ NSLayoutConstraint.activate(constraints)
982
+ }
983
+
984
+ private func createActionButton(title: String, style: ButtonStyleConfig) -> UIButton {
985
+ let button = UIButton(type: .system)
986
+ button.translatesAutoresizingMaskIntoConstraints = false
987
+ let resolvedTitleColor = parseColor(style.textColor, defaultLabelColor())
988
+ applyButtonContent(button: button, title: title, style: style, fallbackTitleColor: resolvedTitleColor)
989
+ button.setTitleColor(resolvedTitleColor, for: .normal)
990
+ button.titleLabel?.font = resolvedFont(
991
+ family: style.fontFamily,
992
+ size: CGFloat(style.fontSize),
993
+ fallback: UIFont.systemFont(ofSize: CGFloat(style.fontSize), weight: .semibold)
994
+ )
995
+ button.titleLabel?.numberOfLines = 1
996
+ button.titleLabel?.lineBreakMode = .byTruncatingTail
997
+ button.titleLabel?.adjustsFontSizeToFitWidth = true
998
+ button.titleLabel?.minimumScaleFactor = 0.85
999
+ button.titleLabel?.allowsDefaultTighteningForTruncation = true
1000
+ if #available(iOS 14.0, *) {
1001
+ button.titleLabel?.lineBreakStrategy = []
1002
+ }
1003
+ button.setContentCompressionResistancePriority(.required, for: .horizontal)
1004
+ button.setContentHuggingPriority(.defaultHigh, for: .horizontal)
1005
+ if #available(iOS 15.0, *) {
1006
+ // Style is applied via `UIButton.Configuration`.
1007
+ button.backgroundColor = .clear
1008
+ button.layer.borderWidth = 0
1009
+ button.layer.borderColor = UIColor.clear.cgColor
1010
+ } else {
1011
+ button.backgroundColor = parseColor(style.backgroundColor, .clear)
1012
+ button.layer.borderColor = parseColor(style.borderColor, .clear).cgColor
1013
+ button.layer.borderWidth = CGFloat(style.borderWidth)
1014
+ }
1015
+ button.clipsToBounds = true
1016
+ applyButtonRounding(button: button, style: style)
1017
+ return button
1018
+ }
1019
+
1020
+ private func applyButtonRounding(button: UIButton, style: ButtonStyleConfig) {
1021
+ button.clipsToBounds = true
1022
+ button.layer.masksToBounds = true
1023
+ button.layer.allowsEdgeAntialiasing = true
1024
+ if #available(iOS 13.0, *) {
1025
+ button.layer.cornerCurve = .continuous
1026
+ }
1027
+
1028
+ // If user provides a radius, respect it (but never exceed half height).
1029
+ // Otherwise default to a perfect "pill" radius.
1030
+ let half = max(button.bounds.height / 2.0, 0)
1031
+ let preferred = CGFloat(max(style.borderRadius, 0))
1032
+ let radius = preferred > 0 ? min(preferred, half) : half
1033
+ if button.layer.cornerRadius != radius {
1034
+ button.layer.cornerRadius = radius
1035
+ }
1036
+ if #available(iOS 15.0, *), var cfg = button.configuration {
1037
+ var bg = cfg.background
1038
+ bg.cornerRadius = radius
1039
+ cfg.background = bg
1040
+ button.configuration = cfg
1041
+ }
1042
+ }
1043
+
1044
+ private func applyButtonContent(
1045
+ button: UIButton,
1046
+ title: String,
1047
+ style: ButtonStyleConfig,
1048
+ fallbackTitleColor: UIColor
1049
+ ) {
1050
+ let raw = style.content.trimmingCharacters(in: .whitespacesAndNewlines)
1051
+ let content: String
1052
+ switch raw {
1053
+ case "iconText", "icon+text":
1054
+ content = "iconText"
1055
+ case "textIcon", "TextIcon", "text+icon":
1056
+ content = "textIcon"
1057
+ case "icon", "text":
1058
+ content = raw
1059
+ default:
1060
+ content = "text"
1061
+ }
1062
+
1063
+ let wantsText = content != "icon"
1064
+ let wantsIcon = content != "text"
1065
+
1066
+ let icon = wantsIcon ? resolvedIcon(style: style) : nil
1067
+ let iconReady = icon != nil
1068
+
1069
+ if #available(iOS 15.0, *) {
1070
+ applyButtonConfiguration(
1071
+ button: button,
1072
+ title: title,
1073
+ content: content,
1074
+ style: style,
1075
+ icon: icon,
1076
+ fallbackTitleColor: fallbackTitleColor
1077
+ )
1078
+ // If icon is not ready, try remote loader (it will update config when done).
1079
+ if wantsIcon, !iconReady {
1080
+ loadRemoteIconIfNeeded(
1081
+ button: button,
1082
+ title: title,
1083
+ content: content,
1084
+ style: style,
1085
+ fallbackTitleColor: fallbackTitleColor
1086
+ )
1087
+ }
1088
+ return
1089
+ }
1090
+
1091
+ // Fallback for iOS < 15 (best-effort).
1092
+ button.titleEdgeInsets = .zero
1093
+ button.imageEdgeInsets = .zero
1094
+ button.contentHorizontalAlignment = .center
1095
+ button.semanticContentAttribute = content == "textIcon" ? .forceRightToLeft : .forceLeftToRight
1096
+
1097
+ if wantsText || !iconReady {
1098
+ button.setTitle(title, for: .normal)
1099
+ } else {
1100
+ button.setTitle(nil, for: .normal)
1101
+ }
1102
+
1103
+ let padH = CGFloat(max(style.paddingHorizontal, 0))
1104
+ let padV = CGFloat(max(style.paddingVertical, 0))
1105
+ button.contentEdgeInsets = UIEdgeInsets(top: padV, left: padH, bottom: padV, right: padH)
1106
+
1107
+ let hasTint = !style.iconTintColor.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1108
+ if iconReady, var img = icon {
1109
+ if hasTint {
1110
+ let tintColor = parseColor(style.iconTintColor, fallbackTitleColor)
1111
+ img = img.withRenderingMode(.alwaysTemplate)
1112
+ button.tintColor = tintColor
1113
+ } else {
1114
+ img = img.withRenderingMode(.alwaysOriginal)
1115
+ }
1116
+ let size = CGFloat(max(style.iconSize, 12))
1117
+ button.setImage(resizedImageToFit(img, size: size), for: .normal)
1118
+ button.imageView?.contentMode = .scaleAspectFit
1119
+ let gap = CGFloat(max(style.iconGap, 0))
1120
+ if gap > 0 {
1121
+ button.titleEdgeInsets = UIEdgeInsets(top: 0, left: gap / 2, bottom: 0, right: -(gap / 2))
1122
+ button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -(gap / 2), bottom: 0, right: gap / 2)
1123
+ }
1124
+ } else if wantsIcon {
1125
+ button.setImage(nil, for: .normal)
1126
+ loadRemoteIconIfNeeded(
1127
+ button: button,
1128
+ title: title,
1129
+ content: content,
1130
+ style: style,
1131
+ fallbackTitleColor: fallbackTitleColor
1132
+ )
1133
+ } else {
1134
+ button.setImage(nil, for: .normal)
1135
+ }
1136
+ }
1137
+
1138
+ @available(iOS 15.0, *)
1139
+ private func applyButtonConfiguration(
1140
+ button: UIButton,
1141
+ title: String,
1142
+ content: String,
1143
+ style: ButtonStyleConfig,
1144
+ icon: UIImage?,
1145
+ fallbackTitleColor: UIColor
1146
+ ) {
1147
+ var config = button.configuration ?? UIButton.Configuration.plain()
1148
+ config.titleAlignment = .center
1149
+ config.titleLineBreakMode = .byTruncatingTail
1150
+
1151
+ let padH = CGFloat(max(style.paddingHorizontal, 0))
1152
+ let padV = CGFloat(max(style.paddingVertical, 0))
1153
+ config.contentInsets = NSDirectionalEdgeInsets(top: padV, leading: padH, bottom: padV, trailing: padH)
1154
+
1155
+ let wantsText = content != "icon"
1156
+ let wantsIcon = content != "text"
1157
+
1158
+ config.title = wantsText ? title : nil
1159
+ config.baseForegroundColor = fallbackTitleColor
1160
+
1161
+ let gap = CGFloat(max(style.iconGap, 0))
1162
+ config.imagePadding = gap
1163
+ config.imagePlacement = (content == "textIcon") ? .trailing : .leading
1164
+
1165
+ // Keep background/border consistent when using UIButton.Configuration.
1166
+ var bg = config.background
1167
+ bg.backgroundColor = parseColor(style.backgroundColor, .clear)
1168
+ bg.strokeColor = parseColor(style.borderColor, .clear)
1169
+ bg.strokeWidth = CGFloat(max(style.borderWidth, 0))
1170
+ // Use a generous initial corner radius; `applyButtonRounding` will clamp to a pill after layout.
1171
+ bg.cornerRadius = CGFloat(max(style.borderRadius, 999))
1172
+ config.background = bg
1173
+
1174
+ if wantsIcon, let icon {
1175
+ let hasTint = !style.iconTintColor.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1176
+ let size = CGFloat(max(style.iconSize, 12))
1177
+ let prepared = resizedImageToFit(icon, size: size)
1178
+ if hasTint {
1179
+ config.image = prepared.withRenderingMode(.alwaysTemplate)
1180
+ button.tintColor = parseColor(style.iconTintColor, fallbackTitleColor)
1181
+ } else {
1182
+ config.image = prepared.withRenderingMode(.alwaysOriginal)
1183
+ }
1184
+ } else {
1185
+ config.image = nil
1186
+ }
1187
+
1188
+ button.configuration = config
1189
+
1190
+ // Ensure title never wraps when using configuration titles.
1191
+ button.titleLabel?.numberOfLines = 1
1192
+ button.titleLabel?.lineBreakMode = .byTruncatingTail
1193
+ button.titleLabel?.adjustsFontSizeToFitWidth = true
1194
+ button.titleLabel?.minimumScaleFactor = 0.85
1195
+ if #available(iOS 14.0, *) {
1196
+ button.titleLabel?.lineBreakStrategy = []
1197
+ }
1198
+ }
1199
+
1200
+ private func resizedImageToFit(_ image: UIImage, size: CGFloat) -> UIImage {
1201
+ guard size > 0 else { return image }
1202
+ let target = CGSize(width: size, height: size)
1203
+ let renderer = UIGraphicsImageRenderer(size: target)
1204
+ return renderer.image { _ in
1205
+ // Aspect-fit into a square.
1206
+ let iw = image.size.width
1207
+ let ih = image.size.height
1208
+ if iw <= 0 || ih <= 0 {
1209
+ image.draw(in: CGRect(origin: .zero, size: target))
1210
+ return
1211
+ }
1212
+ let scale = min(target.width / iw, target.height / ih)
1213
+ let w = iw * scale
1214
+ let h = ih * scale
1215
+ let x = (target.width - w) / 2.0
1216
+ let y = (target.height - h) / 2.0
1217
+ image.draw(in: CGRect(x: x, y: y, width: w, height: h))
1218
+ }
1219
+ }
1220
+
1221
+ private func loadRemoteIconIfNeeded(
1222
+ button: UIButton,
1223
+ title: String,
1224
+ content: String,
1225
+ style: ButtonStyleConfig,
1226
+ fallbackTitleColor: UIColor
1227
+ ) {
1228
+ let uri = style.iconUri.trimmingCharacters(in: .whitespacesAndNewlines)
1229
+ guard uri.hasPrefix("http://") || uri.hasPrefix("https://") else { return }
1230
+ guard let url = URL(string: uri) else { return }
1231
+
1232
+ let cacheKey = "\(uri)|\(max(style.iconSize, 12))" as NSString
1233
+ if let cached = Self.iconCache.object(forKey: cacheKey) {
1234
+ if #available(iOS 15.0, *) {
1235
+ applyButtonConfiguration(
1236
+ button: button,
1237
+ title: title,
1238
+ content: content,
1239
+ style: style,
1240
+ icon: cached,
1241
+ fallbackTitleColor: fallbackTitleColor
1242
+ )
1243
+ } else {
1244
+ button.setImage(cached, for: .normal)
1245
+ }
1246
+ return
1247
+ }
1248
+
1249
+ URLSession.shared.dataTask(with: url) { [weak button] data, _, _ in
1250
+ guard let button, let data, let img = UIImage(data: data) else { return }
1251
+ DispatchQueue.main.async {
1252
+ if #available(iOS 15.0, *) {
1253
+ // Re-apply the full configuration so title/icon/layout stay consistent.
1254
+ self.applyButtonConfiguration(
1255
+ button: button,
1256
+ title: title,
1257
+ content: content,
1258
+ style: style,
1259
+ icon: img,
1260
+ fallbackTitleColor: fallbackTitleColor
1261
+ )
1262
+ } else {
1263
+ let hasTint = !style.iconTintColor.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1264
+ let size = CGFloat(max(style.iconSize, 12))
1265
+ let prepared = self.resizedImageToFit(img, size: size)
1266
+ if hasTint {
1267
+ let tintColor = self.parseColor(style.iconTintColor, fallbackTitleColor)
1268
+ button.tintColor = tintColor
1269
+ button.setImage(prepared.withRenderingMode(.alwaysTemplate), for: .normal)
1270
+ } else {
1271
+ button.setImage(prepared.withRenderingMode(.alwaysOriginal), for: .normal)
1272
+ }
1273
+ button.imageView?.contentMode = .scaleAspectFit
1274
+ // If the button is icon-only, remove the temporary/fallback title after icon is ready.
1275
+ if content == "icon" {
1276
+ button.setTitle(nil, for: .normal)
1277
+ } else if button.title(for: .normal) == nil {
1278
+ button.setTitle(title, for: .normal)
1279
+ }
1280
+ }
1281
+
1282
+ // Cache the final rendered image (already aspect-fit sized for our target).
1283
+ let size = CGFloat(max(style.iconSize, 12))
1284
+ let cached = self.resizedImageToFit(img, size: size)
1285
+ Self.iconCache.setObject(cached, forKey: cacheKey)
1286
+ }
1287
+ }.resume()
1288
+ }
1289
+
1290
+ private func resolvedIcon(style: ButtonStyleConfig) -> UIImage? {
1291
+ let base64 = style.iconBase64.trimmingCharacters(in: .whitespacesAndNewlines)
1292
+ if !base64.isEmpty {
1293
+ let cleaned = base64.components(separatedBy: "base64,").last ?? base64
1294
+ if let data = Data(base64Encoded: cleaned), let img = UIImage(data: data) {
1295
+ return img
1296
+ }
1297
+ }
1298
+ let uri = style.iconUri.trimmingCharacters(in: .whitespacesAndNewlines)
1299
+ if uri.isEmpty { return nil }
1300
+ if uri.hasPrefix("file://"), let url = URL(string: uri) {
1301
+ return UIImage(contentsOfFile: url.path)
1302
+ }
1303
+ if uri.hasPrefix("/") {
1304
+ return UIImage(contentsOfFile: uri)
1305
+ }
1306
+ return nil
1307
+ }
1308
+
1309
+ private func resolvedFont(family: String, size: CGFloat, fallback: UIFont) -> UIFont {
1310
+ let cleaned = family.trimmingCharacters(in: .whitespacesAndNewlines)
1311
+ guard !cleaned.isEmpty else { return fallback }
1312
+ if let font = UIFont(name: cleaned, size: size) {
1313
+ return font
1314
+ }
1315
+ let candidates = [
1316
+ cleaned,
1317
+ cleaned.replacingOccurrences(of: "_", with: "-"),
1318
+ cleaned.replacingOccurrences(of: "_", with: "")
1319
+ ]
1320
+ for name in candidates {
1321
+ if let font = UIFont(name: name, size: size) {
1322
+ return font
1323
+ }
1324
+ }
1325
+ return fallback
1326
+ }
1327
+
1328
+ @objc private func onCancelTapped() {
1329
+ if let delegate {
1330
+ delegate.cropViewController?(self, didFinishCancelled: true)
1331
+ return
1332
+ }
1333
+ dismiss(animated: true)
1334
+ }
1335
+
1336
+ @objc private func onUploadTapped() {
1337
+ if isCommittingCrop { return }
1338
+ isCommittingCrop = true
1339
+ uploadButton.isEnabled = false
1340
+ commitCurrentCrop()
1341
+ }
1342
+ }
1343
+
1344
+ // MARK: - Circular grid overlay (iOS circular crop)
1345
+
1346
+ private final class CircularGridOverlayView: UIView {
1347
+ private let h1 = UIView()
1348
+ private let h2 = UIView()
1349
+ private let v1 = UIView()
1350
+ private let v2 = UIView()
1351
+
1352
+ init(frame: CGRect, lineColor: UIColor) {
1353
+ super.init(frame: frame)
1354
+ [h1, h2, v1, v2].forEach {
1355
+ $0.backgroundColor = lineColor
1356
+ $0.isUserInteractionEnabled = false
1357
+ addSubview($0)
1358
+ }
1359
+ isUserInteractionEnabled = false
1360
+ backgroundColor = .clear
1361
+ }
1362
+
1363
+ @available(*, unavailable)
1364
+ required init?(coder: NSCoder) {
1365
+ fatalError("init(coder:) has not been implemented")
1366
+ }
1367
+
1368
+ override func layoutSubviews() {
1369
+ super.layoutSubviews()
1370
+ let scale = window?.screen.scale ?? UIScreen.main.scale
1371
+ let thickness = max(1.0 / scale, 0.5)
1372
+ let w = bounds.width
1373
+ let h = bounds.height
1374
+ let x1 = (w / 3.0)
1375
+ let x2 = (w * 2.0 / 3.0)
1376
+ let y1 = (h / 3.0)
1377
+ let y2 = (h * 2.0 / 3.0)
1378
+
1379
+ h1.frame = CGRect(x: 0, y: y1, width: w, height: thickness)
1380
+ h2.frame = CGRect(x: 0, y: y2, width: w, height: thickness)
1381
+ v1.frame = CGRect(x: x1, y: 0, width: thickness, height: h)
1382
+ v2.frame = CGRect(x: x2, y: 0, width: thickness, height: h)
1383
+ }
1384
+ }
1385
+
1386
+ private extension UIColor {
1387
+ convenience init?(hex: String) {
1388
+ var s = hex.trimmingCharacters(in: .whitespacesAndNewlines)
1389
+ guard s.hasPrefix("#") else { return nil }
1390
+ s.removeFirst()
1391
+ if s.count == 6 { s = "FF" + s }
1392
+ guard s.count == 8, let v = UInt64(s, radix: 16) else { return nil }
1393
+ let a = CGFloat((v >> 24) & 0xFF) / 255.0
1394
+ let r = CGFloat((v >> 16) & 0xFF) / 255.0
1395
+ let g = CGFloat((v >> 8) & 0xFF) / 255.0
1396
+ let b = CGFloat(v & 0xFF) / 255.0
1397
+ self.init(red: r, green: g, blue: b, alpha: a)
1398
+ }
1399
+ }
1400
+