├── .gitignore ├── Assets └── preview.gif ├── Capture.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Capture ├── App │ └── CaptureApp.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json └── Resources │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ └── capture.png │ ├── Contents.json │ └── capture.imageset │ │ ├── Contents.json │ │ ├── capture.png │ │ ├── capture@2x.png │ │ └── capture@3x.png │ └── CaptureLaunchScreen.storyboard ├── README.md └── Sources ├── App ├── .gitignore ├── Package.swift ├── README.md ├── Sources │ └── App │ │ ├── AppEnvironment+Dependency.swift │ │ ├── Application.swift │ │ └── Route+Factory.swift └── Tests │ └── AppTests │ └── AppTests.swift ├── Core ├── .gitignore ├── Package.swift ├── README.md ├── Sources │ └── Core │ │ ├── Environment │ │ └── AppEnvironment.swift │ │ └── Services │ │ ├── CameraService.swift │ │ └── PhotoLibraryService.swift └── Tests │ └── CoreTests │ └── CoreTests.swift ├── Feature ├── .gitignore ├── Package.swift ├── README.md ├── Sources │ ├── Camera │ │ ├── Components │ │ │ ├── AlertActions.swift │ │ │ ├── CameraPreviewLayer.swift │ │ │ └── FocusFrame.swift │ │ ├── Dependency │ │ │ └── CameraDependency.swift │ │ └── Scene │ │ │ ├── CameraView.swift │ │ │ └── CameraViewModel.swift │ ├── Gallery │ │ ├── Components │ │ │ └── PhotoThumbnail.swift │ │ ├── Dependency │ │ │ └── GalleryDependency.swift │ │ └── Scene │ │ │ ├── GalleryView.swift │ │ │ └── GalleryViewModel.swift │ └── Photo │ │ ├── Dependency │ │ └── PhotoDependency.swift │ │ └── Scene │ │ ├── PhotoView.swift │ │ └── PhotoViewModel.swift └── Tests │ └── FeatureTests │ └── FeatureTests.swift ├── Routing ├── .gitignore ├── Package.swift ├── README.md ├── Sources │ └── Routing │ │ ├── Coordinator.swift │ │ ├── Factory.swift │ │ ├── RootSwitcher.swift │ │ ├── RootView.swift │ │ ├── Route.swift │ │ └── View+Coordinator.swift └── Tests │ └── RoutingTests │ └── RoutingTests.swift └── Utility ├── .gitignore ├── Package.swift ├── README.md ├── Sources └── Utility │ ├── Errors │ ├── CameraError.swift │ └── PhotoLibraryError.swift │ ├── Extensions │ ├── DeviceType++.swift │ └── View++.swift │ └── Helpers │ ├── CameraMode.swift │ ├── CaptureData.swift │ ├── CaptureEvent.swift │ └── CapturePhoto.swift └── Tests └── UtilityTests └── UtilityTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | Packages/ 41 | Package.pins 42 | Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ -------------------------------------------------------------------------------- /Assets/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dscyrescotti/Capture/f1c473d30b588e281f03c369157728736d478821/Assets/preview.gif -------------------------------------------------------------------------------- /Capture.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | EC0F51FB29A5DA9600BC5E5E /* App in Frameworks */ = {isa = PBXBuildFile; productRef = EC0F51FA29A5DA9600BC5E5E /* App */; }; 11 | ECC07BEB299DE1BF0043E5B1 /* CaptureApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC07BEA299DE1BF0043E5B1 /* CaptureApp.swift */; }; 12 | ECC07BEF299DE1C10043E5B1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ECC07BEE299DE1C10043E5B1 /* Assets.xcassets */; }; 13 | ECC07BF2299DE1C10043E5B1 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ECC07BF1299DE1C10043E5B1 /* Preview Assets.xcassets */; }; 14 | ECFFB2F129A731CB00EB2852 /* CaptureLaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = ECFFB2F029A731CB00EB2852 /* CaptureLaunchScreen.storyboard */; }; 15 | /* End PBXBuildFile section */ 16 | 17 | /* Begin PBXFileReference section */ 18 | EC0F51FC29A5DC0600BC5E5E /* Utility */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Utility; path = Sources/Utility; sourceTree = ""; }; 19 | EC0F51FD29A5DC0F00BC5E5E /* Routing */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Routing; path = Sources/Routing; sourceTree = ""; }; 20 | EC0F51FE29A5DC1A00BC5E5E /* Core */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Core; path = Sources/Core; sourceTree = ""; }; 21 | EC0F51FF29A5DC2500BC5E5E /* Feature */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Feature; path = Sources/Feature; sourceTree = ""; }; 22 | EC0F520029A5DC3000BC5E5E /* App */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = App; path = Sources/App; sourceTree = ""; }; 23 | ECC07BE7299DE1BF0043E5B1 /* Capture.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Capture.app; sourceTree = BUILT_PRODUCTS_DIR; }; 24 | ECC07BEA299DE1BF0043E5B1 /* CaptureApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureApp.swift; sourceTree = ""; }; 25 | ECC07BEE299DE1C10043E5B1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 26 | ECC07BF1299DE1C10043E5B1 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 27 | ECFFB2F029A731CB00EB2852 /* CaptureLaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = CaptureLaunchScreen.storyboard; sourceTree = ""; }; 28 | /* End PBXFileReference section */ 29 | 30 | /* Begin PBXFrameworksBuildPhase section */ 31 | ECC07BE4299DE1BF0043E5B1 /* Frameworks */ = { 32 | isa = PBXFrameworksBuildPhase; 33 | buildActionMask = 2147483647; 34 | files = ( 35 | EC0F51FB29A5DA9600BC5E5E /* App in Frameworks */, 36 | ); 37 | runOnlyForDeploymentPostprocessing = 0; 38 | }; 39 | /* End PBXFrameworksBuildPhase section */ 40 | 41 | /* Begin PBXGroup section */ 42 | EC0F51F029A5D37500BC5E5E /* Frameworks */ = { 43 | isa = PBXGroup; 44 | children = ( 45 | ); 46 | name = Frameworks; 47 | sourceTree = ""; 48 | }; 49 | EC0F51F429A5DA1A00BC5E5E /* Packages */ = { 50 | isa = PBXGroup; 51 | children = ( 52 | EC0F520029A5DC3000BC5E5E /* App */, 53 | EC0F51FE29A5DC1A00BC5E5E /* Core */, 54 | EC0F51FF29A5DC2500BC5E5E /* Feature */, 55 | EC0F51FD29A5DC0F00BC5E5E /* Routing */, 56 | EC0F51FC29A5DC0600BC5E5E /* Utility */, 57 | ); 58 | name = Packages; 59 | sourceTree = ""; 60 | }; 61 | ECC07BDE299DE1BF0043E5B1 = { 62 | isa = PBXGroup; 63 | children = ( 64 | EC0F51F429A5DA1A00BC5E5E /* Packages */, 65 | ECC07BE9299DE1BF0043E5B1 /* Capture */, 66 | ECC07BE8299DE1BF0043E5B1 /* Products */, 67 | EC0F51F029A5D37500BC5E5E /* Frameworks */, 68 | ); 69 | sourceTree = ""; 70 | }; 71 | ECC07BE8299DE1BF0043E5B1 /* Products */ = { 72 | isa = PBXGroup; 73 | children = ( 74 | ECC07BE7299DE1BF0043E5B1 /* Capture.app */, 75 | ); 76 | name = Products; 77 | sourceTree = ""; 78 | }; 79 | ECC07BE9299DE1BF0043E5B1 /* Capture */ = { 80 | isa = PBXGroup; 81 | children = ( 82 | ECC07BF8299DE89F0043E5B1 /* App */, 83 | ECC07BF0299DE1C10043E5B1 /* Preview Content */, 84 | ECC07BFB299DE8E50043E5B1 /* Resources */, 85 | ); 86 | path = Capture; 87 | sourceTree = ""; 88 | }; 89 | ECC07BF0299DE1C10043E5B1 /* Preview Content */ = { 90 | isa = PBXGroup; 91 | children = ( 92 | ECC07BF1299DE1C10043E5B1 /* Preview Assets.xcassets */, 93 | ); 94 | path = "Preview Content"; 95 | sourceTree = ""; 96 | }; 97 | ECC07BF8299DE89F0043E5B1 /* App */ = { 98 | isa = PBXGroup; 99 | children = ( 100 | ECC07BEA299DE1BF0043E5B1 /* CaptureApp.swift */, 101 | ); 102 | path = App; 103 | sourceTree = ""; 104 | }; 105 | ECC07BFB299DE8E50043E5B1 /* Resources */ = { 106 | isa = PBXGroup; 107 | children = ( 108 | ECC07BEE299DE1C10043E5B1 /* Assets.xcassets */, 109 | ECFFB2F029A731CB00EB2852 /* CaptureLaunchScreen.storyboard */, 110 | ); 111 | path = Resources; 112 | sourceTree = ""; 113 | }; 114 | /* End PBXGroup section */ 115 | 116 | /* Begin PBXNativeTarget section */ 117 | ECC07BE6299DE1BF0043E5B1 /* Capture */ = { 118 | isa = PBXNativeTarget; 119 | buildConfigurationList = ECC07BF5299DE1C10043E5B1 /* Build configuration list for PBXNativeTarget "Capture" */; 120 | buildPhases = ( 121 | ECC07BE3299DE1BF0043E5B1 /* Sources */, 122 | ECC07BE4299DE1BF0043E5B1 /* Frameworks */, 123 | ECC07BE5299DE1BF0043E5B1 /* Resources */, 124 | ); 125 | buildRules = ( 126 | ); 127 | dependencies = ( 128 | ); 129 | name = Capture; 130 | packageProductDependencies = ( 131 | EC0F51FA29A5DA9600BC5E5E /* App */, 132 | ); 133 | productName = Capture; 134 | productReference = ECC07BE7299DE1BF0043E5B1 /* Capture.app */; 135 | productType = "com.apple.product-type.application"; 136 | }; 137 | /* End PBXNativeTarget section */ 138 | 139 | /* Begin PBXProject section */ 140 | ECC07BDF299DE1BF0043E5B1 /* Project object */ = { 141 | isa = PBXProject; 142 | attributes = { 143 | BuildIndependentTargetsInParallel = 1; 144 | LastSwiftUpdateCheck = 1420; 145 | LastUpgradeCheck = 1420; 146 | TargetAttributes = { 147 | ECC07BE6299DE1BF0043E5B1 = { 148 | CreatedOnToolsVersion = 14.2; 149 | }; 150 | }; 151 | }; 152 | buildConfigurationList = ECC07BE2299DE1BF0043E5B1 /* Build configuration list for PBXProject "Capture" */; 153 | compatibilityVersion = "Xcode 14.0"; 154 | developmentRegion = en; 155 | hasScannedForEncodings = 0; 156 | knownRegions = ( 157 | en, 158 | Base, 159 | ); 160 | mainGroup = ECC07BDE299DE1BF0043E5B1; 161 | packageReferences = ( 162 | ); 163 | productRefGroup = ECC07BE8299DE1BF0043E5B1 /* Products */; 164 | projectDirPath = ""; 165 | projectRoot = ""; 166 | targets = ( 167 | ECC07BE6299DE1BF0043E5B1 /* Capture */, 168 | ); 169 | }; 170 | /* End PBXProject section */ 171 | 172 | /* Begin PBXResourcesBuildPhase section */ 173 | ECC07BE5299DE1BF0043E5B1 /* Resources */ = { 174 | isa = PBXResourcesBuildPhase; 175 | buildActionMask = 2147483647; 176 | files = ( 177 | ECC07BF2299DE1C10043E5B1 /* Preview Assets.xcassets in Resources */, 178 | ECC07BEF299DE1C10043E5B1 /* Assets.xcassets in Resources */, 179 | ECFFB2F129A731CB00EB2852 /* CaptureLaunchScreen.storyboard in Resources */, 180 | ); 181 | runOnlyForDeploymentPostprocessing = 0; 182 | }; 183 | /* End PBXResourcesBuildPhase section */ 184 | 185 | /* Begin PBXSourcesBuildPhase section */ 186 | ECC07BE3299DE1BF0043E5B1 /* Sources */ = { 187 | isa = PBXSourcesBuildPhase; 188 | buildActionMask = 2147483647; 189 | files = ( 190 | ECC07BEB299DE1BF0043E5B1 /* CaptureApp.swift in Sources */, 191 | ); 192 | runOnlyForDeploymentPostprocessing = 0; 193 | }; 194 | /* End PBXSourcesBuildPhase section */ 195 | 196 | /* Begin XCBuildConfiguration section */ 197 | ECC07BF3299DE1C10043E5B1 /* Debug */ = { 198 | isa = XCBuildConfiguration; 199 | buildSettings = { 200 | ALWAYS_SEARCH_USER_PATHS = NO; 201 | CLANG_ANALYZER_NONNULL = YES; 202 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 203 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 204 | CLANG_ENABLE_MODULES = YES; 205 | CLANG_ENABLE_OBJC_ARC = YES; 206 | CLANG_ENABLE_OBJC_WEAK = YES; 207 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 208 | CLANG_WARN_BOOL_CONVERSION = YES; 209 | CLANG_WARN_COMMA = YES; 210 | CLANG_WARN_CONSTANT_CONVERSION = YES; 211 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 212 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 213 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 214 | CLANG_WARN_EMPTY_BODY = YES; 215 | CLANG_WARN_ENUM_CONVERSION = YES; 216 | CLANG_WARN_INFINITE_RECURSION = YES; 217 | CLANG_WARN_INT_CONVERSION = YES; 218 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 219 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 220 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 221 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 222 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 223 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 224 | CLANG_WARN_STRICT_PROTOTYPES = YES; 225 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 226 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 227 | CLANG_WARN_UNREACHABLE_CODE = YES; 228 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 229 | COPY_PHASE_STRIP = NO; 230 | DEBUG_INFORMATION_FORMAT = dwarf; 231 | ENABLE_STRICT_OBJC_MSGSEND = YES; 232 | ENABLE_TESTABILITY = YES; 233 | GCC_C_LANGUAGE_STANDARD = gnu11; 234 | GCC_DYNAMIC_NO_PIC = NO; 235 | GCC_NO_COMMON_BLOCKS = YES; 236 | GCC_OPTIMIZATION_LEVEL = 0; 237 | GCC_PREPROCESSOR_DEFINITIONS = ( 238 | "DEBUG=1", 239 | "$(inherited)", 240 | ); 241 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 242 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 243 | GCC_WARN_UNDECLARED_SELECTOR = YES; 244 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 245 | GCC_WARN_UNUSED_FUNCTION = YES; 246 | GCC_WARN_UNUSED_VARIABLE = YES; 247 | IPHONEOS_DEPLOYMENT_TARGET = 16.2; 248 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 249 | MTL_FAST_MATH = YES; 250 | ONLY_ACTIVE_ARCH = YES; 251 | SDKROOT = iphoneos; 252 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 253 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 254 | }; 255 | name = Debug; 256 | }; 257 | ECC07BF4299DE1C10043E5B1 /* Release */ = { 258 | isa = XCBuildConfiguration; 259 | buildSettings = { 260 | ALWAYS_SEARCH_USER_PATHS = NO; 261 | CLANG_ANALYZER_NONNULL = YES; 262 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 263 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 264 | CLANG_ENABLE_MODULES = YES; 265 | CLANG_ENABLE_OBJC_ARC = YES; 266 | CLANG_ENABLE_OBJC_WEAK = YES; 267 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 268 | CLANG_WARN_BOOL_CONVERSION = YES; 269 | CLANG_WARN_COMMA = YES; 270 | CLANG_WARN_CONSTANT_CONVERSION = YES; 271 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 272 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 273 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 274 | CLANG_WARN_EMPTY_BODY = YES; 275 | CLANG_WARN_ENUM_CONVERSION = YES; 276 | CLANG_WARN_INFINITE_RECURSION = YES; 277 | CLANG_WARN_INT_CONVERSION = YES; 278 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 279 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 280 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 281 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 282 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 283 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 284 | CLANG_WARN_STRICT_PROTOTYPES = YES; 285 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 286 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 287 | CLANG_WARN_UNREACHABLE_CODE = YES; 288 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 289 | COPY_PHASE_STRIP = NO; 290 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 291 | ENABLE_NS_ASSERTIONS = NO; 292 | ENABLE_STRICT_OBJC_MSGSEND = YES; 293 | GCC_C_LANGUAGE_STANDARD = gnu11; 294 | GCC_NO_COMMON_BLOCKS = YES; 295 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 296 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 297 | GCC_WARN_UNDECLARED_SELECTOR = YES; 298 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 299 | GCC_WARN_UNUSED_FUNCTION = YES; 300 | GCC_WARN_UNUSED_VARIABLE = YES; 301 | IPHONEOS_DEPLOYMENT_TARGET = 16.2; 302 | MTL_ENABLE_DEBUG_INFO = NO; 303 | MTL_FAST_MATH = YES; 304 | SDKROOT = iphoneos; 305 | SWIFT_COMPILATION_MODE = wholemodule; 306 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 307 | VALIDATE_PRODUCT = YES; 308 | }; 309 | name = Release; 310 | }; 311 | ECC07BF6299DE1C10043E5B1 /* Debug */ = { 312 | isa = XCBuildConfiguration; 313 | buildSettings = { 314 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 315 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 316 | CODE_SIGN_STYLE = Automatic; 317 | CURRENT_PROJECT_VERSION = 1; 318 | DEVELOPMENT_ASSET_PATHS = "\"Capture/Preview Content\""; 319 | DEVELOPMENT_TEAM = 9TYSSFKV5U; 320 | ENABLE_PREVIEWS = YES; 321 | GENERATE_INFOPLIST_FILE = YES; 322 | INFOPLIST_KEY_NSCameraUsageDescription = "Capture requires the access to the camera to capture the moment around you."; 323 | INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Capture requires the access to the photo library to store pictures you took."; 324 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 325 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 326 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 327 | INFOPLIST_KEY_UILaunchStoryboardName = CaptureLaunchScreen.storyboard; 328 | INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; 329 | LD_RUNPATH_SEARCH_PATHS = ( 330 | "$(inherited)", 331 | "@executable_path/Frameworks", 332 | ); 333 | MARKETING_VERSION = 1.0; 334 | PRODUCT_BUNDLE_IDENTIFIER = com.example.Capture; 335 | PRODUCT_NAME = "$(TARGET_NAME)"; 336 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 337 | SUPPORTS_MACCATALYST = NO; 338 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 339 | SWIFT_EMIT_LOC_STRINGS = YES; 340 | SWIFT_VERSION = 5.0; 341 | TARGETED_DEVICE_FAMILY = 1; 342 | }; 343 | name = Debug; 344 | }; 345 | ECC07BF7299DE1C10043E5B1 /* Release */ = { 346 | isa = XCBuildConfiguration; 347 | buildSettings = { 348 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 349 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 350 | CODE_SIGN_STYLE = Automatic; 351 | CURRENT_PROJECT_VERSION = 1; 352 | DEVELOPMENT_ASSET_PATHS = "\"Capture/Preview Content\""; 353 | DEVELOPMENT_TEAM = 9TYSSFKV5U; 354 | ENABLE_PREVIEWS = YES; 355 | GENERATE_INFOPLIST_FILE = YES; 356 | INFOPLIST_KEY_NSCameraUsageDescription = "Capture requires the access to the camera to capture the moment around you."; 357 | INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Capture requires the access to the photo library to store pictures you took."; 358 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 359 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 360 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 361 | INFOPLIST_KEY_UILaunchStoryboardName = CaptureLaunchScreen.storyboard; 362 | INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; 363 | LD_RUNPATH_SEARCH_PATHS = ( 364 | "$(inherited)", 365 | "@executable_path/Frameworks", 366 | ); 367 | MARKETING_VERSION = 1.0; 368 | PRODUCT_BUNDLE_IDENTIFIER = com.example.Capture; 369 | PRODUCT_NAME = "$(TARGET_NAME)"; 370 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 371 | SUPPORTS_MACCATALYST = NO; 372 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 373 | SWIFT_EMIT_LOC_STRINGS = YES; 374 | SWIFT_VERSION = 5.0; 375 | TARGETED_DEVICE_FAMILY = 1; 376 | }; 377 | name = Release; 378 | }; 379 | /* End XCBuildConfiguration section */ 380 | 381 | /* Begin XCConfigurationList section */ 382 | ECC07BE2299DE1BF0043E5B1 /* Build configuration list for PBXProject "Capture" */ = { 383 | isa = XCConfigurationList; 384 | buildConfigurations = ( 385 | ECC07BF3299DE1C10043E5B1 /* Debug */, 386 | ECC07BF4299DE1C10043E5B1 /* Release */, 387 | ); 388 | defaultConfigurationIsVisible = 0; 389 | defaultConfigurationName = Release; 390 | }; 391 | ECC07BF5299DE1C10043E5B1 /* Build configuration list for PBXNativeTarget "Capture" */ = { 392 | isa = XCConfigurationList; 393 | buildConfigurations = ( 394 | ECC07BF6299DE1C10043E5B1 /* Debug */, 395 | ECC07BF7299DE1C10043E5B1 /* Release */, 396 | ); 397 | defaultConfigurationIsVisible = 0; 398 | defaultConfigurationName = Release; 399 | }; 400 | /* End XCConfigurationList section */ 401 | 402 | /* Begin XCSwiftPackageProductDependency section */ 403 | EC0F51FA29A5DA9600BC5E5E /* App */ = { 404 | isa = XCSwiftPackageProductDependency; 405 | productName = App; 406 | }; 407 | /* End XCSwiftPackageProductDependency section */ 408 | }; 409 | rootObject = ECC07BDF299DE1BF0043E5B1 /* Project object */; 410 | } 411 | -------------------------------------------------------------------------------- /Capture.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Capture.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Capture/App/CaptureApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CaptureApp.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/16/23. 6 | // 7 | 8 | import App 9 | import SwiftUI 10 | 11 | @main 12 | struct CaptureApp: App { 13 | 14 | init() { 15 | Application.setInitialRoute(to: .camera) 16 | } 17 | 18 | var body: some Scene { 19 | WindowGroup { 20 | Application() 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Capture/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Capture/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Capture/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "capture.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Capture/Resources/Assets.xcassets/AppIcon.appiconset/capture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dscyrescotti/Capture/f1c473d30b588e281f03c369157728736d478821/Capture/Resources/Assets.xcassets/AppIcon.appiconset/capture.png -------------------------------------------------------------------------------- /Capture/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Capture/Resources/Assets.xcassets/capture.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "capture.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "capture@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "capture@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Capture/Resources/Assets.xcassets/capture.imageset/capture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dscyrescotti/Capture/f1c473d30b588e281f03c369157728736d478821/Capture/Resources/Assets.xcassets/capture.imageset/capture.png -------------------------------------------------------------------------------- /Capture/Resources/Assets.xcassets/capture.imageset/capture@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dscyrescotti/Capture/f1c473d30b588e281f03c369157728736d478821/Capture/Resources/Assets.xcassets/capture.imageset/capture@2x.png -------------------------------------------------------------------------------- /Capture/Resources/Assets.xcassets/capture.imageset/capture@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dscyrescotti/Capture/f1c473d30b588e281f03c369157728736d478821/Capture/Resources/Assets.xcassets/capture.imageset/capture@3x.png -------------------------------------------------------------------------------- /Capture/Resources/CaptureLaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📷 Capture 2 | 3 | Capture is a basic camera application built with the power of AVFoundation and SwiftUI to faciliate taking pictures. 4 | 5 | ## 📱 Preview 6 | Capture 7 | 8 | ## 🔨 Build & Run 9 | To get started, go to **Terminal** and run the following commands to clone and open the project. 10 | ``` 11 | > git clone https://github.com/dscyrescotti/Capture.git 12 | > cd Capture && xed . 13 | ``` 14 | After seeing the project open, run the project by either using **⌘R** (Command+R) or clicking **Run** button in the project toolbar. 15 | > Don't forget to run on the real device. 16 | 17 | ## 📦 Tech Stack 18 | - AVFoundation 19 | - SwiftUI 20 | - Photos 21 | - Swift Concurrency 22 | - Swift Package Manager 23 | 24 | ## ✍️ Author 25 | Scotti | [@dscyrescotti](https://twitter.com/dscyrescotti) 26 |

27 | 28 | 29 | 30 |   31 | 32 | 33 | 34 |

-------------------------------------------------------------------------------- /Sources/App/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Sources/App/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "App", 8 | platforms: [.iOS(.v16)], 9 | products: [ 10 | .library( 11 | name: "App", 12 | targets: ["App"] 13 | ), 14 | ], 15 | dependencies: [ 16 | .package(path: "../Core"), 17 | .package(path: "../Routing"), 18 | .package(path: "../Feature") 19 | ], 20 | targets: [ 21 | .target( 22 | name: "App", 23 | dependencies: [ 24 | .product(name: "Core", package: "Core"), 25 | .product(name: "Photo", package: "Feature"), 26 | .product(name: "Camera", package: "Feature"), 27 | .product(name: "Gallery", package: "Feature"), 28 | .product(name: "Routing", package: "Routing") 29 | ] 30 | ), 31 | .testTarget( 32 | name: "AppTests", 33 | dependencies: ["App"] 34 | ), 35 | ], 36 | swiftLanguageVersions: [.v5] 37 | ) 38 | -------------------------------------------------------------------------------- /Sources/App/README.md: -------------------------------------------------------------------------------- 1 | # App 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Sources/App/Sources/App/AppEnvironment+Dependency.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppEnvironment+Dependency.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/22/23. 6 | // 7 | 8 | import Core 9 | import Photo 10 | import Camera 11 | import Gallery 12 | import SwiftUI 13 | 14 | public extension AppEnvironment { 15 | var galleryDependency: GalleryDependency { GalleryDependency(photoLibrary: photoLibrary) } 16 | var cameraDependency: CameraDependency { CameraDependency(camera: camera, photoLibrary: photoLibrary) } 17 | 18 | func photoDependency(photo: UIImage?, assetId: String, fileName: String) -> PhotoDependency { 19 | PhotoDependency( 20 | photo: photo, 21 | assetId: assetId, 22 | fileName: fileName, 23 | photoLibrary: photoLibrary 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/App/Sources/App/Application.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Application.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/22/23. 6 | // 7 | 8 | import Routing 9 | import SwiftUI 10 | 11 | public struct Application: View { 12 | @Coordinator var coordinator 13 | 14 | public init() { } 15 | 16 | public var body: some View { 17 | RootView($coordinator) 18 | } 19 | 20 | public static func setInitialRoute(to route: Route) { 21 | RootSwitcher.setInitialRoute(to: route) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/App/Sources/App/Route+Factory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Route+Factory.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/22/23. 6 | // 7 | 8 | import Core 9 | import Photo 10 | import Camera 11 | import Gallery 12 | import Routing 13 | import SwiftUI 14 | 15 | extension Route: Factory { 16 | @ViewBuilder 17 | public func contentView() -> some View { 18 | switch self { 19 | case let .photo(photo, assetId, fileName): 20 | PhotoView(dependency: environment.photoDependency(photo: photo, assetId: assetId, fileName: fileName)) 21 | case .camera: 22 | CameraView(dependency: environment.cameraDependency) 23 | case .gallery: 24 | GalleryView(dependency: environment.galleryDependency) 25 | } 26 | } 27 | 28 | private var environment: AppEnvironment { .live } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/App/Tests/AppTests/AppTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import App 3 | 4 | final class AppTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(App().text, "Hello, World!") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/Core/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Sources/Core/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Core", 8 | platforms: [.iOS(.v16)], 9 | products: [ 10 | .library( 11 | name: "Core", 12 | targets: ["Core"] 13 | ), 14 | ], 15 | dependencies: [ 16 | .package(path: "../Utility"), 17 | .package(url: "https://github.com/apple/swift-async-algorithms.git", .upToNextMajor(from: "0.1.0")) 18 | ], 19 | targets: [ 20 | .target( 21 | name: "Core", 22 | dependencies: [ 23 | .product(name: "Utility", package: "Utility"), 24 | .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") 25 | ] 26 | ), 27 | .testTarget( 28 | name: "CoreTests", 29 | dependencies: ["Core"] 30 | ), 31 | ], 32 | swiftLanguageVersions: [.v5] 33 | ) 34 | -------------------------------------------------------------------------------- /Sources/Core/README.md: -------------------------------------------------------------------------------- 1 | # Core 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Sources/Core/Sources/Core/Environment/AppEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppEnvironment.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/21/23. 6 | // 7 | 8 | import Foundation 9 | 10 | public class AppEnvironment { 11 | public let camera: CameraService 12 | public let photoLibrary: PhotoLibraryService 13 | 14 | public init( 15 | camera: CameraService, 16 | photoLibrary: PhotoLibraryService 17 | ) { 18 | self.camera = camera 19 | self.photoLibrary = photoLibrary 20 | } 21 | } 22 | 23 | public extension AppEnvironment { 24 | static var live: AppEnvironment { 25 | return AppEnvironment( 26 | camera: CameraService(), 27 | photoLibrary: PhotoLibraryService() 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Core/Sources/Core/Services/CameraService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraService.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/16/23. 6 | // 7 | 8 | import SwiftUI 9 | import Utility 10 | import Foundation 11 | import AVFoundation 12 | import AsyncAlgorithms 13 | 14 | public class CameraService: NSObject { 15 | // MARK: - Session 16 | public let captureSession: AVCaptureSession 17 | var sessionQueue: DispatchQueue = DispatchQueue(label: "capture-session-queue", qos: .userInteractive, attributes: .concurrent) 18 | var isConfigured: Bool = false 19 | 20 | // MARK: - Devices 21 | lazy var captureDevices: [AVCaptureDevice] = { 22 | AVCaptureDevice.DiscoverySession( 23 | deviceTypes: [ 24 | .builtInTrueDepthCamera, 25 | .builtInDualCamera, 26 | .builtInDualWideCamera, 27 | .builtInTripleCamera, 28 | .builtInWideAngleCamera, 29 | .builtInUltraWideCamera, 30 | .builtInLiDARDepthCamera, 31 | .builtInTelephotoCamera 32 | ], 33 | mediaType: .video, 34 | position: .unspecified 35 | ).devices 36 | }() 37 | public lazy var frontCaptureDevices: [AVCaptureDevice] = { 38 | captureDevices.filter { $0.position == .front } 39 | }() 40 | public lazy var rearCaptureDevices: [AVCaptureDevice] = { 41 | captureDevices.filter { $0.position == .back } 42 | }() 43 | public var captureDevice: AVCaptureDevice? 44 | public var isAvailableFlashLight: Bool { captureDevice?.isFlashAvailable ?? false } 45 | 46 | // MARK: - Input 47 | var captureInput: AVCaptureInput? 48 | 49 | // MARK: - Output 50 | var captureOutput: AVCaptureOutput? 51 | public var isAvailableLivePhoto: Bool { 52 | guard let captureOutput = captureOutput as? AVCapturePhotoOutput, captureOutput.availablePhotoCodecTypes.contains(.hevc) else { 53 | return false 54 | } 55 | return captureOutput.isLivePhotoCaptureSupported 56 | } 57 | 58 | // MARK: - Preview 59 | public let cameraPreviewLayer: AVCaptureVideoPreviewLayer 60 | 61 | // MARK: - Capture 62 | lazy var captureQueue: DispatchQueue = DispatchQueue(label: "photo-capture-queue", qos: .userInteractive) 63 | public lazy var captureChannel = AsyncChannel() 64 | 65 | public override init() { 66 | self.captureSession = AVCaptureSession() 67 | self.cameraPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession) 68 | super.init() 69 | } 70 | 71 | public func isFocusModeSupported(_ focusMode: AVCaptureDevice.FocusMode) -> Bool { 72 | guard let captureDevice else { return false } 73 | return captureDevice.isFocusModeSupported(focusMode) 74 | } 75 | 76 | public func isExposureModeSupported(_ exposureMode: AVCaptureDevice.ExposureMode) -> Bool { 77 | guard let captureDevice else { return false } 78 | return captureDevice.isExposureModeSupported(exposureMode) 79 | } 80 | 81 | public var zoomFactorRange: (min: CGFloat, max: CGFloat) { 82 | guard let captureDevice else { return (1, 1) } 83 | return (captureDevice.minAvailableVideoZoomFactor, captureDevice.maxAvailableVideoZoomFactor) 84 | } 85 | } 86 | 87 | // MARK: - Life Cycle 88 | public extension CameraService { 89 | func startSession() { 90 | guard isConfigured && !captureSession.isRunning else { return } 91 | sessionQueue.async { [unowned self] in 92 | captureSession.startRunning() 93 | } 94 | } 95 | 96 | func stopSession() { 97 | guard isConfigured && captureSession.isRunning else { return } 98 | sessionQueue.async { [unowned self] in 99 | captureSession.stopRunning() 100 | } 101 | } 102 | } 103 | 104 | // MARK: - Actions 105 | public extension CameraService { 106 | func capturePhoto(enablesLivePhoto: Bool = true, flashMode: AVCaptureDevice.FlashMode) { 107 | guard let captureOutput = captureOutput as? AVCapturePhotoOutput else { return } 108 | let captureSettings: AVCapturePhotoSettings 109 | captureOutput.isLivePhotoCaptureEnabled = isAvailableLivePhoto && enablesLivePhoto 110 | if captureOutput.isLivePhotoCaptureEnabled { 111 | captureSettings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc]) 112 | captureSettings.livePhotoMovieFileURL = FileManager.default.temporaryDirectory.appending(component: UUID().uuidString).appendingPathExtension("mov") 113 | } else { 114 | captureSettings = AVCapturePhotoSettings() 115 | } 116 | captureSettings.flashMode = flashMode 117 | captureOutput.capturePhoto(with: captureSettings, delegate: self) 118 | } 119 | 120 | func switchCameraDevice(to index: Int, for captureMode: CameraMode) async throws -> CameraMode { 121 | try await withCheckedThrowingContinuation { [unowned self] (continuation: CheckedContinuation) in 122 | sessionQueue.async { [unowned self] in 123 | var cameraMode: CameraMode = .none 124 | do { 125 | switch captureMode { 126 | case .front: 127 | cameraMode = try configureCameraInput(from: frontCaptureDevices, for: captureMode, at: index) 128 | case .rear: 129 | cameraMode = try configureCameraInput(from: rearCaptureDevices, for: captureMode, at: index) 130 | case .none: 131 | cameraMode = .none 132 | } 133 | } catch { 134 | continuation.resume(throwing: error) 135 | } 136 | if let captureInput { 137 | updateConfiguration { [unowned self] in 138 | captureSession.beginConfiguration() 139 | captureSession.addInput(captureInput) 140 | captureSession.commitConfiguration() 141 | } 142 | } 143 | continuation.resume(returning: cameraMode) 144 | } 145 | } 146 | } 147 | 148 | func switchFocusMode(to focusMode: AVCaptureDevice.FocusMode) async throws { 149 | try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in 150 | sessionQueue.async { [unowned self] in 151 | guard let captureDevice else { 152 | continuation.resume(throwing: CameraError.unknownError) 153 | return 154 | } 155 | do { 156 | try captureDevice.lockForConfiguration() 157 | captureDevice.focusMode = focusMode 158 | captureDevice.unlockForConfiguration() 159 | continuation.resume(returning: ()) 160 | } catch { 161 | continuation.resume(throwing: error) 162 | } 163 | } 164 | } 165 | 166 | } 167 | 168 | func switchExposureMode(to exposureMode: AVCaptureDevice.ExposureMode) async throws { 169 | try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in 170 | sessionQueue.async { [unowned self] in 171 | guard let captureDevice else { 172 | continuation.resume(throwing: CameraError.unknownError) 173 | return 174 | } 175 | do { 176 | try captureDevice.lockForConfiguration() 177 | captureDevice.exposureMode = exposureMode 178 | captureDevice.unlockForConfiguration() 179 | continuation.resume(returning: ()) 180 | } catch { 181 | continuation.resume(throwing: error) 182 | } 183 | } 184 | } 185 | } 186 | 187 | func changePointOfInterest(to point: CGPoint) async throws { 188 | try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in 189 | sessionQueue.async { [unowned self] in 190 | let relativeX = point.x / cameraPreviewLayer.frame.size.width 191 | let relativeY = point.y / cameraPreviewLayer.frame.size.height 192 | let pointOfInterest = CGPoint(x: relativeX, y: relativeY) 193 | guard let captureDevice else { 194 | continuation.resume(throwing: CameraError.unknownError) 195 | return 196 | } 197 | do { 198 | try captureDevice.lockForConfiguration() 199 | if captureDevice.isFocusPointOfInterestSupported { 200 | captureDevice.focusMode = captureDevice.focusMode 201 | captureDevice.focusPointOfInterest = pointOfInterest 202 | } 203 | if captureDevice.isExposurePointOfInterestSupported { 204 | captureDevice.exposureMode = captureDevice.exposureMode 205 | captureDevice.exposurePointOfInterest = pointOfInterest 206 | } 207 | captureDevice.unlockForConfiguration() 208 | continuation.resume(returning: ()) 209 | } catch { 210 | continuation.resume(throwing: error) 211 | } 212 | } 213 | } 214 | } 215 | 216 | func changeZoomFactor(to factor: CGFloat) async throws { 217 | try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in 218 | sessionQueue.async { [unowned self] in 219 | guard let captureDevice else { 220 | continuation.resume(throwing: CameraError.unknownError) 221 | return 222 | } 223 | do { 224 | try captureDevice.lockForConfiguration() 225 | captureDevice.ramp(toVideoZoomFactor: factor, withRate: 5) 226 | captureDevice.unlockForConfiguration() 227 | continuation.resume(returning: ()) 228 | } catch { 229 | continuation.resume(throwing: error) 230 | } 231 | } 232 | } 233 | } 234 | } 235 | 236 | // MARK: - Configuration 237 | public extension CameraService { 238 | func configureSession() async throws -> CameraMode { 239 | try await withCheckedThrowingContinuation { [unowned self] (continuation: CheckedContinuation) in 240 | sessionQueue.async { [unowned self] in 241 | captureSession.sessionPreset = .photo 242 | if captureDevices.isEmpty { 243 | continuation.resume(throwing: CameraError.cameraUnavalible) 244 | } 245 | var cameraMode: CameraMode = .none 246 | do { 247 | if !rearCaptureDevices.isEmpty { 248 | cameraMode = try configureCameraInput(from: rearCaptureDevices, for: .rear) 249 | } else if !frontCaptureDevices.isEmpty { 250 | cameraMode = try configureCameraInput(from: frontCaptureDevices, for: .front) 251 | } 252 | try configureCameraOutput() 253 | } catch { 254 | continuation.resume(throwing: error) 255 | } 256 | updateConfiguration { [unowned self] in 257 | captureSession.beginConfiguration() 258 | if let captureInput { 259 | captureSession.addInput(captureInput) 260 | } 261 | if let captureOutput { 262 | captureSession.addOutput(captureOutput) 263 | } 264 | captureSession.commitConfiguration() 265 | } 266 | startSession() 267 | continuation.resume(returning: cameraMode) 268 | } 269 | } 270 | } 271 | 272 | @discardableResult 273 | private func configureCameraInput(from devices: [AVCaptureDevice], for cameraMode: CameraMode, at index: Int = 0) throws -> CameraMode { 274 | guard index < devices.count else { throw CameraError.unknownError } 275 | captureDevice = devices[index] 276 | if let captureDevice { 277 | try captureDevice.lockForConfiguration() 278 | captureDevice.videoZoomFactor = captureDevice.minAvailableVideoZoomFactor 279 | if captureDevice.isFocusPointOfInterestSupported { 280 | captureDevice.focusPointOfInterest = CGPoint(x: 0.5, y: 0.5) 281 | } 282 | if captureDevice.isExposurePointOfInterestSupported { 283 | captureDevice.exposurePointOfInterest = CGPoint(x: 0.5, y: 0.5) 284 | } 285 | captureDevice.unlockForConfiguration() 286 | } 287 | guard let captureDevice else { 288 | throw CameraError.unknownError 289 | } 290 | if let captureInput { 291 | updateConfiguration { [unowned self] in 292 | captureSession.beginConfiguration() 293 | captureSession.removeInput(captureInput) 294 | captureSession.commitConfiguration() 295 | } 296 | } 297 | let newCaptureInput = try AVCaptureDeviceInput(device: captureDevice) 298 | guard captureSession.canAddInput(newCaptureInput) else { 299 | throw CameraError.unknownError 300 | } 301 | self.captureInput = newCaptureInput 302 | return cameraMode 303 | } 304 | 305 | private func configureCameraOutput() throws { 306 | let captureOutput = AVCapturePhotoOutput() 307 | captureOutput.isLivePhotoAutoTrimmingEnabled = false 308 | let captureSettings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg]) 309 | captureOutput.setPreparedPhotoSettingsArray([captureSettings], completionHandler: nil) 310 | guard captureSession.canAddOutput(captureOutput) else { 311 | throw CameraError.unknownError 312 | } 313 | self.captureOutput = captureOutput 314 | } 315 | 316 | private func updateConfiguration(_ execute: @escaping () -> Void) { 317 | isConfigured = false 318 | execute() 319 | isConfigured = true 320 | } 321 | } 322 | 323 | // MARK: - Capture Event 324 | public extension CameraService { 325 | func triggerCaptureEvent(_ event: CaptureEvent) { 326 | captureQueue.async { [unowned self] in 327 | Task { 328 | await captureChannel.send(event) 329 | } 330 | } 331 | } 332 | } 333 | 334 | // MARK: - Permission 335 | public extension CameraService { 336 | var cameraPermissionStatus: AVAuthorizationStatus { 337 | AVCaptureDevice.authorizationStatus(for: .video) 338 | } 339 | 340 | func requestCameraPermission() async -> Bool { 341 | await AVCaptureDevice.requestAccess(for: .video) 342 | } 343 | } 344 | 345 | // MARK: - AVCapturePhotoCaptureDelegate 346 | extension CameraService: AVCapturePhotoCaptureDelegate { 347 | public func photoOutput(_ output: AVCapturePhotoOutput, willBeginCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings) { 348 | triggerCaptureEvent(.initial(resolvedSettings.uniqueID)) 349 | } 350 | 351 | public func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { 352 | let uniqueID = photo.resolvedSettings.uniqueID 353 | if let error { 354 | triggerCaptureEvent(.error(uniqueID, error)) 355 | return 356 | } 357 | let photoData = photo.fileDataRepresentation() 358 | triggerCaptureEvent(.photo(uniqueID, photoData)) 359 | } 360 | 361 | public func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingLivePhotoToMovieFileAt outputFileURL: URL, duration: CMTime, photoDisplayTime: CMTime, resolvedSettings: AVCaptureResolvedPhotoSettings, error: Error?) { 362 | let uniqueID = resolvedSettings.uniqueID 363 | if let error { 364 | triggerCaptureEvent(.error(uniqueID, error)) 365 | return 366 | } 367 | triggerCaptureEvent(.livePhoto(uniqueID, outputFileURL)) 368 | } 369 | 370 | public func photoOutput(_ output: AVCapturePhotoOutput, didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, error: Error?) { 371 | triggerCaptureEvent(.end(resolvedSettings.uniqueID)) 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /Sources/Core/Sources/Core/Services/PhotoLibraryService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoLibraryService.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/18/23. 6 | // 7 | 8 | import UIKit 9 | import Photos 10 | import Utility 11 | import Foundation 12 | import AsyncAlgorithms 13 | 14 | public class PhotoLibraryService: NSObject { 15 | let photoLibrary: PHPhotoLibrary 16 | let imageCachingManager = PHCachingImageManager() 17 | 18 | public lazy var libraryUpdateChannel = AsyncChannel() 19 | 20 | public override init() { 21 | self.photoLibrary = .shared() 22 | super.init() 23 | self.photoLibrary.register(self) 24 | } 25 | } 26 | 27 | // MARK: - Fetching 28 | public extension PhotoLibraryService { 29 | func fetchAllPhotos() async -> PHFetchResult { 30 | await withCheckedContinuation { (continuation: CheckedContinuation, Never>) in 31 | imageCachingManager.allowsCachingHighQualityImages = false 32 | let fetchOptions = PHFetchOptions() 33 | fetchOptions.includeHiddenAssets = false 34 | fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] 35 | continuation.resume(returning: PHAsset.fetchAssets(with: .image, options: fetchOptions)) 36 | } 37 | } 38 | 39 | func loadImage(for localId: String, targetSize: CGSize = PHImageManagerMaximumSize, contentMode: PHImageContentMode = .default) async throws -> UIImage? { 40 | let results = PHAsset.fetchAssets( 41 | withLocalIdentifiers: [localId], 42 | options: nil 43 | ) 44 | guard let asset = results.firstObject else { 45 | throw PhotoLibraryError.photoNotFound 46 | } 47 | let options = PHImageRequestOptions() 48 | options.deliveryMode = .opportunistic 49 | options.resizeMode = .fast 50 | options.isNetworkAccessAllowed = true 51 | options.isSynchronous = true 52 | return try await withCheckedThrowingContinuation { [unowned self] continuation in 53 | imageCachingManager.requestImage( 54 | for: asset, 55 | targetSize: targetSize, 56 | contentMode: contentMode, 57 | options: options, 58 | resultHandler: { image, info in 59 | if let error = info?[PHImageErrorKey] as? Error { 60 | continuation.resume(throwing: error) 61 | return 62 | } 63 | continuation.resume(returning: image) 64 | } 65 | ) 66 | } 67 | } 68 | } 69 | 70 | // MARK: - Saving 71 | public extension PhotoLibraryService { 72 | func savePhoto(for photoData: Data, withLivePhotoURL url: URL? = nil) async throws { 73 | guard photoLibraryPermissionStatus == .authorized else { 74 | throw PhotoLibraryError.photoLibraryDenied 75 | } 76 | do { 77 | try await photoLibrary.performChanges { 78 | let createRequest = PHAssetCreationRequest.forAsset() 79 | createRequest.addResource(with: .photo, data: photoData, options: nil) 80 | if let url { 81 | let options = PHAssetResourceCreationOptions() 82 | options.shouldMoveFile = true 83 | createRequest.addResource(with: .pairedVideo, fileURL: url, options: options) 84 | } 85 | } 86 | } catch { 87 | throw PhotoLibraryError.photoSavingFailed 88 | } 89 | } 90 | } 91 | 92 | // MARK: - Permission 93 | public extension PhotoLibraryService { 94 | var photoLibraryPermissionStatus: PHAuthorizationStatus { 95 | PHPhotoLibrary.authorizationStatus(for: .readWrite) 96 | } 97 | 98 | func requestPhotoLibraryPermission() async { 99 | await PHPhotoLibrary.requestAuthorization(for: .readWrite) 100 | } 101 | } 102 | 103 | // MARK: - Delegate 104 | extension PhotoLibraryService: PHPhotoLibraryChangeObserver { 105 | public func photoLibraryDidChange(_ changeInstance: PHChange) { 106 | Task { 107 | await libraryUpdateChannel.send(changeInstance) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Sources/Core/Tests/CoreTests/CoreTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Core 3 | 4 | final class CoreTests: XCTestCase { 5 | func testExample() throws { 6 | 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/Feature/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Sources/Feature/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Feature", 8 | platforms: [.iOS(.v16)], 9 | products: [ 10 | .library( 11 | name: "Camera", 12 | targets: ["Camera"] 13 | ), 14 | .library( 15 | name: "Gallery", 16 | targets: ["Gallery"] 17 | ), 18 | .library( 19 | name: "Photo", 20 | targets: ["Photo"] 21 | ) 22 | ], 23 | dependencies: [ 24 | .package(path: "../Core"), 25 | .package(path: "../Routing"), 26 | .package(path: "../Utility") 27 | ], 28 | targets: [ 29 | .target( 30 | name: "Camera", 31 | dependencies: [ 32 | .product(name: "Core", package: "Core"), 33 | .product(name: "Routing", package: "Routing"), 34 | .product(name: "Utility", package: "Utility") 35 | ] 36 | ), 37 | .target( 38 | name: "Gallery", 39 | dependencies: [ 40 | .product(name: "Core", package: "Core"), 41 | .product(name: "Routing", package: "Routing"), 42 | .product(name: "Utility", package: "Utility") 43 | ] 44 | ), 45 | .target( 46 | name: "Photo", 47 | dependencies: [ 48 | .product(name: "Core", package: "Core"), 49 | .product(name: "Routing", package: "Routing") 50 | ] 51 | ), 52 | .testTarget( 53 | name: "FeatureTests", 54 | dependencies: ["Camera"] 55 | ), 56 | ], 57 | swiftLanguageVersions: [.v5] 58 | ) 59 | -------------------------------------------------------------------------------- /Sources/Feature/README.md: -------------------------------------------------------------------------------- 1 | # Feature 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Sources/Feature/Sources/Camera/Components/AlertActions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraView+Alert.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/16/23. 6 | // 7 | 8 | import SwiftUI 9 | import Utility 10 | 11 | extension CameraView { 12 | @ViewBuilder 13 | func cameraAlertActions(for error: CameraError, completion: @escaping () -> Void) -> some View { 14 | switch error { 15 | case .cameraDenied: 16 | if let url = URL(string: UIApplication.openSettingsURLString) { 17 | Button("Open Settings") { 18 | UIApplication.shared.open(url) 19 | completion() 20 | } 21 | .fontWeight(.bold) 22 | } 23 | default: 24 | EmptyView() 25 | } 26 | Button("Cancel", role: .cancel) { 27 | completion() 28 | } 29 | } 30 | 31 | @ViewBuilder 32 | func photoLibraryAlertActions(for error: PhotoLibraryError, completion: @escaping () -> Void) -> some View { 33 | switch error { 34 | case .photoLibraryDenied: 35 | if let url = URL(string: UIApplication.openSettingsURLString) { 36 | Button("Open Settings") { 37 | UIApplication.shared.open(url) 38 | completion() 39 | } 40 | .fontWeight(.bold) 41 | } 42 | default: 43 | EmptyView() 44 | } 45 | Button("Cancel", role: .cancel) { 46 | completion() 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/Feature/Sources/Camera/Components/CameraPreviewLayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraContainer.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/16/23. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | import AVFoundation 11 | 12 | struct CameraPreviewLayer: UIViewRepresentable { 13 | let camera: CameraService 14 | 15 | func makeUIView(context: Context) -> LayerView { 16 | let view = LayerView() 17 | camera.cameraPreviewLayer.videoGravity = .resizeAspectFill 18 | camera.cameraPreviewLayer.frame = view.frame 19 | view.layer.addSublayer(camera.cameraPreviewLayer) 20 | return view 21 | } 22 | 23 | func updateUIView(_ uiView: LayerView, context: Context) { } 24 | } 25 | 26 | extension CameraPreviewLayer { 27 | class LayerView: UIView { 28 | override func layoutSubviews() { 29 | super.layoutSubviews() 30 | /// disable default animation of layer. 31 | CATransaction.begin() 32 | CATransaction.setDisableActions(true) 33 | layer.sublayers?.forEach({ layer in 34 | layer.frame = frame 35 | }) 36 | CATransaction.commit() 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Feature/Sources/Camera/Components/FocusFrame.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FocusFrame.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/19/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FocusFrame: Shape { 11 | let lineWidth: CGFloat 12 | let lineHeight: CGFloat 13 | 14 | init(lineWidth: CGFloat = 4, lineHeight: CGFloat = 40) { 15 | self.lineWidth = lineWidth 16 | self.lineHeight = lineHeight 17 | } 18 | 19 | func path(in rect: CGRect) -> Path { 20 | Path { path in 21 | path.addPath( 22 | createCornersPath( 23 | left: rect.minX + lineWidth / 2, 24 | top: rect.minY + lineWidth / 2, 25 | right: rect.width - lineWidth / 2, 26 | bottom: rect.height - lineWidth / 2 27 | ) 28 | ) 29 | } 30 | } 31 | 32 | private func createCornersPath( 33 | left: CGFloat, 34 | top: CGFloat, 35 | right: CGFloat, 36 | bottom: CGFloat 37 | ) -> Path { 38 | var path = Path() 39 | 40 | // top left 41 | path.move(to: CGPoint(x: left, y: top + lineHeight)) 42 | path.addLine(to: CGPoint(x: left, y: top)) 43 | path.addLine(to: CGPoint(x: left + lineHeight, y: top)) 44 | 45 | // top right 46 | path.move(to: CGPoint(x: right - lineHeight, y: top)) 47 | path.addLine(to: CGPoint(x: right, y: top)) 48 | path.addLine(to: CGPoint(x: right, y: top + lineHeight)) 49 | 50 | // bottom right 51 | path.move(to: CGPoint(x: right, y: bottom - lineHeight)) 52 | path.addLine(to: CGPoint(x: right, y: bottom)) 53 | path.addLine(to: CGPoint(x: right - lineHeight, y: bottom)) 54 | 55 | // bottom left 56 | path.move(to: CGPoint(x: left + lineHeight, y: bottom)) 57 | path.addLine(to: CGPoint(x: left, y: bottom)) 58 | path.addLine(to: CGPoint(x: left, y: bottom - lineHeight)) 59 | 60 | return path.strokedPath( 61 | StrokeStyle( 62 | lineWidth: lineWidth, 63 | lineCap: .round 64 | ) 65 | ) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/Feature/Sources/Camera/Dependency/CameraDependency.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraDependency.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/16/23. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | 11 | public struct CameraDependency { 12 | let camera: CameraService 13 | let photoLibrary: PhotoLibraryService 14 | 15 | public init( 16 | camera: CameraService, 17 | photoLibrary: PhotoLibraryService 18 | ) { 19 | self.camera = camera 20 | self.photoLibrary = photoLibrary 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Feature/Sources/Camera/Scene/CameraView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraView.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/16/23. 6 | // 7 | 8 | import Core 9 | import Routing 10 | import SwiftUI 11 | import Utility 12 | 13 | public struct CameraView: View { 14 | @Coordinator var coordinator 15 | @Environment(\.scenePhase) var scenePhase 16 | @StateObject var viewModel: CameraViewModel 17 | 18 | public init(dependency: CameraDependency) { 19 | let viewModel = CameraViewModel(dependency: dependency) 20 | self._viewModel = StateObject(wrappedValue: viewModel) 21 | } 22 | 23 | public var body: some View { 24 | VStack(spacing: 0) { 25 | cameraTopActions 26 | GeometryReader { proxy in 27 | ZStack { 28 | cameraPreview 29 | .onTapGesture(coordinateSpace: .local) { point in 30 | viewModel.changePointOfInterest(to: point, in: proxy.frame(in: .local)) 31 | } 32 | .overlay { 33 | if viewModel.pointOfInterest != .zero { 34 | Rectangle() 35 | .stroke(lineWidth: 2) 36 | .frame(width: 120, height: 120) 37 | .position(viewModel.pointOfInterest) 38 | .animation(.none, value: viewModel.pointOfInterest) 39 | .foregroundColor(.yellow) 40 | .transition(.opacity) 41 | } 42 | } 43 | FocusFrame() 44 | .foregroundColor(.white.opacity(0.8)) 45 | } 46 | } 47 | cameraBottomActions 48 | } 49 | .task { 50 | await viewModel.checkPhotoLibraryPermission() 51 | await viewModel.checkCameraPermission() 52 | } 53 | .task { 54 | await viewModel.bindCaptureChannel() 55 | } 56 | .errorAlert($viewModel.cameraError) { error, completion in 57 | cameraAlertActions(for: error, completion: completion) 58 | } 59 | .onChange(of: scenePhase, perform: viewModel.onChangeScenePhase(to:)) 60 | .preferredColorScheme(.dark) 61 | .coordinated($coordinator) 62 | } 63 | 64 | @ViewBuilder 65 | var cameraPreview: some View { 66 | if viewModel.cameraPermission == .authorized { 67 | GeometryReader { proxy in 68 | CameraPreviewLayer(camera: viewModel.camera) 69 | .onAppear { 70 | viewModel.hideCameraPreview(false) 71 | viewModel.camera.startSession() 72 | } 73 | .onDisappear { 74 | viewModel.hideCameraPreview(true) 75 | viewModel.camera.stopSession() 76 | } 77 | .overlay { 78 | if viewModel.hidesCameraPreview { 79 | Color(uiColor: .systemBackground) 80 | .transition(.opacity.animation(.default)) 81 | } 82 | } 83 | .overlay(alignment: .bottomLeading) { 84 | if !viewModel.photos.isEmpty { 85 | photoPreviewStack 86 | } 87 | } 88 | .overlay(alignment: .bottom) { 89 | Text("x\(viewModel.zoomFactor * 100 / viewModel.camera.zoomFactorRange.max, specifier: "%.1f")") 90 | .font(.headline.bold()) 91 | .padding(.vertical, 3) 92 | .padding(.horizontal, 10) 93 | .background(.ultraThinMaterial) 94 | .clipShape(Capsule()) 95 | .padding(.bottom, 5) 96 | } 97 | .gesture(magnificationGesture(size: proxy.size)) 98 | } 99 | } else { 100 | Color.black 101 | } 102 | } 103 | 104 | @ViewBuilder 105 | var cameraBottomActions: some View { 106 | VStack(spacing: 20) { 107 | switch viewModel.cameraMode { 108 | case .front: 109 | cameraPicker(selection: $viewModel.frontDeviceIndex, devices: viewModel.frontDevices) 110 | .onChange(of: viewModel.frontDeviceIndex) { index in 111 | self.viewModel.switchCameraDevice(to: index, for: .front) 112 | } 113 | case .rear: 114 | cameraPicker(selection: $viewModel.rearDeviceIndex, devices: viewModel.rearDevices) 115 | .onChange(of: viewModel.rearDeviceIndex) { index in 116 | self.viewModel.switchCameraDevice(to: index, for: .rear) 117 | } 118 | case .none: 119 | Color.clear 120 | .frame(height: 25) 121 | } 122 | HStack { 123 | Button { 124 | $coordinator.fullScreen(.gallery) 125 | } label: { 126 | Image(systemName: "photo.on.rectangle.angled") 127 | .font(.title) 128 | .frame(width: 60, height: 60) 129 | .background(.ultraThinMaterial, in: Circle()) 130 | .clipShape(Circle()) 131 | } 132 | Spacer() 133 | Button { 134 | viewModel.capturePhoto() 135 | } label: { 136 | Circle() 137 | } 138 | .padding(5) 139 | .background { 140 | Circle() 141 | .stroke(lineWidth: 3) 142 | } 143 | .frame(width: 80, height: 80) 144 | Spacer() 145 | Button { 146 | viewModel.switchCameraMode() 147 | } label: { 148 | Image(systemName: "arrow.triangle.2.circlepath") 149 | .font(.title) 150 | .frame(width: 60, height: 60) 151 | .background(.ultraThinMaterial, in: Circle()) 152 | .clipShape(Circle()) 153 | } 154 | } 155 | } 156 | .foregroundColor(.white) 157 | .padding(.all, 15) 158 | .background(.black.opacity(0.7), ignoresSafeAreaEdges: .bottom) 159 | .errorAlert($viewModel.photoLibraryError) { error, completion in 160 | photoLibraryAlertActions(for: error, completion: completion) 161 | } 162 | } 163 | 164 | @ViewBuilder 165 | func cameraPicker(selection: Binding, devices: [String]) -> some View { 166 | Picker("", selection: selection) { 167 | ForEach(0.. some Gesture { 267 | MagnificationGesture() 268 | .onChanged { value in 269 | let range = viewModel.camera.zoomFactorRange 270 | let maxZoom = range.max * 5 / 100 271 | guard viewModel.zoomFactor >= range.min && viewModel.zoomFactor <= maxZoom else { 272 | return 273 | } 274 | let delta = value / viewModel.lastZoomFactor 275 | viewModel.lastZoomFactor = value 276 | viewModel.zoomFactor = min(maxZoom, max(range.min, viewModel.zoomFactor * delta)) 277 | viewModel.changeZoomFactor() 278 | } 279 | .onEnded { _ in 280 | viewModel.lastZoomFactor = 1 281 | viewModel.changeZoomFactor() 282 | } 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /Sources/Feature/Sources/Camera/Scene/CameraViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraViewModel.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/16/23. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | import Utility 11 | import Foundation 12 | import AVFoundation 13 | 14 | class CameraViewModel: ObservableObject { 15 | let dependency: CameraDependency 16 | 17 | var rearDevices: [String] = [] 18 | var frontDevices: [String] = [] 19 | var scenePhase: ScenePhase = .inactive 20 | var captureCache: [Int64: CaptureData] = [:] 21 | 22 | @Published var rearDeviceIndex: Int = 0 23 | @Published var cameraError: CameraError? 24 | @Published var frontDeviceIndex: Int = 0 25 | @Published var zoomFactor: CGFloat = 1.0 26 | @Published var photos: Set = [] 27 | @Published var lastZoomFactor: CGFloat = 1.0 28 | @Published var enablesLivePhoto: Bool = true 29 | @Published var cameraMode: CameraMode = .none 30 | @Published var hidesCameraPreview: Bool = true 31 | @Published var pointOfInterest: CGPoint = .zero 32 | @Published var isAvailableLivePhoto: Bool = false 33 | @Published var isAvailableFlashLight: Bool = false 34 | @Published var photoLibraryError: PhotoLibraryError? 35 | @Published var flashMode: AVCaptureDevice.FlashMode = .off 36 | @Published var focusMode: AVCaptureDevice.FocusMode? = nil 37 | @Published var exposureMode: AVCaptureDevice.ExposureMode? = nil 38 | @Published var cameraPermission: AVAuthorizationStatus = .notDetermined 39 | 40 | var camera: CameraService { dependency.camera } 41 | var photoLibrary: PhotoLibraryService { dependency.photoLibrary } 42 | 43 | init(dependency: CameraDependency) { 44 | self.dependency = dependency 45 | self.rearDevices = camera.rearCaptureDevices.map(\.deviceType.deviceName) 46 | self.frontDevices = camera.frontCaptureDevices.map(\.deviceType.deviceName) 47 | } 48 | } 49 | 50 | // MARK: - Actions 51 | extension CameraViewModel { 52 | func switchCameraMode() { 53 | var index: Int 54 | var cameraMode: CameraMode 55 | switch self.cameraMode.opposite { 56 | case .front: 57 | index = frontDeviceIndex 58 | cameraMode = .front 59 | case .rear: 60 | index = rearDeviceIndex 61 | cameraMode = .rear 62 | case .none: 63 | return 64 | } 65 | switchCameraDevice(to: index, for: cameraMode) 66 | } 67 | 68 | func switchCameraDevice(to index: Int, for cameraMode: CameraMode) { 69 | Task { 70 | do { 71 | let cameraMode = try await camera.switchCameraDevice(to: index, for: cameraMode) 72 | await MainActor.run { 73 | updateState(cameraMode) 74 | } 75 | } catch { 76 | await MainActor.run { 77 | self.cameraError = error as? CameraError ?? .unknownError 78 | } 79 | } 80 | } 81 | } 82 | 83 | func toggleLivePhoto() { 84 | if isAvailableLivePhoto { 85 | enablesLivePhoto.toggle() 86 | } 87 | } 88 | 89 | func switchFlashMode() { 90 | if isAvailableFlashLight { 91 | switch flashMode { 92 | case .off: flashMode = .auto 93 | case .auto: flashMode = .on 94 | case .on: flashMode = .off 95 | @unknown default: 96 | flashMode = .off 97 | } 98 | } 99 | } 100 | 101 | func switchFocusMode() { 102 | Task { 103 | let newFocusMode: AVCaptureDevice.FocusMode 104 | let modes: [AVCaptureDevice.FocusMode] = [.autoFocus, .continuousAutoFocus, .locked].filter { 105 | if $0 == focusMode { 106 | return false 107 | } 108 | return camera.isFocusModeSupported($0) 109 | } 110 | if modes.isEmpty { 111 | await MainActor.run { 112 | cameraError = .unknownError 113 | } 114 | return 115 | } 116 | switch focusMode { 117 | case .autoFocus: 118 | newFocusMode = modes.contains(.continuousAutoFocus) ? .continuousAutoFocus : .locked 119 | case .continuousAutoFocus: 120 | newFocusMode = modes.contains(.locked) ? .locked : .autoFocus 121 | case .locked: 122 | newFocusMode = modes.contains(.autoFocus) ? .autoFocus : .continuousAutoFocus 123 | default: 124 | return 125 | } 126 | do { 127 | try await camera.switchFocusMode(to: newFocusMode) 128 | await MainActor.run { 129 | focusMode = newFocusMode 130 | } 131 | } catch { 132 | await MainActor.run { 133 | cameraError = error as? CameraError ?? .unknownError 134 | } 135 | } 136 | } 137 | } 138 | 139 | func switchExposureMode() { 140 | Task { 141 | let newExposureMode: AVCaptureDevice.ExposureMode 142 | let modes: [AVCaptureDevice.ExposureMode] = [.autoExpose, .continuousAutoExposure, .locked].filter { 143 | if $0 == exposureMode { 144 | return false 145 | } 146 | return camera.isExposureModeSupported($0) 147 | } 148 | if modes.isEmpty { 149 | await MainActor.run { 150 | cameraError = .unknownError 151 | } 152 | return 153 | } 154 | switch exposureMode { 155 | case .autoExpose: 156 | newExposureMode = modes.contains(.continuousAutoExposure) ? .continuousAutoExposure : .locked 157 | case .continuousAutoExposure: 158 | newExposureMode = modes.contains(.locked) ? .locked : .autoExpose 159 | case .locked: 160 | newExposureMode = modes.contains(.autoExpose) ? .autoExpose : .continuousAutoExposure 161 | default: 162 | return 163 | } 164 | do { 165 | try await camera.switchExposureMode(to: newExposureMode) 166 | await MainActor.run { 167 | exposureMode = newExposureMode 168 | } 169 | } catch { 170 | await MainActor.run { 171 | cameraError = error as? CameraError ?? .unknownError 172 | } 173 | } 174 | } 175 | } 176 | 177 | func changePointOfInterest(to point: CGPoint, in frame: CGRect) { 178 | Task { 179 | do { 180 | await MainActor.run { 181 | pointOfInterest = .zero 182 | } 183 | let offset: CGFloat = 60 184 | let x = max(offset, min(point.x, frame.maxX - offset)) 185 | let y = max(offset, min(point.y, frame.maxY - offset)) 186 | let point = CGPoint(x: x, y: y) 187 | await MainActor.run { 188 | withAnimation { 189 | pointOfInterest = point 190 | } 191 | } 192 | try await camera.changePointOfInterest(to: point) 193 | } catch { 194 | await MainActor.run { 195 | cameraError = error as? CameraError ?? .unknownError 196 | } 197 | } 198 | } 199 | } 200 | 201 | func changeZoomFactor() { 202 | Task { 203 | do { 204 | try await camera.changeZoomFactor(to: zoomFactor) 205 | } catch { 206 | await MainActor.run { 207 | cameraError = error as? CameraError ?? .unknownError 208 | } 209 | } 210 | } 211 | } 212 | 213 | func capturePhoto() { 214 | Task { 215 | camera.capturePhoto(enablesLivePhoto: enablesLivePhoto, flashMode: flashMode) 216 | } 217 | } 218 | } 219 | 220 | // MARK: - Capture Events 221 | extension CameraViewModel { 222 | func bindCaptureChannel() async { 223 | for await event in camera.captureChannel { 224 | handleCaptureEvent(event) 225 | } 226 | } 227 | 228 | private func handleCaptureEvent(_ event: CaptureEvent) { 229 | switch event { 230 | case let .initial(uniqueId): 231 | Task { 232 | captureCache[uniqueId] = CaptureData(uniqueId: uniqueId) 233 | } 234 | case let .photo(uniqueId, photo): 235 | Task { 236 | captureCache[uniqueId]?.setPhoto(photo) 237 | guard let photo, let image = UIImage(data: photo, scale: 1) else { return } 238 | await MainActor.run { 239 | withAnimation { 240 | _ = photos.insert(CapturePhoto(id: uniqueId, image: image)) 241 | } 242 | } 243 | } 244 | case let .livePhoto(uniqueId, url): 245 | Task { 246 | captureCache[uniqueId]?.setLivePhotoURL(url) 247 | } 248 | case let .end(uniqueId): 249 | Task { 250 | guard let photo = photos.first(where: { uniqueId == $0.id }) else { return } 251 | try? await Task.sleep(for: .seconds(2)) 252 | await MainActor.run { 253 | withAnimation { 254 | _ = photos.remove(photo) 255 | } 256 | } 257 | } 258 | Task { 259 | if let captureData = captureCache[uniqueId], let photo = captureData.photo { 260 | do { 261 | try await photoLibrary.savePhoto(for: photo, withLivePhotoURL: captureData.livePhotoURL) 262 | captureCache[uniqueId] = nil 263 | } catch { 264 | await MainActor.run { 265 | photoLibraryError = error as? PhotoLibraryError ?? .unknownError 266 | } 267 | } 268 | } 269 | } 270 | case let .error(uniqueId, error): 271 | Task { 272 | captureCache[uniqueId] = nil 273 | guard let photo = photos.first(where: { uniqueId == $0.id }) else { return } 274 | await MainActor.run { 275 | withAnimation { 276 | _ = photos.remove(photo) 277 | } 278 | cameraError = error as? CameraError ?? .unknownError 279 | } 280 | } 281 | } 282 | } 283 | } 284 | 285 | // MARK: - UI Update 286 | extension CameraViewModel { 287 | func hideCameraPreview(_ value: Bool) { 288 | withAnimation { 289 | hidesCameraPreview = value 290 | } 291 | } 292 | 293 | @MainActor 294 | func updateState(_ cameraMode: CameraMode) { 295 | withAnimation { 296 | self.pointOfInterest = .zero 297 | } 298 | self.cameraMode = cameraMode 299 | self.isAvailableLivePhoto = camera.isAvailableLivePhoto 300 | self.isAvailableFlashLight = camera.isAvailableFlashLight 301 | self.focusMode = camera.captureDevice?.focusMode 302 | self.exposureMode = camera.captureDevice?.exposureMode 303 | self.zoomFactor = camera.captureDevice?.videoZoomFactor ?? .zero 304 | self.lastZoomFactor = 1.0 305 | } 306 | } 307 | 308 | // MARK: - Scene Phase 309 | extension CameraViewModel { 310 | func onChangeScenePhase(to scenePhase: ScenePhase) { 311 | onChangeScenePhaseForCamera(to: scenePhase) 312 | onChangeScenePhaseForPhotoLibrary(to: scenePhase) 313 | self.scenePhase = scenePhase 314 | } 315 | 316 | private func onChangeScenePhaseForCamera(to scenePhase: ScenePhase) { 317 | switch scenePhase { 318 | case .active: 319 | let isRunning = camera.captureSession.isRunning 320 | camera.startSession() 321 | Task { 322 | try? await Task.sleep(for: .seconds(isRunning ? 0.4 : 0.6)) 323 | await MainActor.run { 324 | hideCameraPreview(false) 325 | } 326 | } 327 | guard cameraError == .cameraDenied else { return } 328 | Task { 329 | await checkCameraPermission() 330 | } 331 | case .background: 332 | camera.stopSession() 333 | hideCameraPreview(true) 334 | case .inactive: 335 | if self.scenePhase == .active { 336 | camera.stopSession() 337 | Task { 338 | try? await Task.sleep(for: .seconds(0.5)) 339 | await MainActor.run { 340 | hideCameraPreview(true) 341 | } 342 | } 343 | } else { 344 | camera.startSession() 345 | } 346 | default: 347 | break 348 | } 349 | } 350 | 351 | private func onChangeScenePhaseForPhotoLibrary(to scenePhase: ScenePhase) { 352 | switch scenePhase { 353 | case .active: 354 | Task { 355 | await checkPhotoLibraryPermission() 356 | } 357 | default: 358 | break 359 | } 360 | } 361 | } 362 | 363 | // MARK: - Permission 364 | extension CameraViewModel { 365 | func checkCameraPermission() async { 366 | let status = camera.cameraPermissionStatus 367 | await MainActor.run { 368 | cameraPermission = status 369 | } 370 | switch status { 371 | case .notDetermined: 372 | _ = await camera.requestCameraPermission() 373 | await checkCameraPermission() 374 | case .restricted, .denied: 375 | await MainActor.run { 376 | cameraError = .cameraDenied 377 | } 378 | case .authorized: 379 | Task { 380 | do { 381 | let cameraMode = try await camera.configureSession() 382 | await MainActor.run { 383 | updateState(cameraMode) 384 | } 385 | } catch { 386 | await MainActor.run { 387 | self.cameraError = error as? CameraError ?? .unknownError 388 | } 389 | } 390 | } 391 | @unknown default: 392 | break 393 | } 394 | } 395 | 396 | func checkPhotoLibraryPermission() async { 397 | let status = photoLibrary.photoLibraryPermissionStatus 398 | switch status { 399 | case .notDetermined: 400 | _ = await photoLibrary.requestPhotoLibraryPermission() 401 | await checkPhotoLibraryPermission() 402 | case .restricted, .denied, .limited: 403 | await MainActor.run { 404 | photoLibraryError = .photoLibraryDenied 405 | } 406 | default: break 407 | } 408 | } 409 | } 410 | -------------------------------------------------------------------------------- /Sources/Feature/Sources/Gallery/Components/PhotoThumbnail.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoThumbnail.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/21/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PhotoThumbnail: View { 11 | @State var image: UIImage? 12 | 13 | let assetId: String 14 | let loadImage: (String, CGSize) async -> UIImage? 15 | let onTap: (UIImage?, String) -> Void 16 | 17 | init( 18 | assetId: String, 19 | loadImage: @escaping (String, CGSize) async -> UIImage?, 20 | onTap: @escaping (UIImage?, String) -> Void 21 | ) { 22 | self.assetId = assetId 23 | self.loadImage = loadImage 24 | self.onTap = onTap 25 | } 26 | 27 | var body: some View { 28 | GeometryReader { proxy in 29 | ZStack { 30 | if let image { 31 | Image(uiImage: image) 32 | .resizable() 33 | .scaledToFill() 34 | .frame(width: proxy.size.width, height: proxy.size.height) 35 | .clipped() 36 | } else { 37 | Color.gray 38 | .opacity(0.3) 39 | } 40 | } 41 | .task { 42 | let image = await loadImage(assetId, proxy.size) 43 | await MainActor.run { 44 | self.image = image 45 | } 46 | } 47 | .onDisappear { 48 | self.image = nil 49 | } 50 | .onTapGesture { 51 | onTap(image, assetId) 52 | } 53 | } 54 | } 55 | } 56 | 57 | -------------------------------------------------------------------------------- /Sources/Feature/Sources/Gallery/Dependency/GalleryDependency.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GalleryDependency.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/21/23. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | 11 | public struct GalleryDependency { 12 | let photoLibrary: PhotoLibraryService 13 | 14 | public init(photoLibrary: PhotoLibraryService) { 15 | self.photoLibrary = photoLibrary 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Feature/Sources/Gallery/Scene/GalleryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GalleryView.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/21/23. 6 | // 7 | 8 | import Photos 9 | import SwiftUI 10 | import Routing 11 | 12 | public struct GalleryView: View { 13 | @Coordinator var coordinator 14 | @StateObject var viewModel: GalleryViewModel 15 | 16 | public init(dependency: GalleryDependency) { 17 | let viewModel = GalleryViewModel(dependency: dependency) 18 | self._viewModel = StateObject(wrappedValue: viewModel) 19 | } 20 | 21 | var columns: [GridItem] = [GridItem](repeating: GridItem(.flexible(), spacing: 5, alignment: .center), count: 3) 22 | 23 | public var body: some View { 24 | GeometryReader { proxy in 25 | NavigationStack { 26 | ScrollView { 27 | LazyVGrid(columns: columns, spacing: 5) { 28 | ForEach(0..() 17 | 18 | var photoLibrary: PhotoLibraryService { 19 | dependency.photoLibrary 20 | } 21 | 22 | init(dependency: GalleryDependency) { 23 | self.dependency = dependency 24 | } 25 | } 26 | 27 | // MARK: - Library Update 28 | extension GalleryViewModel { 29 | func bindLibraryUpdateChannel() async { 30 | for await changeInstance in photoLibrary.libraryUpdateChannel { 31 | if let changes = changeInstance.changeDetails(for: results) { 32 | await MainActor.run { 33 | withAnimation { 34 | results = changes.fetchResultAfterChanges 35 | } 36 | } 37 | } 38 | } 39 | } 40 | } 41 | 42 | // MARK: - Fetching 43 | extension GalleryViewModel { 44 | func loadImage(for assetId: String, targetSize: CGSize) async -> UIImage? { 45 | try? await dependency.photoLibrary.loadImage(for: assetId, targetSize: targetSize) 46 | } 47 | 48 | func loadAllPhotos() async { 49 | let results = await photoLibrary.fetchAllPhotos() 50 | await MainActor.run { 51 | withAnimation { 52 | self.results = results 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Feature/Sources/Photo/Dependency/PhotoDependency.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoDependency.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/22/23. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | import Foundation 11 | 12 | public struct PhotoDependency { 13 | let photo: UIImage? 14 | let assetId: String 15 | let fileName: String 16 | let photoLibrary: PhotoLibraryService 17 | 18 | public init( 19 | photo: UIImage?, 20 | assetId: String, 21 | fileName: String, 22 | photoLibrary: PhotoLibraryService 23 | ) { 24 | self.photo = photo 25 | self.assetId = assetId 26 | self.fileName = fileName 27 | self.photoLibrary = photoLibrary 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Feature/Sources/Photo/Scene/PhotoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoView.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/22/23. 6 | // 7 | 8 | import Routing 9 | import SwiftUI 10 | import Utility 11 | 12 | public struct PhotoView: View { 13 | @Coordinator var coordinator 14 | @StateObject var viewModel: PhotoViewModel 15 | 16 | public init(dependency: PhotoDependency) { 17 | let viewModel = PhotoViewModel(dependency: dependency) 18 | self._viewModel = StateObject(wrappedValue: viewModel) 19 | } 20 | 21 | public var body: some View { 22 | NavigationStack { 23 | GeometryReader { proxy in 24 | ZStack { 25 | if let photo = viewModel.photo { 26 | Image(uiImage: photo) 27 | .resizable() 28 | .scaledToFit() 29 | .frame(width: proxy.size.width, height: proxy.size.height) 30 | .scaleEffect(viewModel.scale) 31 | .offset(viewModel.offset) 32 | .gesture(dragGesture(size: proxy.size)) 33 | .gesture(magnificationGesture(size: proxy.size)) 34 | } else { 35 | ProgressView() 36 | } 37 | } 38 | .task { 39 | await viewModel.loadImage(targetSize: proxy.size) 40 | } 41 | } 42 | .ignoresSafeArea(.all) 43 | .navigationBarTitleDisplayMode(.inline) 44 | .navigationTitle(viewModel.dependency.fileName) 45 | .toolbar { 46 | ToolbarItem(placement: .navigationBarTrailing) { 47 | Button { 48 | $coordinator.dismiss() 49 | } label: { 50 | Image(systemName: "xmark") 51 | .font(.caption) 52 | .bold() 53 | .padding(10) 54 | .background(.ultraThinMaterial, in: Circle()) 55 | } 56 | .tint(.white) 57 | } 58 | } 59 | .toolbarBackground(.visible, for: .navigationBar) 60 | } 61 | .errorAlert($viewModel.photoLibraryError) { _, completion in 62 | Button("Cancel", role: .cancel) { 63 | completion() 64 | } 65 | } 66 | } 67 | 68 | private func magnificationGesture(size: CGSize) -> some Gesture { 69 | MagnificationGesture() 70 | .onChanged { value in 71 | let delta = value / viewModel.lastScale 72 | viewModel.lastScale = value 73 | viewModel.scale = viewModel.scale * delta 74 | } 75 | .onEnded { _ in 76 | viewModel.lastScale = 1 77 | let range: (min: CGFloat, max: CGFloat) = (min: 1, max: 4) 78 | if viewModel.scale < range.min || viewModel.scale > range.max { 79 | withAnimation { 80 | viewModel.scale = min(range.max, max(range.min, viewModel.scale)) 81 | } 82 | } 83 | resetImageFrame(size: size) 84 | } 85 | } 86 | 87 | private func dragGesture(size: CGSize) -> some Gesture { 88 | DragGesture() 89 | .onChanged { value in 90 | let deltaX = value.translation.width - viewModel.lastOffset.width 91 | let deltaY = value.translation.height - viewModel.lastOffset.height 92 | viewModel.lastOffset = value.translation 93 | 94 | let newOffsetWidth = viewModel.offset.width + deltaX 95 | let newOffsetHeight = viewModel.offset.height + deltaY 96 | viewModel.offset.width = newOffsetWidth 97 | viewModel.offset.height = newOffsetHeight 98 | } 99 | .onEnded { value in 100 | viewModel.lastOffset = .zero 101 | resetImageFrame(size: size) 102 | } 103 | } 104 | 105 | func widthLimit(size: CGSize) -> CGFloat { 106 | let halfWidth = size.width / 2 107 | let scaledHalfWidth = halfWidth * viewModel.scale 108 | return halfWidth - scaledHalfWidth 109 | } 110 | 111 | func heightLimit(size: CGSize) -> CGFloat { 112 | let halfHeight = size.height / 2 113 | let scaledHalfHeight = halfHeight * viewModel.scale 114 | return halfHeight - scaledHalfHeight 115 | } 116 | 117 | func resetImageFrame(size: CGSize) { 118 | let widthLimit = widthLimit(size: size) 119 | if viewModel.offset.width < widthLimit { 120 | withAnimation { 121 | viewModel.offset.width = widthLimit 122 | } 123 | } 124 | if viewModel.offset.width > -widthLimit { 125 | withAnimation { 126 | viewModel.offset.width = -widthLimit 127 | } 128 | } 129 | 130 | let heightLimit = heightLimit(size: size) 131 | if viewModel.offset.height < heightLimit { 132 | withAnimation { 133 | viewModel.offset.height = heightLimit 134 | } 135 | } 136 | if viewModel.offset.height > -heightLimit { 137 | withAnimation { 138 | viewModel.offset.height = -heightLimit 139 | } 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Sources/Feature/Sources/Photo/Scene/PhotoViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoViewModel.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/22/23. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | import Utility 11 | import Foundation 12 | 13 | class PhotoViewModel: ObservableObject { 14 | let dependency: PhotoDependency 15 | 16 | @Published var photo: UIImage? 17 | @Published var scale: CGFloat = 1.0 18 | @Published var lastScale: CGFloat = 1.0 19 | @Published var offset: CGSize = .zero 20 | @Published var lastOffset: CGSize = .zero 21 | @Published var photoLibraryError: PhotoLibraryError? 22 | 23 | var photoLibrary: PhotoLibraryService { 24 | dependency.photoLibrary 25 | } 26 | 27 | init(dependency: PhotoDependency) { 28 | self.dependency = dependency 29 | self.photo = dependency.photo 30 | } 31 | } 32 | 33 | // MARK: - Fetching 34 | extension PhotoViewModel { 35 | func loadImage(targetSize: CGSize) async { 36 | do { 37 | let size = CGSize(width: targetSize.width * 3, height: targetSize.height * 3) 38 | let photo = try await dependency.photoLibrary.loadImage(for: dependency.assetId, targetSize: size) 39 | await MainActor.run { 40 | self.photo = photo 41 | } 42 | } catch { 43 | await MainActor.run { 44 | photoLibraryError = error as? PhotoLibraryError ?? .photoLoadingFailed 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Feature/Tests/FeatureTests/FeatureTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Feature 3 | 4 | final class FeatureTests: XCTestCase { 5 | func testExample() throws { } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Routing/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Sources/Routing/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Routing", 8 | platforms: [.iOS(.v16)], 9 | products: [ 10 | .library( 11 | name: "Routing", 12 | targets: ["Routing"] 13 | ), 14 | ], 15 | dependencies: [], 16 | targets: [ 17 | .target( 18 | name: "Routing", 19 | dependencies: [] 20 | ), 21 | .testTarget( 22 | name: "RoutingTests", 23 | dependencies: ["Routing"] 24 | ), 25 | ], 26 | swiftLanguageVersions: [.v5] 27 | ) 28 | -------------------------------------------------------------------------------- /Sources/Routing/README.md: -------------------------------------------------------------------------------- 1 | # Routing 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Sources/Routing/Sources/Routing/Coordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Coordinator.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/22/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @propertyWrapper 11 | public struct Coordinator: DynamicProperty { 12 | @Environment(\.dismiss) var dismissRoute 13 | @StateObject var rootSwitcher: RootSwitcher = .shared 14 | @State var fullScreenRoute: Route? 15 | 16 | public var wrappedValue: Route? { nil } 17 | 18 | public var projectedValue: Coordinator { self } 19 | 20 | public init() { } 21 | 22 | public func fullScreen(_ route: Route) { 23 | self.fullScreenRoute = route 24 | } 25 | 26 | public func dismiss() { 27 | dismissRoute() 28 | } 29 | 30 | public func switchScreen(_ route: Route, animated: Bool = true) { 31 | if animated { 32 | withAnimation { 33 | rootSwitcher.switchRoute = route 34 | } 35 | } else { 36 | rootSwitcher.switchRoute = route 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Routing/Sources/Routing/Factory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Factory.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/22/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public protocol Factory { 11 | associatedtype Content: View 12 | @ViewBuilder 13 | func contentView() -> Content 14 | } 15 | 16 | extension Factory { 17 | func view() -> AnyView { 18 | AnyView(contentView()) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Routing/Sources/Routing/RootSwitcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RootSwitcher.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/22/23. 6 | // 7 | 8 | import Foundation 9 | 10 | public class RootSwitcher: ObservableObject { 11 | @Published var switchRoute: Route? 12 | 13 | private init() { } 14 | 15 | static let shared = RootSwitcher() 16 | 17 | public static func setInitialRoute(to route: Route) { 18 | shared.switchRoute = route 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Routing/Sources/Routing/RootView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RootView.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/22/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct RootView: View { 11 | let coordinator: Coordinator 12 | 13 | public init(_ coordinator: Coordinator) { 14 | self.coordinator = coordinator 15 | } 16 | 17 | @ViewBuilder 18 | public var body: some View { 19 | switch coordinator.rootSwitcher.switchRoute { 20 | case .none: 21 | EmptyView() 22 | case .some(let route): 23 | route.contentView 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Routing/Sources/Routing/Route.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Route.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/22/23. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | public enum Route: Equatable, Identifiable, Hashable { 12 | case camera 13 | case gallery 14 | case photo(photo: UIImage?, assetId: String, fileName: String) 15 | 16 | public var id: Int { 17 | hashValue 18 | } 19 | } 20 | 21 | extension Route { 22 | private var factory: any Factory { 23 | self as! any Factory 24 | } 25 | 26 | var contentView: AnyView { 27 | factory.view() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Routing/Sources/Routing/View+Coordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Coordinator.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/22/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension View { 11 | @ViewBuilder 12 | func coordinated(_ coordinator: Coordinator, onDismiss: (() -> Void)? = nil) -> some View { 13 | fullScreenCover(item: coordinator.$fullScreenRoute, onDismiss: onDismiss) { route in 14 | route.contentView 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Routing/Tests/RoutingTests/RoutingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Routing 3 | 4 | final class RoutingTests: XCTestCase { 5 | func testExample() throws { } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Utility/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Sources/Utility/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Utility", 8 | platforms: [.iOS(.v16)], 9 | products: [ 10 | .library( 11 | name: "Utility", 12 | targets: ["Utility"] 13 | ), 14 | ], 15 | dependencies: [], 16 | targets: [ 17 | .target( 18 | name: "Utility", 19 | dependencies: [] 20 | ), 21 | .testTarget( 22 | name: "UtilityTests", 23 | dependencies: ["Utility"] 24 | ), 25 | ], 26 | swiftLanguageVersions: [.v5] 27 | ) 28 | -------------------------------------------------------------------------------- /Sources/Utility/README.md: -------------------------------------------------------------------------------- 1 | # Utility 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Sources/Utility/Sources/Utility/Errors/CameraError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraError.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/16/23. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum CameraError: LocalizedError { 11 | case cameraDenied 12 | case cameraUnavalible 13 | case focusModeChangeFailed 14 | case unknownError 15 | } 16 | 17 | public extension CameraError { 18 | var errorDescription: String? { 19 | switch self { 20 | case .cameraDenied: 21 | return "Camera Acess Denied" 22 | case .cameraUnavalible: 23 | return "Camera Unavailable" 24 | case .focusModeChangeFailed: 25 | return "Focus Mode Change Failed" 26 | case .unknownError: 27 | return "Unknown Error" 28 | } 29 | } 30 | 31 | var recoverySuggestion: String? { 32 | switch self { 33 | case .cameraDenied: 34 | return "You need to allow the camera access to fully capture the moment around you. Go to Settings and enable the camera permission." 35 | case .cameraUnavalible: 36 | return "There is no camera avalible on your device. 🥲" 37 | case .focusModeChangeFailed: 38 | return "It failed to change focus mode. 🥲" 39 | case .unknownError: 40 | return "Oops! The unknown error occurs." 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/Utility/Sources/Utility/Errors/PhotoLibraryError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoLibraryError.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/18/23. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum PhotoLibraryError: LocalizedError { 11 | case photoNotFound 12 | case photoSavingFailed 13 | case photoLibraryDenied 14 | case photoLoadingFailed 15 | case unknownError 16 | } 17 | 18 | public extension PhotoLibraryError { 19 | var errorDescription: String? { 20 | switch self { 21 | case .photoNotFound: 22 | return "Photo Not Found" 23 | case .photoSavingFailed: 24 | return "Photo Saving Failed" 25 | case .photoLibraryDenied: 26 | return "Photo Library Access Denied" 27 | case .photoLoadingFailed: 28 | return "Photo Loading Failed" 29 | case .unknownError: 30 | return "Unknown Error" 31 | } 32 | } 33 | 34 | var recoverySuggestion: String? { 35 | switch self { 36 | case .photoNotFound: 37 | return "The photo is not found in the photo library." 38 | case .photoSavingFailed: 39 | return "Oops! There is an error occurred while saving a photo into the photo library." 40 | case .photoLibraryDenied: 41 | return "You need to allow the photo library access to save pictures you captured. Go to Settings and enable the photo library permission." 42 | case .photoLoadingFailed: 43 | return "Oops! There is an error occurred while loading a photo from the photo library." 44 | case .unknownError: 45 | return "Oops! The unknown error occurs." 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Utility/Sources/Utility/Extensions/DeviceType++.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeviceType++.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/17/23. 6 | // 7 | 8 | import AVFoundation 9 | 10 | public extension AVCaptureDevice.DeviceType { 11 | var deviceName: String { 12 | switch self { 13 | case .builtInTrueDepthCamera: 14 | return "True Depth" 15 | case .builtInDualCamera: 16 | return "Dual" 17 | case .builtInDualWideCamera: 18 | return "Dual Wide" 19 | case .builtInTripleCamera: 20 | return "Triple" 21 | case .builtInWideAngleCamera: 22 | return "Wide Angle" 23 | case .builtInUltraWideCamera: 24 | return "Ultra Wide" 25 | case .builtInLiDARDepthCamera: 26 | return "LiDAR Depth" 27 | case .builtInTelephotoCamera: 28 | return "Telephoto" 29 | default: 30 | return "Unknown" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Utility/Sources/Utility/Extensions/View++.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View++.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/16/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension View { 11 | @ViewBuilder 12 | func errorAlert(_ error: Binding, @ViewBuilder actions: @escaping (E, @escaping () -> Void) -> Content) -> some View { 13 | let wrappedValue = error.wrappedValue 14 | let title = wrappedValue?.errorDescription ?? "" 15 | alert(title, isPresented: .constant(wrappedValue != nil), presenting: wrappedValue) { err in 16 | actions(err) { 17 | error.wrappedValue = nil 18 | } 19 | } message: { error in 20 | if let suggestion = error.recoverySuggestion { 21 | Text(suggestion) 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Utility/Sources/Utility/Helpers/CameraMode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraMode.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/16/23. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum CameraMode { 11 | case front 12 | case rear 13 | case none 14 | 15 | public var opposite: CameraMode { 16 | switch self { 17 | case .front: 18 | return .rear 19 | case .rear: 20 | return .front 21 | case .none: 22 | return .none 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Utility/Sources/Utility/Helpers/CaptureData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CaptureData.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/22/23. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct CaptureData { 11 | public var uniqueId: Int64 12 | public var photo: Data? 13 | public var livePhotoURL: URL? 14 | 15 | public init(uniqueId: Int64) { 16 | self.uniqueId = uniqueId 17 | } 18 | 19 | mutating public func setPhoto(_ photo: Data?) { 20 | self.photo = photo 21 | } 22 | 23 | mutating public func setLivePhotoURL(_ url: URL) { 24 | self.livePhotoURL = url 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Utility/Sources/Utility/Helpers/CaptureEvent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CaptureEvent.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/22/23. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum CaptureEvent { 11 | case initial(Int64) 12 | case photo(Int64, Data?) 13 | case livePhoto(Int64, URL) 14 | case end(Int64) 15 | case error(Int64, Error) 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Utility/Sources/Utility/Helpers/CapturePhoto.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CapturePhoto.swift 3 | // Capture 4 | // 5 | // Created by Aye Chan on 2/22/23. 6 | // 7 | 8 | import UIKit 9 | 10 | public struct CapturePhoto: Identifiable, Hashable, Comparable { 11 | public static func < (lhs: CapturePhoto, rhs: CapturePhoto) -> Bool { 12 | lhs.id < rhs.id 13 | } 14 | 15 | public var id: Int64 16 | public var image: UIImage 17 | 18 | public init(id: Int64, image: UIImage) { 19 | self.id = id 20 | self.image = image 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Utility/Tests/UtilityTests/UtilityTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Utility 3 | 4 | final class UtilityTests: XCTestCase { 5 | func testExample() throws { } 6 | } 7 | --------------------------------------------------------------------------------