diff --git a/.swiftlint.yml b/.swiftlint.yml index 9f7d391..65a3c28 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,7 +1,6 @@ -whitelist_rules: +only_rules: - anyobject_protocol - array_init - - attributes - block_based_kvo - class_delegate_protocol - closing_brace @@ -12,6 +11,7 @@ whitelist_rules: - colon - comma - compiler_protocol_init + - computed_accessors_order - conditional_returns_on_newline - contains_over_filter_count - contains_over_filter_is_empty @@ -21,12 +21,15 @@ whitelist_rules: - custom_rules - deployment_target - discarded_notification_center_observer + - discouraged_assert - discouraged_direct_init + - discouraged_none_name - discouraged_object_literal - discouraged_optional_boolean - discouraged_optional_collection - duplicate_enum_cases - duplicate_imports + - duplicated_key_in_dictionary_literal - dynamic_inline - empty_collection_literal - empty_count @@ -35,22 +38,20 @@ whitelist_rules: - empty_parentheses_with_trailing_closure - empty_string - empty_xctest_method - - enum_case_associated_value_count + - enum_case_associated_values_count - explicit_init - fallthrough - fatal_error_message - first_where - flatmap_over_map_reduce - for_where - # Enable these once in a while - # - force_cast - # - force_try - # - force_unwrapping - generic_type_name + - ibinspectable_in_extension - identical_operands - identifier_name - implicit_getter - implicit_return + - inclusive_language - inert_defer - is_disjoint - joined_default_parameter @@ -84,11 +85,13 @@ whitelist_rules: - operator_whitespace - orphaned_doc_comment - overridden_super_call + - prefer_self_in_static_references - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init - private_action - private_outlet + - private_subject - private_unit_test - - prohibited_nan_comparison - prohibited_super_call - protocol_property_accessors_order - reduce_boolean @@ -102,8 +105,9 @@ whitelist_rules: - redundant_type_annotation - redundant_void_return - required_enum_case - - return_value_from_void_function - return_arrow_whitespace + - return_value_from_void_function + - self_in_property_initialization - shorthand_operator - sorted_first_last - statement_position @@ -113,18 +117,20 @@ whitelist_rules: - switch_case_alignment - switch_case_on_newline - syntactic_sugar + - test_case_accessibility - toggle_bool - trailing_closure - trailing_comma - trailing_newline - trailing_semicolon - trailing_whitespace - - tuple_pattern + - unavailable_condition - unavailable_function - unneeded_break_in_switch - unneeded_parentheses_in_closure_argument - unowned_variable_capture - untyped_error_in_catch + - unused_capture_list - unused_closure_parameter - unused_control_flow_label - unused_enumerated @@ -136,16 +142,14 @@ whitelist_rules: - vertical_whitespace_closing_braces - vertical_whitespace_opening_braces - void_return - - weak_delegate - xct_specific_matcher - xctfail_message - yoda_condition analyzer_rules: + - capture_variable - unused_declaration - unused_import -force_cast: warning -force_try: warning -force_unwrapping: warning + - typesafe_array_init number_separator: minimum_length: 5 identifier_name: @@ -161,14 +165,16 @@ identifier_name: excluded: - 'x' - 'y' + - 'z' - 'a' - 'b' - 'x1' - 'x2' - 'y1' - 'y2' + - 'z2' deployment_target: - iOS_deployment_target: '14' + iOS_deployment_target: '15' custom_rules: no_nsrect: regex: '\bNSRect\b' @@ -182,3 +188,19 @@ custom_rules: regex: '\bNSPoint\b' match_kinds: typeidentifier message: 'Use CGPoint instead of NSPoint' + no_cgfloat: + regex: '\bCGFloat\b' + match_kinds: typeidentifier + message: 'Use Double instead of CGFloat' + no_cgfloat2: + regex: '\bCGFloat\(' + message: 'Use Double instead of CGFloat' + swiftui_state_private: + regex: '@(State|StateObject|ObservedObject|EnvironmentObject)\s+var' + message: 'SwiftUI @State/@StateObject/@ObservedObject/@EnvironmentObject properties should be private' + swiftui_environment_private: + regex: '@Environment\(\\\.\w+\)\s+var' + message: 'SwiftUI @Environment properties should be private' + final_class: + regex: '^class [a-zA-Z\d]+[^{]+\{' + message: 'Classes should be marked as final whenever possible. If you actually need it to be subclassable, just add `// swiftlint:disable:next final_class`.' diff --git a/Action Extension/ActionViewController.swift b/Action Extension/ActionViewController.swift index 250d644..9ec6bcd 100644 --- a/Action Extension/ActionViewController.swift +++ b/Action Extension/ActionViewController.swift @@ -6,9 +6,9 @@ final class ActionViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - initAppCenter() + initSentry() - let contentView = ContentView() + let contentView = MainScreen() .environment(\.extensionContext, extensionContext) view = UIHostingView(rootView: contentView) diff --git a/Action Extension/ContentView.swift b/Action Extension/ContentView.swift deleted file mode 100644 index 98f67ab..0000000 --- a/Action Extension/ContentView.swift +++ /dev/null @@ -1,140 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -struct ContentView: View { - @Environment(\.extensionContext) private var extensionContext: NSExtensionContext! - @State private var image: UIImage? - @State private var blurAmount = Constants.initialBlurAmount - @State private var isSaving = false - @State private var loadError: Error? - @State private var saveError: Error? - @State private var isShowingWallpaperTip = false - @ViewStorage private var uiView: UIView? - - private var controls: some View { - VStack { - HStack { - Group { - Button { - extensionContext.cancel() - } label: { - Image(systemName: "xmark.circle") - .imageScale(.large) - } - .accessibilityLabel("Cancel") - Spacer() - Button { - saveImage() - } label: { - Image(systemName: "square.and.arrow.down") - .imageScale(.large) - } - .accessibilityLabel("Save Image") - } - .shadow(radius: Constants.buttonShadowRadius) - .accentColor(.white) - .padding(.horizontal) - } - Spacer() - Slider(value: $blurAmount, in: 10...100) - .padding(.horizontal, DeviceInfo.isPad ? 60 : 30) - .frame(maxWidth: 500) - .accentColor(.white) - } - .padding() - .padding(.bottom, 40) - .padding(.vertical) - } - - var body: some View { - ZStack { - if let image = image { - EditorView( - image: .constant(image), - blurAmount: $blurAmount - ) - } - if !isSaving { - controls - } - } - .onAppear { - loadImage() - } - .alert2(isPresented: $isShowingWallpaperTip) { - Alert( - title: Text("How to Change Wallpaper"), - message: Text("Go to the most recent photo in the photo library, tap the action button at the \(DeviceInfo.isPad ? "top right" : "bottom left"), and choose “Use as Wallpaper”.") - ) - } - .onChange(of: isShowingWallpaperTip) { - guard !$0 else { - return - } - - extensionContext.cancel() - } - .alert(error: $saveError) - .accessNativeView { - uiView = $0 - } - } - - private func loadImage() { - guard - let attachment = extensionContext.attachments.first, - attachment.hasItemConformingTo(.image) - else { - extensionContext.cancel() - return - } - - attachment.loadItem(forType: .image) { item, error in - guard - let imageURL = item as? URL, - let pickedImage = UIImage(contentsOf: imageURL) - else { - loadError = error - return - } - - image = Utilities.resizeImage(pickedImage) - } - } - - private func saveImage() { - isSaving = true - - delay(seconds: 0.2) { - guard let image = uiView?.highestAncestor?.toImage() else { - saveError = NSError.appError( - "Failed to generate the image.", - // TODO: Report this to AppCenter when it supports non-crash reports. - recoverySuggestion: "Please report this problem to the developer." - ) - return - } - - image.saveToPhotosLibrary { error in - if let error = error { - isSaving = false - saveError = error - return - } - - if SSApp.runOnceShouldRun(identifier: "wallpaperTip") { - isShowingWallpaperTip = true - return - } - - extensionContext.cancel() - } - } - } -} - -//struct ContentView_Previews: PreviewProvider { -// static var previews: some View { -// ContentView() -// } -//} diff --git a/Action Extension/Info.plist b/Action Extension/Info.plist index 77cd3d2..cda1064 100644 --- a/Action Extension/Info.plist +++ b/Action Extension/Info.plist @@ -51,5 +51,7 @@ NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).ActionViewController + ITSAppUsesNonExemptEncryption + diff --git a/Action Extension/MainScreen.swift b/Action Extension/MainScreen.swift new file mode 100644 index 0000000..7f5334d --- /dev/null +++ b/Action Extension/MainScreen.swift @@ -0,0 +1,159 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct MainScreen: View { + @Environment(\.extensionContext) private var extensionContext: NSExtensionContext! + @State private var image: UIImage? + @State private var blurAmount = Constants.initialBlurAmount + @State private var isSaving = false + @State private var error: Error? + @State private var isWallpaperTipPresented = false + @ViewStorage private var hostingView: UIView? + + private var controls: some View { + VStack { + HStack { + Group { + // TODO: Use `CloseOrClearButton`. + Button { + extensionContext.cancel() + } label: { + Image(systemName: "xmark.circle") + .imageScale(.large) + // Increase tap area + .padding(8) + .contentShape(.rectangle) + } + .padding(.horizontal, -8) + // TOOD: use label instead + .help("Cancel") + Spacer() + Button { + Task { + await tryOrAssign($error) { + try await saveImage() + } + } + } label: { + Image(systemName: "square.and.arrow.down") + .imageScale(.large) + // Increase tap area + .padding(8) + .contentShape(.rectangle) + } + .padding(.horizontal, -8) + // TOOD: Use label instead + .help("Save image") + } + .shadow(radius: Constants.buttonShadowRadius) + .tint(.white) + .padding(.horizontal) + } + Spacer() + Slider(value: $blurAmount, in: 10...100) + .padding(.horizontal, DeviceInfo.isPad ? 60 : 30) + .frame(maxWidth: 500) + .tint(.white) + } + .padding() + .padding(.bottom, 40) + .padding(.vertical) + } + + var body: some View { + ZStack { + if let image = image { + EditorScreen( + image: .constant(image), + blurAmount: $blurAmount + ) + } + if !isSaving { + controls + } + } + .taskOrAssign($error) { + image = try await getImage() + } + .alert2( + "How to Change Wallpaper", + message: "Go to the most recent photo in the photo library, tap the action button at the \(DeviceInfo.isPad ? "top right" : "bottom left"), and choose “Use as Wallpaper”.", + isPresented: $isWallpaperTipPresented + ) + .onChange(of: isWallpaperTipPresented) { + guard !$0 else { + return + } + + extensionContext.cancel() + } + .alert(error: $error) + .accessNativeView { + hostingView = $0 + } + } + + private func getImage() async throws -> UIImage { + guard + let itemProvider = (extensionContext.attachments.first { $0.hasItemConforming(to: .image) }) + else { + throw NSError.appError("Did not receive any compatible image.") + } + + // TODO: Force the following to execute in a background thread. + do { + return try await itemProvider.getImage(maxSize: Constants.maxImageSize) + } catch { + SSApp.reportError( + error, + userInfo: [ + "registeredTypeIdentifiers": itemProvider.registeredTypeIdentifiers, + "canLoadObject(UIImage)": itemProvider.canLoadObject(ofClass: UIImage.self), + "underlyingErrors": (error as NSError).underlyingErrors + ] + ) // TODO: Remove at some point. + + throw error + } + } + + private func saveImage() async throws { + try await _saveImage() + + guard SSApp.runOnceShouldRun(identifier: "wallpaperTip") else { + extensionContext.cancel() + return + } + + try? await Task.sleep(seconds: 1) + isWallpaperTipPresented = true + } + + // TODO: Unify this from the main app. + private func _saveImage() async throws { + isSaving = true + + defer { + isSaving = false + } + + try? await Task.sleep(seconds: 0.2) + + guard let image = await hostingView?.highestAncestor?.toImage() else { + SSApp.reportError("Failed to generate the image.") + + throw NSError.appError( + "Failed to generate the image.", + recoverySuggestion: "Please report this problem to the developer (sindresorhus@gmail.com)." + ) + } + + try await image.saveToPhotosLibrary() + } +} + +//struct MainScreen_Previews: PreviewProvider { +// static var previews: some View { +// MainScreen() +// } +//} diff --git a/Blear.xcodeproj/project.pbxproj b/Blear.xcodeproj/project.pbxproj index db7afa5..c77d88e 100644 --- a/Blear.xcodeproj/project.pbxproj +++ b/Blear.xcodeproj/project.pbxproj @@ -7,25 +7,28 @@ objects = { /* Begin PBXBuildFile section */ + E304D608268502480035185A /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = E304D607268502480035185A /* Defaults */; }; + E31C26B3282D301B0047A564 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = E31C26B2282D301B0047A564 /* Sentry */; }; + E31C26B5282D31900047A564 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = E31C26B4282D31900047A564 /* Sentry */; }; E33A6D501B2272B700CBEDC9 /* UIImageEffects.m in Sources */ = {isa = PBXBuildFile; fileRef = E33A6D4F1B2272B700CBEDC9 /* UIImageEffects.m */; }; E3430F431B27AD5800AF3F80 /* Bundled Photos in Resources */ = {isa = PBXBuildFile; fileRef = E3430F421B27AD5800AF3F80 /* Bundled Photos */; }; - E35850582541D24200F9D5BD /* EditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E35850572541D24200F9D5BD /* EditorView.swift */; }; + E3476879267E9ED40022D2F5 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3476878267E9ED40022D2F5 /* AppState.swift */; }; + E35850582541D24200F9D5BD /* EditorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E35850572541D24200F9D5BD /* EditorScreen.swift */; }; + E37CDDE426F1FA7A00130AD3 /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = E37CDDE326F1FA7A00130AD3 /* Defaults */; }; E37D72AE2329664C008B1C48 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = E37D72AD2329664B008B1C48 /* Constants.swift */; }; E3A0365A17E5E7AE00892B76 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3A0365917E5E7AE00892B76 /* App.swift */; }; E3A0368F17E6214600892B76 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3A0368E17E6214500892B76 /* Utilities.swift */; }; - E3C3BAE5255D83DD008FF129 /* AppCenterCrashes in Frameworks */ = {isa = PBXBuildFile; productRef = E3C3BAE4255D83DD008FF129 /* AppCenterCrashes */; }; - E3C773ED255D7B2400A492FC /* AppCenterCrashes in Frameworks */ = {isa = PBXBuildFile; productRef = E3C773EC255D7B2400A492FC /* AppCenterCrashes */; }; E3C77408255D7FF900A492FC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E3C77407255D7FF900A492FC /* Assets.xcassets */; }; - E3DFE28B25422D4500B3F299 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3DFE28A25422D4500B3F299 /* AboutView.swift */; }; + E3DFE28B25422D4500B3F299 /* AboutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3DFE28A25422D4500B3F299 /* AboutScreen.swift */; }; E3DFE29C254390AB00B3F299 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E3DFE29B254390AB00B3F299 /* Media.xcassets */; }; E3DFE29E254390AB00B3F299 /* ActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3DFE29D254390AB00B3F299 /* ActionViewController.swift */; }; E3DFE2A5254390AB00B3F299 /* Action Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = E3DFE299254390AB00B3F299 /* Action Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - E3DFE2AD254390D000B3F299 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3DFE2AC254390D000B3F299 /* ContentView.swift */; }; + E3DFE2AD254390D000B3F299 /* MainScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3DFE2AC254390D000B3F299 /* MainScreen.swift */; }; E3DFE2B22543923B00B3F299 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3A0368E17E6214500892B76 /* Utilities.swift */; }; - E3DFE2B52543923E00B3F299 /* EditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E35850572541D24200F9D5BD /* EditorView.swift */; }; + E3DFE2B52543923E00B3F299 /* EditorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E35850572541D24200F9D5BD /* EditorScreen.swift */; }; E3DFE2B8254392DC00B3F299 /* UIImageEffects.m in Sources */ = {isa = PBXBuildFile; fileRef = E33A6D4F1B2272B700CBEDC9 /* UIImageEffects.m */; }; E3DFE2BB2543930D00B3F299 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = E37D72AD2329664B008B1C48 /* Constants.swift */; }; - E3ECA0C224A4FC5A00A1864E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3ECA0C124A4FC5900A1864E /* ContentView.swift */; }; + E3ECA0C224A4FC5A00A1864E /* MainScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3ECA0C124A4FC5900A1864E /* MainScreen.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -56,7 +59,8 @@ E33A6D4E1B2272B700CBEDC9 /* UIImageEffects.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UIImageEffects.h; sourceTree = ""; }; E33A6D4F1B2272B700CBEDC9 /* UIImageEffects.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UIImageEffects.m; sourceTree = ""; }; E3430F421B27AD5800AF3F80 /* Bundled Photos */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "Bundled Photos"; sourceTree = ""; }; - E35850572541D24200F9D5BD /* EditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorView.swift; sourceTree = ""; }; + E3476878267E9ED40022D2F5 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + E35850572541D24200F9D5BD /* EditorScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorScreen.swift; sourceTree = ""; }; E37D72AD2329664B008B1C48 /* Constants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Constants.swift; sourceTree = ""; usesTabs = 1; }; E3A0364617E5E7AE00892B76 /* Blear.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Blear.app; sourceTree = BUILT_PRODUCTS_DIR; }; E3A0365117E5E7AE00892B76 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -64,14 +68,14 @@ E3A0368E17E6214500892B76 /* Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Utilities.swift; sourceTree = ""; usesTabs = 1; }; E3B140881F2C2F2A008408AC /* Blear-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "Blear-Bridging-Header.h"; sourceTree = ""; }; E3C77407255D7FF900A492FC /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - E3DFE28A25422D4500B3F299 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; + E3DFE28A25422D4500B3F299 /* AboutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutScreen.swift; sourceTree = ""; }; E3DFE299254390AB00B3F299 /* Action Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Action Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; E3DFE29B254390AB00B3F299 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = ""; }; E3DFE29D254390AB00B3F299 /* ActionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionViewController.swift; sourceTree = ""; }; E3DFE2A2254390AB00B3F299 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - E3DFE2AC254390D000B3F299 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - E3DFE2C425439A8A00B3F299 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = ""; }; - E3ECA0C124A4FC5900A1864E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + E3DFE2AC254390D000B3F299 /* MainScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreen.swift; sourceTree = ""; }; + E3DFE2C425439A8A00B3F299 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; + E3ECA0C124A4FC5900A1864E /* MainScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreen.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -79,7 +83,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - E3C773ED255D7B2400A492FC /* AppCenterCrashes in Frameworks */, + E304D608268502480035185A /* Defaults in Frameworks */, + E31C26B3282D301B0047A564 /* Sentry in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -87,7 +92,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - E3C3BAE5255D83DD008FF129 /* AppCenterCrashes in Frameworks */, + E37CDDE426F1FA7A00130AD3 /* Defaults in Frameworks */, + E31C26B5282D31900047A564 /* Sentry in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -97,7 +103,7 @@ E3A0363D17E5E7AE00892B76 = { isa = PBXGroup; children = ( - E3DFE2C425439A8A00B3F299 /* Shared.xcconfig */, + E3DFE2C425439A8A00B3F299 /* Config.xcconfig */, E3A0364F17E5E7AE00892B76 /* Blear */, E3DFE29A254390AB00B3F299 /* Action Extension */, E3B0ED1522CCE084001D4F9E /* Vendor */, @@ -120,9 +126,10 @@ children = ( E3A0365917E5E7AE00892B76 /* App.swift */, E37D72AD2329664B008B1C48 /* Constants.swift */, - E3ECA0C124A4FC5900A1864E /* ContentView.swift */, - E35850572541D24200F9D5BD /* EditorView.swift */, - E3DFE28A25422D4500B3F299 /* AboutView.swift */, + E3476878267E9ED40022D2F5 /* AppState.swift */, + E3ECA0C124A4FC5900A1864E /* MainScreen.swift */, + E35850572541D24200F9D5BD /* EditorScreen.swift */, + E3DFE28A25422D4500B3F299 /* AboutScreen.swift */, E3A0368E17E6214500892B76 /* Utilities.swift */, E3C77407255D7FF900A492FC /* Assets.xcassets */, E3A0365017E5E7AE00892B76 /* Supporting Files */, @@ -168,7 +175,7 @@ isa = PBXGroup; children = ( E3DFE29B254390AB00B3F299 /* Media.xcassets */, - E3DFE2AC254390D000B3F299 /* ContentView.swift */, + E3DFE2AC254390D000B3F299 /* MainScreen.swift */, E3DFE29D254390AB00B3F299 /* ActionViewController.swift */, E3DFE2A2254390AB00B3F299 /* Info.plist */, ); @@ -195,7 +202,8 @@ ); name = Blear; packageProductDependencies = ( - E3C773EC255D7B2400A492FC /* AppCenterCrashes */, + E304D607268502480035185A /* Defaults */, + E31C26B2282D301B0047A564 /* Sentry */, ); productName = blurr; productReference = E3A0364617E5E7AE00892B76 /* Blear.app */; @@ -215,7 +223,8 @@ ); name = "Action Extension"; packageProductDependencies = ( - E3C3BAE4255D83DD008FF129 /* AppCenterCrashes */, + E37CDDE326F1FA7A00130AD3 /* Defaults */, + E31C26B4282D31900047A564 /* Sentry */, ); productName = "Action Extension"; productReference = E3DFE299254390AB00B3F299 /* Action Extension.appex */; @@ -250,7 +259,8 @@ ); mainGroup = E3A0363D17E5E7AE00892B76; packageReferences = ( - E3C773EB255D7B2400A492FC /* XCRemoteSwiftPackageReference "appcenter-sdk-apple" */, + E304D606268502480035185A /* XCRemoteSwiftPackageReference "Defaults" */, + E31C26B1282D301B0047A564 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, ); productRefGroup = E3A0364717E5E7AE00892B76 /* Products */; projectDirPath = ""; @@ -305,9 +315,10 @@ buildActionMask = 2147483647; files = ( E37D72AE2329664C008B1C48 /* Constants.swift in Sources */, - E3ECA0C224A4FC5A00A1864E /* ContentView.swift in Sources */, - E3DFE28B25422D4500B3F299 /* AboutView.swift in Sources */, - E35850582541D24200F9D5BD /* EditorView.swift in Sources */, + E3ECA0C224A4FC5A00A1864E /* MainScreen.swift in Sources */, + E3DFE28B25422D4500B3F299 /* AboutScreen.swift in Sources */, + E3476879267E9ED40022D2F5 /* AppState.swift in Sources */, + E35850582541D24200F9D5BD /* EditorScreen.swift in Sources */, E3A0365A17E5E7AE00892B76 /* App.swift in Sources */, E33A6D501B2272B700CBEDC9 /* UIImageEffects.m in Sources */, E3A0368F17E6214600892B76 /* Utilities.swift in Sources */, @@ -319,8 +330,8 @@ buildActionMask = 2147483647; files = ( E3DFE2B22543923B00B3F299 /* Utilities.swift in Sources */, - E3DFE2B52543923E00B3F299 /* EditorView.swift in Sources */, - E3DFE2AD254390D000B3F299 /* ContentView.swift in Sources */, + E3DFE2B52543923E00B3F299 /* EditorScreen.swift in Sources */, + E3DFE2AD254390D000B3F299 /* MainScreen.swift in Sources */, E3DFE2B8254392DC00B3F299 /* UIImageEffects.m in Sources */, E3DFE29E254390AB00B3F299 /* ActionViewController.swift in Sources */, E3DFE2BB2543930D00B3F299 /* Constants.swift in Sources */, @@ -340,7 +351,7 @@ /* Begin XCBuildConfiguration section */ E3A0367017E5E7AE00892B76 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E3DFE2C425439A8A00B3F299 /* Shared.xcconfig */; + baseConfigurationReference = E3DFE2C425439A8A00B3F299 /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -387,7 +398,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.5; + IPHONEOS_DEPLOYMENT_TARGET = 15.4; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; @@ -397,7 +408,7 @@ }; E3A0367117E5E7AE00892B76 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E3DFE2C425439A8A00B3F299 /* Shared.xcconfig */; + baseConfigurationReference = E3DFE2C425439A8A00B3F299 /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -437,7 +448,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.5; + IPHONEOS_DEPLOYMENT_TARGET = 15.4; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; @@ -593,26 +604,44 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - E3C773EB255D7B2400A492FC /* XCRemoteSwiftPackageReference "appcenter-sdk-apple" */ = { + E304D606268502480035185A /* XCRemoteSwiftPackageReference "Defaults" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/microsoft/appcenter-sdk-apple"; + repositoryURL = "https://github.com/sindresorhus/Defaults"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 4.1.1; + minimumVersion = 6.2.1; + }; + }; + E31C26B1282D301B0047A564 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/getsentry/sentry-cocoa"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 7.15.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - E3C3BAE4255D83DD008FF129 /* AppCenterCrashes */ = { + E304D607268502480035185A /* Defaults */ = { + isa = XCSwiftPackageProductDependency; + package = E304D606268502480035185A /* XCRemoteSwiftPackageReference "Defaults" */; + productName = Defaults; + }; + E31C26B2282D301B0047A564 /* Sentry */ = { + isa = XCSwiftPackageProductDependency; + package = E31C26B1282D301B0047A564 /* XCRemoteSwiftPackageReference "sentry-cocoa" */; + productName = Sentry; + }; + E31C26B4282D31900047A564 /* Sentry */ = { isa = XCSwiftPackageProductDependency; - package = E3C773EB255D7B2400A492FC /* XCRemoteSwiftPackageReference "appcenter-sdk-apple" */; - productName = AppCenterCrashes; + package = E31C26B1282D301B0047A564 /* XCRemoteSwiftPackageReference "sentry-cocoa" */; + productName = Sentry; }; - E3C773EC255D7B2400A492FC /* AppCenterCrashes */ = { + E37CDDE326F1FA7A00130AD3 /* Defaults */ = { isa = XCSwiftPackageProductDependency; - package = E3C773EB255D7B2400A492FC /* XCRemoteSwiftPackageReference "appcenter-sdk-apple" */; - productName = AppCenterCrashes; + package = E304D606268502480035185A /* XCRemoteSwiftPackageReference "Defaults" */; + productName = Defaults; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/Blear/AboutView.swift b/Blear/AboutScreen.swift similarity index 69% rename from Blear/AboutView.swift rename to Blear/AboutScreen.swift index 1f61e4c..5b79eac 100644 --- a/Blear/AboutView.swift +++ b/Blear/AboutScreen.swift @@ -1,14 +1,14 @@ import SwiftUI -struct AboutView: View { - @Environment(\.presentationMode) private var presentationMode +struct AboutScreen: View { + @Environment(\.dismiss) private var dismiss var body: some View { NavigationView { Form { Section( header: Text("\(SSApp.name) \(SSApp.version)"), - footer: Text("\n\(SSApp.name) was made with ♥ by Sindre Sorhus, an indie app developer from Norway.\n\nIf you enjoy using this app, please consider leaving a review in the App Store. It helps more than you can imagine.") + footer: Text("\n\(SSApp.name) was made with ♥ by Sindre Sorhus, an indie app developer from Norway.\n\nIf you enjoy using this app, please consider leaving a review on the App Store. It helps more than you can imagine.") ) { SendFeedbackButton() Link("Website", destination: "https://sindresorhus.com/blear") @@ -17,11 +17,10 @@ struct AboutView: View { } } .navigationTitle("About") - .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { - presentationMode.wrappedValue.dismiss() + dismiss() } } } @@ -29,8 +28,8 @@ struct AboutView: View { } } -struct AboutView_Previews: PreviewProvider { +struct AboutScreen_Previews: PreviewProvider { static var previews: some View { - AboutView() + AboutScreen() } } diff --git a/Blear/App.swift b/Blear/App.swift index cebb06e..fd231e1 100644 --- a/Blear/App.swift +++ b/Blear/App.swift @@ -2,13 +2,16 @@ import SwiftUI @main struct AppMain: App { + @StateObject private var appState = AppState() + init() { - initAppCenter() + initSentry() } var body: some Scene { WindowGroup { - ContentView() + MainScreen() + .environmentObject(appState) } } } diff --git a/Blear/AppState.swift b/Blear/AppState.swift new file mode 100644 index 0000000..7ace799 --- /dev/null +++ b/Blear/AppState.swift @@ -0,0 +1,6 @@ +import SwiftUI + +@MainActor +final class AppState: ObservableObject { + init() {} +} diff --git a/Blear/Constants.swift b/Blear/Constants.swift index 648a623..74a3f14 100644 --- a/Blear/Constants.swift +++ b/Blear/Constants.swift @@ -3,7 +3,8 @@ import UIKit enum Constants { static let initialBlurAmount = 50.0 - static let buttonShadowRadius: CGFloat = 2 + static let buttonShadowRadius = 2.0 + static let maxImageSize = UIScreen.main.bounds.size.longestSide / 2 } @@ -11,10 +12,3 @@ enum DeviceInfo { static let isPhone = UIDevice.current.userInterfaceIdiom == .phone static let isPad = UIDevice.current.userInterfaceIdiom == .pad } - - -enum Utilities { - static func resizeImage(_ image: UIImage) -> UIImage { - image.resized(longestSide: Double(UIScreen.main.bounds.size.longestSide) / 2) - } -} diff --git a/Blear/ContentView.swift b/Blear/ContentView.swift deleted file mode 100644 index 2c50920..0000000 --- a/Blear/ContentView.swift +++ /dev/null @@ -1,171 +0,0 @@ -import SwiftUI - -struct ContentView: View { - private static let stockImages = Bundle.main.urls(forResourcesWithExtension: "jpg", subdirectory: "Bundled Photos")! - private static let randomImageIterator = stockImages.uniqueRandomElement() - - private static func getRandomImage() -> UIImage { - UIImage(contentsOf: randomImageIterator.next()!)! - } - - // For testing individual bundled images. - // @State private var image = stockImages.first { $0.path.contains("stock6.jpg") }.map { UIImage(contentsOf: $0)! } ?? UIImage() - - @State private var image = Self.getRandomImage() - @State private var blurAmount = Constants.initialBlurAmount - @State private var isShowingShakeTip = false - @State private var isShowingWallpaperTip = false - @State private var isShowingAboutSheet = false - @State private var isSaving = false - @State private var saveError: Error? - @ViewStorage private var window: UIWindow? - - private var controls: some View { - VStack { - HStack { - Spacer() - Menu { - Button { - randomImage() - } label: { - Label("Random Image", systemImage: "photo") - } - Divider() - Button { - isShowingAboutSheet = true - } label: { - Label("About", systemImage: "info.circle") - } - } label: { - Image(systemName: "ellipsis.circle") - .accessibilityLabel("More") - // TODO: Workaround for iOS 14.5 where the tap target is tiny. - .imageScale(.large) - .padding(.trailing, 2) - .contentShape(Rectangle()) - // - .shadow(radius: Constants.buttonShadowRadius) - .accentColor(.white) - } - } - .padding(.top) - Spacer() - HStack { - SinglePhotoPickerButton { pickedImage in - image = Utilities.resizeImage(pickedImage) - } - .accessibilityLabel("Pick Image") - .shadow(radius: Constants.buttonShadowRadius) - Spacer() - // TODO: Use a custom slider like the iOS brightness control. - Slider(value: $blurAmount, in: 10...100) - .padding(.horizontal, DeviceInfo.isPad ? 60 : 30) - .frame(maxWidth: 500) - Spacer() - Button { - saveImage() - } label: { - Image(systemName: "square.and.arrow.down") - } - .accessibilityLabel("Save Image") - .shadow(radius: Constants.buttonShadowRadius) - } - .imageScale(.large) - .accentColor(.white) - } - .edgesIgnoringSafeArea(.top) - .padding(.horizontal, DeviceInfo.isPad ? 50 : 30) - .padding(.bottom, DeviceInfo.isPad ? 50 : 30) - .fadeInAfterDelay(0.4) - } - - var body: some View { - ZStack { - EditorView( - image: $image, - blurAmount: $blurAmount - ) - .onTapGesture(count: 2) { - randomImage() - } - if !isSaving { - controls - } - } - .statusBar(hidden: true) - .alert2(isPresented: $isShowingShakeTip) { - Alert( - title: Text("Tip"), - message: Text("Double-tap the image or shake the device to get another random image.") - ) - } - .alert2(isPresented: $isShowingWallpaperTip) { - Alert( - title: Text("How to Change Wallpaper"), - message: Text("In the Photos app, go to the image you just saved, tap the action button at the \(DeviceInfo.isPad ? "top right" : "bottom left"), and choose “Use as Wallpaper”.") - ) - } - .alert(error: $saveError) - .sheet(isPresented: $isShowingAboutSheet) { - AboutView() - } - .onAppear { - showShakeTipIfNeeded() - } - .onDeviceShake { - image = Self.getRandomImage() - } - .accessNativeWindow { - window = $0 - } - } - - private func randomImage() { - image = Self.getRandomImage() - } - - private func saveImage() { - isSaving = true - - delay(seconds: 0.2) { - guard let image = window?.rootViewController?.view?.toImage() else { - saveError = NSError.appError( - "Failed to generate the image.", - recoverySuggestion: "Please report this problem to the developer." - ) - return - } - - image.saveToPhotosLibrary { error in - isSaving = false - - if let error = error { - saveError = error - return - } - - showWallpaperTipIfNeeded() - } - } - } - - private func showShakeTipIfNeeded() { - guard SSApp.isFirstLaunch else { - return - } - - delay(seconds: 1.5) { - isShowingShakeTip = true - } - } - - private func showWallpaperTipIfNeeded() { - guard SSApp.isFirstLaunch else { - return - } - - delay(seconds: 1) { - isShowingWallpaperTip = true - } - } -} diff --git a/Blear/EditorView.swift b/Blear/EditorScreen.swift similarity index 74% rename from Blear/EditorView.swift rename to Blear/EditorScreen.swift index c684051..942dc0f 100644 --- a/Blear/EditorView.swift +++ b/Blear/EditorScreen.swift @@ -1,7 +1,9 @@ import SwiftUI -struct EditorView: View { - private static let updateImageQueue = DispatchQueue(label: "\(SSApp.id).updateImage", qos: .userInteractive) +// TODO: Move to an ObservableObject that handles the blurring. Or maybe Actor? + +struct EditorScreen: View { + private static let updateImageQueue = DispatchQueue(label: "\(SSApp.idString).updateImage", qos: .userInteractive) @ViewStorage private var workItem: DispatchWorkItem? @State private var blurredImage: UIImage? @@ -28,7 +30,7 @@ struct EditorView: View { .onChange(of: blurAmount) { updateImage(blurAmount: $0) } - .onAppear { + .task { UIScrollView.appearance().bounces = false updateImage(blurAmount: blurAmount) } @@ -37,15 +39,15 @@ struct EditorView: View { private func blurImage(_ blurAmount: Double) -> UIImage { UIImageEffects.imageByApplyingBlur( to: image, - withRadius: CGFloat(blurAmount * 0.8), - tintColor: UIColor(white: 1, alpha: CGFloat(max(0, min(0.25, blurAmount * 0.004)))), - saturationDeltaFactor: CGFloat(max(1, min(2.8, blurAmount * (DeviceInfo.isPad ? 0.035 : 0.045)))), + withRadius: blurAmount * 0.8, + tintColor: UIColor(white: 1, alpha: max(0, min(0.25, blurAmount * 0.004))), + saturationDeltaFactor: max(1, min(2.8, blurAmount * (DeviceInfo.isPad ? 0.035 : 0.045))), maskImage: nil ) } private func updateImage(blurAmount: Double) { - if let workItem = self.workItem { + if let workItem = workItem { workItem.cancel() } @@ -58,8 +60,8 @@ struct EditorView: View { } } -//struct EditorView_Previews: PreviewProvider { +//struct EditorScreen_Previews: PreviewProvider { // static var previews: some View { -// EditorView() +// EditorScreen() // } //} diff --git a/Blear/Info.plist b/Blear/Info.plist index d84324a..b2f3c03 100644 --- a/Blear/Info.plist +++ b/Blear/Info.plist @@ -48,5 +48,9 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown + UIWhitePointAdaptivityStyle + UIWhitePointAdaptivityStylePhoto + ITSAppUsesNonExemptEncryption + diff --git a/Blear/MainScreen.swift b/Blear/MainScreen.swift new file mode 100644 index 0000000..ab0f0b6 --- /dev/null +++ b/Blear/MainScreen.swift @@ -0,0 +1,189 @@ +import SwiftUI + +struct MainScreen: View { + private static let stockImages = Bundle.main.urls(forResourcesWithExtension: "jpg", subdirectory: "Bundled Photos")! + private static let randomImageIterator = stockImages.uniqueRandomElement() + + private static func getRandomImage() -> UIImage { + UIImage(contentsOf: randomImageIterator.next()!)! + } + + // For testing individual bundled images. + // @State private var image = stockImages.first { $0.path.contains("stock6.jpg") }.map { UIImage(contentsOf: $0)! } ?? UIImage() + + @Environment(\.sizeCategory) private var sizeClass + @State private var image = Self.getRandomImage() + @State private var blurAmount = Constants.initialBlurAmount + @State private var isShakeTipPresented = false + @State private var isWallpaperTipPresented = false + @State private var isAboutScreenPresented = false + @State private var isSaving = false + @State private var error: Error? + @ViewStorage private var hostingWindow: UIWindow? + + private var moreButton: some View { + Menu { + Button("Random Image", systemImage: "photo") { + randomImage() + } + Divider() + Button("About", systemImage: "info.circle") { + isAboutScreenPresented = true + } + } label: { + Label("More", systemImage: "ellipsis.circle") + // TODO: Workaround for iOS 15.4 where the tap target is tiny. + .imageScale(.large) + .padding(.trailing, 2) + // Increase tap area + .padding(8) + .contentShape(.rectangle) + // + .shadow(radius: Constants.buttonShadowRadius) + .tint(.white) + .labelStyle(.iconOnly) + .offset(y: -8) + } + } + + private var controls: some View { + VStack { + HStack { + Spacer() + moreButton + } + .padding(.top) + Spacer() + HStack { + SinglePhotoPickerButton(maxSize: Constants.maxImageSize) { pickedImage in + tryOrAssign($error) { + image = try pickedImage.get() + } + } + .help("Pick image") + .shadow(radius: Constants.buttonShadowRadius) + // Increase tap area + .padding(8) + .contentShape(.rectangle) + .padding(.horizontal, -8) + Spacer() + // TODO: Use a custom slider like the iOS brightness control. + Slider(value: $blurAmount, in: 10...100) + .padding(.horizontal, DeviceInfo.isPad ? 60 : 30) + .frame(minWidth: 180, maxWidth: 500) + .scaleEffect(sizeClass.isAccessibilityCategory ? 1.5 : 1) + .padding(.horizontal, sizeClass.isAccessibilityCategory ? 10 : 0) + Spacer() + Button { + Task { + await tryOrAssign($error) { + try await saveImage() + } + } + } label: { + Image(systemName: "square.and.arrow.down") + // Increase tap area + .padding(8) + .contentShape(.rectangle) + } + .help("Save image") + .shadow(radius: Constants.buttonShadowRadius) + .padding(.horizontal, -8) + } + .imageScale(.large) + .tint(.white) + } + .edgesIgnoringSafeArea(.top) + .padding(.horizontal, DeviceInfo.isPad ? 50 : 30) + .padding(.bottom, DeviceInfo.isPad ? 50 : 30) + .fadeInAfterDelay(0.4) + } + + var body: some View { + ZStack { + EditorScreen( + image: $image, + blurAmount: $blurAmount + ) + .onTapGesture(count: 2) { + randomImage() + } + if !isSaving { + controls + } + } + .statusBar(hidden: true) + .alert2( + "Tip", + message: "Double-tap the image or shake the device to get another random image.", + isPresented: $isShakeTipPresented + ) + .alert2( + "How to Change Wallpaper", + message: "In the Photos app, go to the image you just saved, tap the action button at the \(DeviceInfo.isPad ? "top right" : "bottom left"), and choose “Use as Wallpaper”.", + isPresented: $isWallpaperTipPresented + ) + .alert(error: $error) + .sheet(isPresented: $isAboutScreenPresented) { + AboutScreen() + } + .task { + await showShakeTipIfNeeded() + } + .onDeviceShake { + image = Self.getRandomImage() + } + .bindNativeWindow($hostingWindow) + } + + private func randomImage() { + image = Self.getRandomImage() + } + + private func saveImage() async throws { + try await _saveImage() + + await showWallpaperTipIfNeeded() + + await SSApp.requestReviewAfterBeingCalledThisManyTimes([3, 50, 200, 500, 1000]) + } + + private func _saveImage() async throws { + isSaving = true + + defer { + isSaving = false + } + + try? await Task.sleep(seconds: 0.2) + + guard let image = await hostingWindow?.rootViewController?.view?.toImage() else { + SSApp.reportError("Failed to generate the image.") + + throw NSError.appError( + "Failed to generate the image.", + recoverySuggestion: "Please report this problem to the developer (sindresorhus@gmail.com)." + ) + } + + try await image.saveToPhotosLibrary() + } + + private func showShakeTipIfNeeded() async { + guard SSApp.isFirstLaunch else { + return + } + + try? await Task.sleep(seconds: 1.5) + isShakeTipPresented = true + } + + private func showWallpaperTipIfNeeded() async { + guard SSApp.isFirstLaunch else { + return + } + + try? await Task.sleep(seconds: 1) + isWallpaperTipPresented = true + } +} diff --git a/Blear/Utilities.swift b/Blear/Utilities.swift index a05a088..8f1bcfd 100644 --- a/Blear/Utilities.swift +++ b/Blear/Utilities.swift @@ -2,17 +2,18 @@ import SwiftUI import Combine import MobileCoreServices import PhotosUI -import AppCenter -import AppCenterCrashes +import StoreKit +import Sentry +import Defaults -func initAppCenter() { - AppCenter.start( - withAppSecret: "266f557d-902a-44d4-8d0e-65b3fd19ae16", - services: [ - Crashes.self - ] - ) +func initSentry() { + #if !DEBUG + SentrySDK.start { + $0.dsn = "https://56d71bf257f043f8ad95ce7b61d52b41@o844094.ingest.sentry.io/6398796" + $0.enableSwizzling = false + } + #endif } @@ -35,25 +36,6 @@ func with(_ item: T, update: (inout T) throws -> Void) rethrows -> T { } -extension DispatchQueue { - /** - ``` - DispatchQueue.main.asyncAfter(duration: 100.milliseconds) { - print("100ms later") - } - ``` - */ - func asyncAfter(duration: TimeInterval, execute: @escaping () -> Void) { - asyncAfter(deadline: .now() + duration, execute: execute) - } -} - - -func delay(seconds: TimeInterval, closure: @escaping () -> Void) { - DispatchQueue.main.asyncAfter(duration: seconds, execute: closure) -} - - extension Collection { /** Returns a infinite sequence with consecutively unique random elements from the collection. @@ -90,8 +72,11 @@ extension Collection { extension UIImage { - /// Initialize with a URL. - /// `AppKit.NSImage` polyfill. + /** + Initialize with a URL. + + `AppKit.NSImage` polyfill. + */ convenience init?(contentsOf url: URL) { self.init(contentsOfFile: url.path) } @@ -111,16 +96,18 @@ extension UIImage { ) } - /// Resize the image so the longest side is equal or less than `longestSide`. + /** + Resize the image so the longest side is equal or less than `longestSide`. + */ func resized(longestSide: Double) -> UIImage { - let longestSide = CGFloat(longestSide) + let length = longestSide let width = size.width let height = size.height let ratio = width / height let newSize = width > height - ? CGSize(width: longestSide, height: longestSide / ratio) - : CGSize(width: longestSide * ratio, height: longestSide) + ? CGSize(width: length, height: length / ratio) + : CGSize(width: length * ratio, height: length) return resized(to: newSize) } @@ -134,7 +121,9 @@ extension UIImage { extension UIView { - /// The most efficient solution. + /** + The most efficient solution. + */ @objc func toImage() -> UIImage { UIGraphicsImageRenderer(size: bounds.size).image { _ in @@ -145,11 +134,11 @@ extension UIView { extension UIEdgeInsets { - init(all: CGFloat) { + init(all: Double) { self.init(top: all, left: all, bottom: all, right: all) } - init(horizontal: CGFloat, vertical: CGFloat) { + init(horizontal: Double, vertical: Double) { self.init(top: vertical, left: horizontal, bottom: vertical, right: horizontal) } @@ -171,13 +160,15 @@ extension UIScrollView { extension CGSize { func aspectFit(to size: Self) -> Self { let ratio = max(size.width / width, size.height / height) - return Self(width: width * CGFloat(ratio), height: height * CGFloat(ratio)) + return Self(width: width * ratio, height: height * ratio) } } extension Bundle { - /// Returns the current app's bundle whether it's called from the app or an app extension. + /** + Returns the current app's bundle whether it's called from the app or an app extension. + */ static let app: Bundle = { var components = main.bundleURL.path.split(separator: "/") @@ -192,7 +183,7 @@ extension Bundle { enum SSApp { - static let id = Bundle.main.bundleIdentifier! + static let idString = Bundle.main.bundleIdentifier! static let name = Bundle.main.object(forInfoDictionaryKey: kCFBundleNameKey as String) as! String static let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String static let build = Bundle.main.object(forInfoDictionaryKey: kCFBundleVersionKey as String) as! String @@ -213,12 +204,14 @@ enum SSApp { extension SSApp { - /// - Note: Call this lazily only when actually needed as otherwise it won't get the live info. + /** + - Note: Call this lazily only when actually needed as otherwise it won't get the live info. + */ static func appFeedbackUrl() -> URL { let metadata = """ \(SSApp.name) \(SSApp.versionWithBuild) - Bundle Identifier: \(SSApp.id) + Bundle Identifier: \(SSApp.idString) OS: \(UIDevice.current.systemName) \(UIDevice.current.systemVersion) Model: \(Device.modelIdentifier) """ @@ -237,7 +230,9 @@ final class ErrorRecoveryAttempter: NSObject { struct Option { let title: String - /// Return a boolean for whether the recovery was successful. + /** + Return a boolean for whether the recovery was successful. + */ let action: () -> Bool } @@ -268,7 +263,6 @@ final class ErrorRecoveryAttempter: NSObject { } extension NSError { - // TODO: I should probably include failure reason too. https://stackoverflow.com/questions/37160801/setting-nslocalizedrecoveryoptionserrorkey-as-nserror-userinfo-doesnt-provide-a https://github.com/CharlesJS/CSErrors/blob/1847537713809cd6176a34b2912756544ad9139e/Sources/CSErrors/Utils.swift /** Use this for generic app errors. @@ -306,7 +300,7 @@ extension NSError { } return .init( - domain: domainPostfix.map { "\(SSApp.id) - \($0)" } ?? SSApp.id, + domain: domainPostfix.map { "\(SSApp.idString) - \($0)" } ?? SSApp.idString, code: 1, // This is what Swift errors end up as. userInfo: userInfo ) @@ -378,9 +372,9 @@ private struct FadeInAfterDelayModifier: ViewModifier { .disabled(!isShowingContent) .opacity(isShowingContent ? 1 : 0) .animation( - Animation - .easeIn(duration: 0.5) - .delay(delay) + .easeIn(duration: 0.5) + .delay(delay), + value: isShowingContent ) .onAppear { isShowingContent = true @@ -414,10 +408,10 @@ extension View { extension UIImage { private final class ImageSaver: NSObject { - private let completion: (Error?) -> Void + private let continuation: CheckedContinuation - init(image: UIImage, completion: @escaping (Error?) -> Void) { - self.completion = completion + init(image: UIImage, continuation: CheckedContinuation) { + self.continuation = continuation super.init() UIImageWriteToSavedPhotosAlbum(image, self, #selector(handler), nil) @@ -425,7 +419,12 @@ extension UIImage { @objc private func handler(_ image: UIImage?, didFinishSavingWithError error: Error?, contextInfo: UnsafeMutableRawPointer?) { - completion(error) + if let error = error { + continuation.resume(throwing: error) + return + } + + continuation.resume() } } @@ -434,59 +433,566 @@ extension UIImage { The image will be saved to the “Camera Roll” album if the device has a camera or “Saved Photos” otherwise. */ - func saveToPhotosLibrary(_ completion: @escaping (Error?) -> Void) { - _ = ImageSaver(image: self) { error in - guard PHPhotoLibrary.authorizationStatus(for: .addOnly) == .authorized else { - #if APP_EXTENSION - let recoverySuggestion = "You can manually grant access in “Settings › \(SSApp.rootName) › Photos”." - let recoveryOptions = [ErrorRecoveryAttempter.Option]() - #else - let recoverySuggestion = "You can manually grant access in the “Settings”." - let recoveryOptions = [ - ErrorRecoveryAttempter.Option(title: "Settings") { - guard SSApp.canOpenSettings else { - return false - } - - SSApp.openSettings() - return true - } - ] - #endif + func saveToPhotosLibrary() async throws { + try await withCheckedThrowingContinuation { continuation in + _ = ImageSaver(image: self, continuation: continuation) + } - let error = NSError.appError( - "“\(SSApp.rootName)” does not have access to add photos to your photo library.", - recoverySuggestion: recoverySuggestion, - recoveryOptions: recoveryOptions - ) + guard PHPhotoLibrary.authorizationStatus(for: .addOnly) == .authorized else { + #if APP_EXTENSION + let recoverySuggestion = "You can manually grant access in “Settings › \(SSApp.rootName) › Photos”." + let recoveryOptions = [ErrorRecoveryAttempter.Option]() + #else + let recoverySuggestion = "You can manually grant access in the “Settings”." + let recoveryOptions = [ + ErrorRecoveryAttempter.Option(title: "Settings") { + guard SSApp.canOpenSettings else { + return false + } - completion(error) - return - } + SSApp.openSettings() + return true + } + ] + #endif - completion(error) + throw NSError.appError( + "“\(SSApp.rootName)” does not have access to add photos to your photo library.", + recoverySuggestion: recoverySuggestion, + recoveryOptions: recoveryOptions + ) } } } +extension Binding { + /** + Converts the binding of an optional value to a binding to a boolean for whether the value is non-nil. + + You could use this in a `isPresent` parameter for a sheet, alert, etc, to have it show when the value is non-nil. + */ + func isPresent() -> Binding where Value == Wrapped? { + .init( + get: { wrappedValue != nil }, + set: { isPresented in + if !isPresented { + wrappedValue = nil + } + } + ) + } +} + + extension View { - /// This allows multiple alerts on a single view, which `.alert()` doesn't. - func alert2( + /** + This allows multiple alerts on a single view, which `.alert()` doesn't. + */ + func alert2( + _ title: Text, + isPresented: Binding, + @ViewBuilder actions: () -> A, + @ViewBuilder message: () -> M + ) -> some View where A: View, M: View { + background( + EmptyView() + .alert( + title, + isPresented: isPresented, + actions: actions, + message: message + ) + ) + } + + /** + This allows multiple alerts on a single view, which `.alert()` doesn't. + */ + func alert2( + _ title: String, + isPresented: Binding, + @ViewBuilder actions: () -> A, + @ViewBuilder message: () -> M + ) -> some View where A: View, M: View { + alert2( + Text(title), + isPresented: isPresented, + actions: actions, + message: message + ) + } + + /** + This allows multiple alerts on a single view, which `.alert()` doesn't. + */ + func alert2( + _ title: Text, + message: String? = nil, + isPresented: Binding, + @ViewBuilder actions: () -> A + ) -> some View where A: View { + // swiftlint:disable:next trailing_closure + alert2( + title, + isPresented: isPresented, + actions: actions, + message: { + if let message = message { + Text(message) + } + } + ) + } + + // This is a convenience method and does not exist natively. + /** + This allows multiple alerts on a single view, which `.alert()` doesn't. + */ + func alert2( + _ title: String, + message: String? = nil, isPresented: Binding, - content: @escaping () -> Alert + @ViewBuilder actions: () -> A + ) -> some View where A: View { + // swiftlint:disable:next trailing_closure + alert2( + title, + isPresented: isPresented, + actions: actions, + message: { + if let message = message { + Text(message) + } + } + ) + } + + /** + This allows multiple alerts on a single view, which `.alert()` doesn't. + */ + func alert2( + _ title: Text, + message: String? = nil, + isPresented: Binding + ) -> some View { + // swiftlint:disable:next trailing_closure + alert2( + title, + message: message, + isPresented: isPresented, + actions: {} + ) + } + + // This is a convenience method and does not exist natively. + /** + This allows multiple alerts on a single view, which `.alert()` doesn't. + */ + func alert2( + _ title: String, + message: String? = nil, + isPresented: Binding ) -> some View { + // swiftlint:disable:next trailing_closure + alert2( + title, + message: message, + isPresented: isPresented, + actions: {} + ) + } +} + + +extension View { + // This is a convenience method and does not exist natively. + /** + This allows multiple alerts on a single view, which `.alert()` doesn't. + */ + func alert2( + title: (T) -> Text, + presenting data: Binding, + @ViewBuilder actions: (T) -> A, + @ViewBuilder message: (T) -> M + ) -> some View where A: View, M: View { background( - EmptyView().alert( - isPresented: isPresented, - content: content - ) + EmptyView() + .alert( + data.wrappedValue.map(title) ?? Text(""), + isPresented: data.isPresent(), + presenting: data.wrappedValue, + actions: actions, + message: message + ) + ) + } + + // This is a convenience method and does not exist natively. + /** + This allows multiple alerts on a single view, which `.alert()` doesn't. + */ + func alert2( + title: (T) -> Text, + message: ((T) -> String?)? = nil, + presenting data: Binding, + @ViewBuilder actions: (T) -> A + ) -> some View where A: View { + alert2( + title: { title($0) }, + presenting: data, + actions: actions, + message: { + if let message = message?($0) { + Text(message) + } + } ) } + + // This is a convenience method and does not exist natively. + /** + This allows multiple alerts on a single view, which `.alert()` doesn't. + */ + func alert2( + title: (T) -> String, + message: ((T) -> String?)? = nil, + presenting data: Binding, + @ViewBuilder actions: (T) -> A + ) -> some View where A: View { + alert2( + title: { Text(title($0)) }, + message: message, + presenting: data, + actions: actions + ) + } + + // This is a convenience method and does not exist natively. + /** + This allows multiple alerts on a single view, which `.alert()` doesn't. + */ + func alert2( + title: (T) -> Text, + message: ((T) -> String?)? = nil, + presenting data: Binding + ) -> some View { + // swiftlint:disable:next trailing_closure + alert2( + title: title, + message: message, + presenting: data, + actions: { _ in } + ) + } + + // This is a convenience method and does not exist natively. + /** + This allows multiple alerts on a single view, which `.alert()` doesn't. + */ + func alert2( + title: (T) -> String, + message: ((T) -> String?)? = nil, + presenting data: Binding + ) -> some View { + alert2( + title: { Text(title($0)) }, + message: message, + presenting: data + ) + } +} + + +extension Dictionary { + /** + Adds the elements of the given dictionary to a copy of self and returns that. + + Identical keys in the given dictionary overwrites keys in the copy of self. + + - Note: This exists as an addition to `+` as Swift sometimes struggle to infer the type of `dict + dict`. + */ + func appending(_ dictionary: Self) -> Self { + var newDictionary = self + + for (key, value) in dictionary { + newDictionary[key] = value + } + + return newDictionary + } +} + + +extension Error { + var isNsError: Bool { Self.self is NSError.Type } +} + +extension NSError { + static func from(error: Error, userInfo: [String: Any] = [:]) -> NSError { + let nsError = error as NSError + + // Since Error and NSError are often bridged between each other, we check if it was originally an NSError and then return that. + guard !error.isNsError else { + guard !userInfo.isEmpty else { + return nsError + } + + return nsError.appending(userInfo: userInfo) + } + + var userInfo = userInfo + userInfo[NSLocalizedDescriptionKey] = error.localizedDescription + + // Awful, but no better way to get the enum case name. + // This gets `Error.generateFrameFailed` from `Error.generateFrameFailed(Error Domain=AVFoundationErrorDomain Code=-11832 […]`. + let errorName = "\(error)".split(separator: "(").first ?? "" + + return .init( + domain: "\(SSApp.idString) - \(nsError.domain)\(errorName.isEmpty ? "" : ".")\(errorName)", + code: nsError.code, + userInfo: userInfo + ) + } + + /** + Returns a new error with the user info appended. + */ + func appending(userInfo newUserInfo: [String: Any]) -> NSError { + // Cannot use `Self` here: https://github.com/apple/swift/issues/58046 + NSError( + domain: domain, + code: code, + userInfo: userInfo.appending(newUserInfo) + ) + } +} + + +extension SSApp { + /** + Report an error to the chosen crash reporting solution. + */ + @inlinable + static func reportError( + _ error: Error, + userInfo: [String: Any] = [:], + file: String = #fileID, + line: Int = #line + ) { + guard !(error is CancellationError) else { + #if DEBUG + print("[\(file):\(line)] CancellationError:", error) + #endif + return + } + + let userInfo = userInfo + .appending([ + "file": file, + "line": line + ]) + + let error = NSError.from( + error: error, + userInfo: userInfo + ) + + #if DEBUG + print("[\(file):\(line)] Reporting error:", error) + #endif + + #if canImport(Sentry) + SentrySDK.capture(error: error) + #endif + } + + /** + Report an error message to the chosen crash reporting solution. + */ + @inlinable + static func reportError( + _ message: String, + userInfo: [String: Any] = [:], + file: String = #fileID, + line: Int = #line + ) { + reportError( + NSError.appError(message), + file: file, + line: line + ) + } +} + + +struct UnexpectedNilError: LocalizedError { + let message: String? + let file: String + let line: Int + + init( + _ message: String?, + file: String = #fileID, + line: Int = #line + ) { + self.message = message + self.file = file + self.line = line + + SSApp.reportError( + self, + userInfo: [ + "message": message ?? "", + "file": file, + "line": line + ] + ) + } + + var errorDescription: String { + message ?? failureReason + } + + var failureReason: String { + "Unexpected nil encountered at \(file):\(line)" + } +} + + +extension CGImage { + static func from(_ url: URL, maxSize: Double?) throws -> CGImage { + let sourceOptions: [CFString: Any] = [ + kCGImageSourceShouldCache: false + ] + + var thumbnailOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true + ] + + if let maxSize = maxSize { + thumbnailOptions[kCGImageSourceThumbnailMaxPixelSize] = maxSize + } + + guard + let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions as CFDictionary), + let image = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions as CFDictionary) + else { + throw NSError.appError("Failed to load image.") + } + + return image + } +} + +extension UIImage { + static func from(_ url: URL, maxSize: Double?) throws -> Self { + let cgImage = try CGImage.from(url, maxSize: maxSize) + return Self(cgImage: cgImage) + } +} + + +extension URL { + static func uniqueTemporaryPath() -> Self { + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + } + + private static func createdTemporaryDirectory() throws -> Self { + let url = uniqueTemporaryPath() + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + static func uniqueTemporaryDirectory(appropriateFor: Self? = nil) throws -> Self { + let url = { + // `Bundle.main.bundleURL` does not work when an iOS app is running on an Apple silicon Mac. (macOS 12.1) + #if canImport(AppKit) + Bundle.current.bundleURL + #elseif canImport(UIKit) + FileManager.default.temporaryDirectory + #endif + } + + do { + return try FileManager.default.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + // Note: Using `URL.rootDirectory` or `nil` here causes an permission error when running in an app extension on iOS. (iOS 15.1) + appropriateFor: appropriateFor ?? url(), + create: true + ) + } catch { + return try createdTemporaryDirectory() + } + } + + /** + Copy the file at the current URL to a unique temporary directory and return the new URL. + */ + func copyToUniqueTemporaryDirectory(filename: String? = nil) throws -> Self { + // We intentionally do not use `Self.uniqueTemporaryDirectory(appropriateFor: self)` as the source URL might be transient. It's better to be safe and copy to a global temporary directory. + let destinationUrl = try Self.uniqueTemporaryDirectory() + .appendingPathComponent(filename ?? lastPathComponent, isDirectory: false) + + try FileManager.default.copyItem(at: self, to: destinationUrl) + + return destinationUrl + } +} + + +extension NSItemProvider { + /** + Load a file from the item provider. + + The returned file resides in a temporary directory and is yours and you can move or modify it as you please. Don't forget to remove it when you are done with it. + */ + func loadFileRepresentation(for type: UTType) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + _ = loadFileRepresentation(forTypeIdentifier: type.identifier) { url, error in + if let error = error { + continuation.resume(throwing: error) + return + } + + guard let url = url else { + // This should in theory not happen. + continuation.resume(throwing: UnexpectedNilError("Expected NSItemProvider#loadFileRepresentation to return either an error or URL. It returned neither.")) + return + } + + let newURL: URL + do { + newURL = try url.copyToUniqueTemporaryDirectory() + } catch { + continuation.resume(throwing: error) + return + } + + continuation.resume(returning: newURL) + } + } + } +} + + +extension NSItemProvider { + /** + Get the image from an item provider. + */ + func getImage(maxSize: Double? = nil) async throws -> UIImage { + let url = try await loadFileRepresentation(for: .image) + return try UIImage.from(url, maxSize: maxSize) + } } -/// Let the user pick photos and videos from their library. +/** +Let the user pick photos and videos from their library. +*/ struct PhotoVideoPicker: UIViewControllerRepresentable { final class Coordinator: PHPickerViewControllerDelegate { private let parent: PhotoVideoPicker @@ -499,20 +1005,20 @@ struct PhotoVideoPicker: UIViewControllerRepresentable { // This is important as otherwise it causes weird problems like `@State` not updating. (iOS 14) picker.dismiss(animated: true) - parent.presentationMode.wrappedValue.dismiss() + parent.dismiss() // Give the sheet time to close. DispatchQueue.main.async { [self] in - parent.onPick(results) + parent.onCompletion(results) } } } - @Environment(\.presentationMode) private var presentationMode + @Environment(\.dismiss) private var dismiss var filter: PHPickerFilter var selectionLimit = 1 - let onPick: ([PHPickerResult]) -> Void + let onCompletion: ([PHPickerResult]) -> Void func makeCoordinator() -> Coordinator { .init(self) } @@ -530,28 +1036,41 @@ struct PhotoVideoPicker: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} } +/** +Let the user pick a single photo from their library. -/// Let the user pick a single photo from their library. +- Note: If the user cancels the operation, `isPresented` will be set to `false` and `onCompletion` will not be called. +*/ struct SinglePhotoPicker: View { - var onPick: (UIImage?) -> Void + var maxSize: Double? + var onCompletion: (Result) -> Void var body: some View { PhotoVideoPicker(filter: .images) { results in - guard - let itemProvider = results.first?.itemProvider, - itemProvider.canLoadObject(ofClass: UIImage.self) - else { - onPick(nil) - return - } - - itemProvider.loadObject(ofClass: UIImage.self) { image, _ in - guard let image = image as? UIImage else { - onPick(nil) + Task { + guard let itemProvider = results.first?.itemProvider else { return } - onPick(image) + do { + guard itemProvider.hasItemConforming(to: .image) else { + throw NSError.appError("The image format “\(itemProvider.registeredTypeIdentifiers.first ?? "")” is not supported") + } + + let image = try await itemProvider.getImage(maxSize: maxSize) + onCompletion(.success(image)) + } catch { + SSApp.reportError( + error, + userInfo: [ + "registeredTypeIdentifiers": itemProvider.registeredTypeIdentifiers, + "canLoadObject(UIImage)": itemProvider.canLoadObject(ofClass: UIImage.self), + "underlyingErrors": (error as NSError).underlyingErrors + ] + ) // TODO: Remove at some point. + + onCompletion(.failure(error)) + } } } } @@ -559,43 +1078,38 @@ struct SinglePhotoPicker: View { struct SinglePhotoPickerButton: View { - @State private var isShowingPhotoPicker = false + @State private var isPresented = false + var maxSize: Double? var iconName = "photo" - var onImage: (UIImage) -> Void + var onCompletion: (Result) -> Void var body: some View { Button { - isShowingPhotoPicker = true + isPresented = true } label: { Image(systemName: iconName) } - .sheet(isPresented: $isShowingPhotoPicker) { - SinglePhotoPicker { - guard let image = $0 else { - return - } - - onImage(image) - } + .sheet(isPresented: $isPresented) { + SinglePhotoPicker(maxSize: maxSize, onCompletion: onCompletion) .ignoresSafeArea() } } } - extension UIDevice { - fileprivate static let _didShakePublisher = PassthroughSubject() + // TODO: Find out a way to do this without Combine. + fileprivate static let didShakeSubject = PassthroughSubject() - var didShakePublisher: AnyPublisher { - Self._didShakePublisher.eraseToAnyPublisher() + var didShake: AnyAsyncSequence { + Self.didShakeSubject.eraseToAnySequence() } } extension UIWindow { override open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { if motion == .motionShake { - UIDevice._didShakePublisher.send() + UIDevice.didShakeSubject.send() } super.motionEnded(motion, with: event) @@ -603,19 +1117,23 @@ extension UIWindow { } private struct DeviceShakeViewModifier: ViewModifier { - let action: (() -> Void) + let action: () -> Void func body(content: Content) -> some View { content .onAppear() // Shake doesn't work without this. (iOS 14.5) - .onReceive(UIDevice.current.didShakePublisher) { _ in - action() + .task { + for await _ in UIDevice.current.didShake { + action() + } } } } extension View { - /// Perform sn ction when the device is shaked. + /** + Perform an action when the device is shaked. + */ func onDeviceShake(perform action: @escaping (() -> Void)) -> some View { modifier(DeviceShakeViewModifier(action: action)) } @@ -623,7 +1141,7 @@ extension View { extension CGSize { - var longestSide: CGFloat { max(width, height) } + var longestSide: Double { max(width, height) } } @@ -734,8 +1252,11 @@ struct RateOnAppStoreButton: View { typealias QueryDictionary = [String: String] extension CharacterSet { - /// Characters allowed to be unescaped in an URL - /// https://tools.ietf.org/html/rfc3986#section-2.3 + /** + Characters allowed to be unescaped in an URL. + + https://tools.ietf.org/html/rfc3986#section-2.3 + */ static let urlUnreservedRFC3986 = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~") } @@ -744,7 +1265,9 @@ private func escapeQueryComponent(_ query: String) -> String { } extension Dictionary where Key == String { - /// This correctly escapes items. See `escapeQueryComponent`. + /** + This correctly escapes items. See `escapeQueryComponent`. + */ var toQueryItems: [URLQueryItem] { map { URLQueryItem( @@ -756,19 +1279,23 @@ extension Dictionary where Key == String { } extension URLComponents { - /// This correctly escapes items. See `escapeQueryComponent`. + /** + This correctly escapes items. See `escapeQueryComponent`. + */ init?(string: String, query: QueryDictionary) { self.init(string: string) self.queryDictionary = query } - /// This correctly escapes items. See `escapeQueryComponent`. + /** + This correctly escapes items. See `escapeQueryComponent`. + */ var queryDictionary: QueryDictionary { get { queryItems?.toDictionary { ($0.name, $0.value) }.compactValues() ?? [:] } set { - /// Using `percentEncodedQueryItems` instead of `queryItems` since the query items are already custom-escaped. See `escapeQueryComponent`. + // Using `percentEncodedQueryItems` instead of `queryItems` since the query items are already custom-escaped. See `escapeQueryComponent`. percentEncodedQueryItems = newValue.toQueryItems } } @@ -788,7 +1315,7 @@ extension URL { extension Dictionary { func compactValues() -> [Key: T] where Value == T? { - // TODO: Make this `compactMapValues(\.self)` when https://bugs.swift.org/browse/SR-12897 is fixed. + // TODO: Make this `compactMapValues(\.self)` when https://github.com/apple/swift/issues/55343 is fixed. compactMapValues { $0 } } } @@ -851,8 +1378,7 @@ You can use it for storing both value and reference types. */ @propertyWrapper struct ViewStorage: DynamicProperty { - private final class ValueBox: ObservableObject { - let objectWillChange = Empty(completeImmediately: false) + private final class ValueBox { var value: Value init(_ value: Value) { @@ -860,7 +1386,7 @@ struct ViewStorage: DynamicProperty { } } - @StateObject private var valueBox: ValueBox + @State private var valueBox: ValueBox var wrappedValue: Value { get { valueBox.value } @@ -869,8 +1395,17 @@ struct ViewStorage: DynamicProperty { } } + var projectedValue: Binding { + .init( + get: { wrappedValue }, + set: { + wrappedValue = $0 + } + ) + } + init(wrappedValue value: @autoclosure @escaping () -> Value) { - self._valueBox = StateObject(wrappedValue: .init(value())) + self._valueBox = .init(wrappedValue: ValueBox(value())) } } @@ -886,13 +1421,20 @@ private struct AccessNativeView: UIViewRepresentable { } extension View { - /// Access the native view hierarchy from SwiftUI. - /// - Important: Don't assume the view is in the view hierarchy on the first callback invocation. + /** + Access the native view hierarchy from SwiftUI. + + - Important: Don't assume the view is in the view hierarchy on the first callback invocation. + */ func accessNativeView(_ callback: @escaping (UIView?) -> Void) -> some View { - background(AccessNativeView(callback: callback)) + background { + AccessNativeView(callback: callback) + } } - /// Access the window the view is contained in if any. + /** + Access the window the view is contained in if any. + */ func accessNativeWindow(_ callback: @escaping (UIWindow?) -> Void) -> some View { accessNativeView { uiView in guard let window = uiView?.window else { @@ -902,6 +1444,15 @@ extension View { callback(window) } } + + /** + Bind the native backing-window of a SwiftUI window to a property. + */ + func bindNativeWindow(_ window: Binding) -> some View { + accessNativeWindow { + window.wrappedValue = $0 + } + } } @@ -932,6 +1483,61 @@ extension Binding { } +/** +Useful in SwiftUI: + +``` +ForEach(persons.indexed(), id: \.1.id) { index, person in + // … +} +``` +*/ +struct IndexedCollection: RandomAccessCollection { + typealias Index = Base.Index + typealias Element = (index: Index, element: Base.Element) + + let base: Base + var startIndex: Index { base.startIndex } + var endIndex: Index { base.endIndex } + + func index(after index: Index) -> Index { + base.index(after: index) + } + + func index(before index: Index) -> Index { + base.index(before: index) + } + + func index(_ index: Index, offsetBy distance: Int) -> Index { + base.index(index, offsetBy: distance) + } + + subscript(position: Index) -> Element { + (index: position, element: base[position]) + } +} + +extension RandomAccessCollection { + /** + Returns a sequence with a tuple of both the index and the element. + */ + func indexed() -> IndexedCollection { + IndexedCollection(base: self) + } +} + + +extension Sequence { + /** + Returns an array containing the non-nil elements. + */ + func compact() -> [T] where Element == T? { + // TODO: Make this `compactMap(\.self)` when https://github.com/apple/swift/issues/55343 is fixed. + compactMap { $0 } + } +} + + extension NSError: Identifiable { public var id: String { "\(code)" + domain + localizedDescription + (localizedRecoverySuggestion ?? "") @@ -939,67 +1545,58 @@ extension NSError: Identifiable { } -extension View { +extension NSError { /** - Present an error as an alert. + Use this for the second line in an alert. + */ + var localizedSecondaryDescription: String? { + // The correct way to make a `LocalizedError` is to include the failure reason in the localized description too, but some errors do not correctly do this, so we try to get the failure reason if it's not part of the localized description. + if + let failureReason = localizedFailureReason, + !localizedDescription.contains(failureReason) + { + return [ + failureReason, + localizedRecoverySuggestion + ] + .compact() + .joined(separator: "\n\n") + } - If you set it multiple times, the alert will only change if the error is different. + return localizedRecoverySuggestion + } +} - ``` - struct ContentView: View { - @State private var convertError: Error? - - var body: some View { - VStack { - Button("Convert") { - do { - try convert() - } catch { - convertError = error + +extension View { + func alert(error: Binding) -> some View { + alert2( + title: { ($0 as NSError).localizedDescription }, + message: { ($0 as NSError).localizedSecondaryDescription }, + presenting: error + ) { + let nsError = $0 as NSError + if + let options = nsError.localizedRecoveryOptions, + let recoveryAttempter = nsError.recoveryAttempter + { + // Alert only supports 3 buttons, so we limit it to 2 attempters, otherwise it would take over the cancel button. + ForEach(options.prefix(2).indexed(), id: \.0) { index, option in + Button(option) { + // We use the old NSError mechanism for recovery attempt as recoverable NSError's are not bridged to RecoverableError. + _ = (recoveryAttempter as AnyObject).attemptRecovery(fromError: nsError, optionIndex: index) } } + Button("Cancel", role: .cancel) {} } - .alert(error: $convertError) } } - ``` - */ - func alert(error: Binding) -> some View { - background( - EmptyView().alert(item: error.map( - get: { $0 as NSError? }, - set: { $0 as Error? } - )) { nsError in - if - let options = nsError.localizedRecoveryOptions, - let firstOption = options.first, - let recoveryAttempter = nsError.recoveryAttempter - { - // There could be multiple recovery options, but we can only support one as `Alert` in SwiftUI can only add one extra button. - return Alert( - title: Text(nsError.localizedDescription), - message: nsError.localizedRecoverySuggestion.map { Text($0) }, - primaryButton: .default(Text(firstOption)) { - _ = (recoveryAttempter as AnyObject).attemptRecovery(fromError: nsError, optionIndex: 0) - }, - secondaryButton: .cancel() - ) - } - - return Alert( - title: Text(nsError.localizedDescription), - // Note that we don't also use `localizedFailureReason` as the `NSError#localizedDescription` docs says it's already being included there. - message: nsError.localizedRecoverySuggestion.map { Text($0) } - ) - } - ) - } } extension Sequence where Element: Sequence { func flatten() -> [Element.Element] { - // TODO: Make this `flatMap(\.self)` when https://bugs.swift.org/browse/SR-12897 is fixed. + // TODO: Make this `flatMap(\.self)` when https://github.com/apple/swift/issues/55343 is fixed. flatMap { $0 } } } @@ -1013,7 +1610,9 @@ extension NSExtensionContext { extension Collection { - /// Returns the element at the specified index if it is within bounds, otherwise nil. + /** + Returns the element at the specified index if it is within bounds, otherwise `nil`. + */ subscript(safe index: Index) -> Element? { indices.contains(index) ? self[index] : nil } @@ -1024,19 +1623,35 @@ extension Collection { extension SSApp { private static var settingsUrl = URL(string: UIApplication.openSettingsURLString)! - /// Whether the settings view in Settings for the current app exists and can be opened. + /** + Whether the settings view in Settings for the current app exists and can be opened. + */ static var canOpenSettings = UIApplication.shared.canOpenURL(settingsUrl) - /// Open the settings view in Settings for the current app. - /// - Important: Ensure you use `.canOpenSettings`. + /** + Open the settings view in Settings for the current app. + + - Important: Ensure you use `.canOpenSettings`. + */ static func openSettings() { - UIApplication.shared.open(settingsUrl) { _ in } + Task.detached { @MainActor in + guard await UIApplication.shared.open(settingsUrl) else { + // TODO: Present the error + _ = NSError.appError("Failed to open settings for this app.") + + // TODO: Remove at some point. + SSApp.reportError("Failed to open settings for this app.") + return + } + } } } extension UIView { - /// The highest ancestor superview. + /** + The highest ancestor superview. + */ var highestAncestor: UIView? { var ancestor = superview @@ -1054,7 +1669,9 @@ extension EnvironmentValues { static var defaultValue: NSExtensionContext? } - /// The `.extensionContext` of an app extension view controller. + /** + The `.extensionContext` of an app extension view controller. + */ var extensionContext: NSExtensionContext? { get { self[ExtensionContext.self] } set { @@ -1074,19 +1691,17 @@ extension NSExtensionContext { // Strongly-typed versions of some of the methods. extension NSItemProvider { - func hasItemConformingTo(_ type: UTType) -> Bool { - hasItemConformingToTypeIdentifier(type.identifier) + func hasItemConforming(to contentType: UTType) -> Bool { + hasItemConformingToTypeIdentifier(contentType.identifier) } func loadItem( - forType type: UTType, - options: [AnyHashable: Any]? = nil, // swiftlint:disable:this discouraged_optional_collection - completionHandler: NSItemProvider.CompletionHandler? = nil - ) { - loadItem( - forTypeIdentifier: type.identifier, - options: options, - completionHandler: completionHandler + for contentType: UTType, + options: [AnyHashable: Any]? = nil // swiftlint:disable:this discouraged_optional_collection + ) async throws -> NSSecureCoding? { + try await loadItem( + forTypeIdentifier: contentType.identifier, + options: options ) } } @@ -1115,7 +1730,9 @@ extension SSApp { return true } - /// Run a closure only once ever, even between relaunches of the app. + /** + Run a closure only once ever, even between relaunches of the app. + */ static func runOnce(identifier: String, _ execute: () -> Void) { guard runOnceShouldRun(identifier: identifier) else { return @@ -1124,3 +1741,288 @@ extension SSApp { execute() } } + + +extension View { + @warn_unqualified_access + func debugAction(_ closure: () -> Void) -> Self { + //#if DEBUG + closure() + //#endif + + return self + } +} + +extension View { + /** + Print without inconvenience. + + ``` + VStack { + Text("Unicorns") + .debugPrint("Something") + } + ``` + */ + @warn_unqualified_access + func debugPrint(_ items: Any..., separator: String = " ") -> Self { + self.debugAction { + let item = items.map { "\($0)" }.joined(separator: separator) + Swift.print(item) + } + } +} + + +extension Numeric { + mutating func increment(by value: Self = 1) -> Self { + self += value + return self + } + + mutating func decrement(by value: Self = 1) -> Self { + self -= value + return self + } + + func incremented(by value: Self = 1) -> Self { + self + value + } + + func decremented(by value: Self = 1) -> Self { + self - value + } +} + + +extension Sequence { + /** + Returns the first non-`nil` result obtained from applying the given. + */ + public func firstNonNil( + _ transform: (Element) throws -> Result? + ) rethrows -> Result? { + for value in self { + if let value = try transform(value) { + return value + } + } + return nil + } +} + + +extension SSApp { + @MainActor + static var currentScene: UIWindowScene? { + #if !APP_EXTENSION + return UIApplication.shared // swiftlint:disable:this first_where + .connectedScenes + .filter { $0.activationState == .foregroundActive } + .firstNonNil { $0 as? UIWindowScene } + // If it's called early on in the launch, the scene might not be active yet, so we fall back to the inactive state. + ?? UIApplication.shared // swiftlint:disable:this first_where + .connectedScenes + .filter { $0.activationState == .foregroundInactive } + .firstNonNil { $0 as? UIWindowScene } + #else + return nil + #endif + } +} + + +extension SSApp { + private static let key = Defaults.Key("SSApp_requestReview", default: 0) + + /** + Requests a review only after this method has been called the given amount of times. + */ + @MainActor + static func requestReviewAfterBeingCalledThisManyTimes(_ counts: [Int]) { + guard + !SSApp.isFirstLaunch, + counts.contains(Defaults[key].increment()) + else { + return + } + + Task { @MainActor in + if let scene = currentScene { + SKStoreReviewController.requestReview(in: scene) + } + } + } +} + + +extension Task where Success == Never, Failure == Never { + public static func sleep(seconds: TimeInterval) async throws { + try await sleep(nanoseconds: UInt64(seconds * Double(NSEC_PER_SEC))) + } +} + + +struct AnyAsyncSequence: AsyncSequence { + typealias AsyncIterator = AnyAsyncIterator + + struct AnyAsyncIterator: AsyncIteratorProtocol { + private let _next: () async -> Element? + + init(_ asyncIterator: I) where I.Element == Element { + var asyncIterator = asyncIterator + self._next = { + do { + return try await asyncIterator.next() + } catch { + assertionFailure("AnyAsyncSequence should not throw.") + return nil + } + } + } + + mutating func next() async -> Element? { + await _next() + } + } + + private let _makeAsyncIterator: AsyncIterator + + init(_ asyncSequence: S) where S.AsyncIterator.Element == AsyncIterator.Element { + self._makeAsyncIterator = AnyAsyncIterator(asyncSequence.makeAsyncIterator()) + } + + func makeAsyncIterator() -> AsyncIterator { + _makeAsyncIterator + } +} + +extension AsyncSequence { + /** + - Important: Only use this on non-throwing async sequences! + */ + func eraseToAnyAsyncSequence() -> AnyAsyncSequence { + AnyAsyncSequence(self) + } +} + +struct AnyThrowingAsyncSequence: AsyncSequence { + typealias AsyncIterator = AnyAsyncIterator + + struct AnyAsyncIterator: AsyncIteratorProtocol { + private let _next: () async throws -> Element? + + init(_ asyncIterator: I) where I.Element == Element { + var asyncIterator = asyncIterator + self._next = { + try await asyncIterator.next() + } + } + + mutating func next() async throws -> Element? { + try await _next() + } + } + + private let _makeAsyncIterator: AsyncIterator + + init(_ asyncSequence: S) where S.AsyncIterator.Element == AsyncIterator.Element { + self._makeAsyncIterator = AnyAsyncIterator(asyncSequence.makeAsyncIterator()) + } + + func makeAsyncIterator() -> AsyncIterator { + _makeAsyncIterator + } +} + +extension AsyncSequence { + func eraseToAnyThrowingAsyncSequence() -> AnyThrowingAsyncSequence { + AnyThrowingAsyncSequence(self) + } +} + +extension AsyncStream { + @available(*, unavailable) + func eraseToAnyThrowingAsyncSequence() -> AnyThrowingAsyncSequence { + fatalError() // swiftlint:disable:this fatal_error_message + } +} + +extension AsyncThrowingStream { + @available(*, unavailable) + func eraseToAnyAsyncSequence() -> AnyAsyncSequence { + fatalError() // swiftlint:disable:this fatal_error_message + } +} + +extension Publisher { + func eraseToAnySequence() -> AnyAsyncSequence where Output == Element, Failure == Never { + AnyAsyncSequence(values) + } + + func eraseToAnySequence() -> AnyThrowingAsyncSequence where Output == Element { + AnyThrowingAsyncSequence(values) + } +} + + +extension Shape where Self == Rectangle { + static var rectangle: Self { Self() } +} + + +func tryOrAssign( + _ errorBinding: Binding, + doClosure: () throws -> T? +) -> T? { + do { + return try doClosure() + } catch { + errorBinding.wrappedValue = error + return nil + } +} + +func tryOrAssign( + _ errorBinding: Binding, + doClosure: () async throws -> T? +) async -> T? { + do { + return try await doClosure() + } catch { + errorBinding.wrappedValue = error + return nil + } +} + +extension View { + func taskOrAssign( + _ errorBinding: Binding, + priority: TaskPriority = .userInitiated, + _ action: @escaping @Sendable () async throws -> Void + ) -> some View { + task(priority: priority) { + await tryOrAssign(errorBinding) { + try await action() + } + } + } +} + + +extension Button where Label == SwiftUI.Label { + init( + _ title: String, + systemImage: String, + role: ButtonRole? = nil, + action: @escaping () -> Void + ) { + self.init( + role: role, + action: action + ) { + Label(title, systemImage: systemImage) + } + } +} diff --git a/Config.xcconfig b/Config.xcconfig new file mode 100644 index 0000000..36e9bef --- /dev/null +++ b/Config.xcconfig @@ -0,0 +1,2 @@ +MARKETING_VERSION = 2.1.1 +CURRENT_PROJECT_VERSION = 18 diff --git a/Shared.xcconfig b/Shared.xcconfig deleted file mode 100644 index f684a6a..0000000 --- a/Shared.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -MARKETING_VERSION = 2.1.0 -CURRENT_PROJECT_VERSION = 17 diff --git a/readme.md b/readme.md index 48bfb2e..1d9a350 100644 --- a/readme.md +++ b/readme.md @@ -15,4 +15,4 @@ ## Info -Requires minimum iOS 14. +Requires minimum iOS 15.