diff --git a/Demo/Demo/Gravatar-UIKit-Demo/DemoQuickEditorViewController.swift b/Demo/Demo/Gravatar-UIKit-Demo/DemoQuickEditorViewController.swift new file mode 100644 index 00000000..62677f32 --- /dev/null +++ b/Demo/Demo/Gravatar-UIKit-Demo/DemoQuickEditorViewController.swift @@ -0,0 +1,191 @@ +import UIKit +import GravatarUI + +final class DemoQuickEditorViewController: UIViewController { + var savedEmail: String? { + get { + UserDefaults.standard.string(forKey: "QEEmailKey") + } + set { + UserDefaults.standard.setValue(newValue, forKey: "QEEmailKey") + } + } + + var savedToken: String? { + get { + UserDefaults.standard.string(forKey: "QETokenKey") + } + set { + UserDefaults.standard.setValue(newValue, forKey: "QETokenKey") + } + } + + lazy var emailField: UITextField = { + let field = UITextField() + field.translatesAutoresizingMaskIntoConstraints = false + field.textContentType = .emailAddress + field.keyboardType = .emailAddress + field.autocapitalizationType = .none + field.borderStyle = .roundedRect + field.placeholder = "email" + field.delegate = self + field.text = savedEmail + return field + }() + + lazy var tokenField: UITextField = { + let field = UITextField() + let showButton = UIButton(type: .custom, primaryAction: UIAction { action in + field.isSecureTextEntry = !field.isSecureTextEntry + (action.sender as? UIButton)?.isSelected = !field.isSecureTextEntry + + }) + showButton.tintColor = .systemGray + showButton.setImage(UIImage(systemName: "eye"), for: .normal) + showButton.setImage(UIImage(systemName: "eye.slash"), for: .selected) + + field.rightView = showButton + field.rightViewMode = .always + + field.translatesAutoresizingMaskIntoConstraints = false + field.isSecureTextEntry = true + + field.autocapitalizationType = .none + field.borderStyle = .roundedRect + field.placeholder = "Token (optional)" + field.text = savedToken + field.delegate = self + return field + }() + + var token: String? { + guard let token = tokenField.text, !token.isEmpty else { return nil } + savedToken = token + return token + } + + var selectedLayout: QELayoutOptions = .horizontal { + didSet { + layoutButton.setTitle(selectedLayout.rawValue, for: .normal) + } + } + + lazy var layoutButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle("Layout: \(selectedLayout.rawValue)", for: .normal) + button.addTarget(self, action: #selector(presentLayoutOptions), for: .touchUpInside) + return button + }() + + @objc func presentLayoutOptions() { + let sheet = UIAlertController(title: "Layout Options", message: nil, preferredStyle: .actionSheet) + QELayoutOptions.allCases.forEach { layout in + sheet.addAction(.init(title: layout.rawValue, style: .default) { _ in + self.selectedLayout = layout + }) + } + present(sheet, animated: true) + } + + lazy var logoutButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle("Logout", for: .normal) + button.addAction(UIAction { [weak self] _ in self?.logout() }, for: .touchUpInside) + updateLogoutButton(button) + return button + }() + + func updateLogoutButton(_ button: UIButton? = nil) { + guard let savedEmail else { return } + let session = OAuthSession() + let button = button ?? logoutButton + UIView.animate { + button.isHidden = !session.hasSession(with: Email(savedEmail)) + button.alpha = button.isHidden ? 0 : 1 + } + } + + func logout() { + guard let savedEmail else { return } + let session = OAuthSession() + session.deleteSession(with: Email(savedEmail)) + updateLogoutButton() + } + + lazy var showButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle("Show Quick Editor", for: .normal) + button.addAction(UIAction { [weak self] _ in self?.presentQuickEditor() }, for: .touchUpInside) + button.isEnabled = savedEmail != nil + return button + }() + + lazy var rootStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [emailField, tokenField, layoutButton, logoutButton, showButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 12 + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = .init(top: 0, left: 24, bottom: 0, right: 24) + return stackView + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + view.addSubview(rootStackView) + NSLayoutConstraint.activate([ + rootStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + rootStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + rootStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + ]) + } + + func presentQuickEditor() { + guard let email = emailField.text else { return } + savedEmail = email + let presenter = QuickEditorPresenter( + email: Email(email), + scope: .avatarPicker, + avatarPickerConfiguration: AvatarPickerConfiguration(contentLayout: selectedLayout.contentLayout), + token: token + ) + + presenter.present(in: self, onDismiss: { [weak self] in + self?.updateLogoutButton() + }) + } +} + +extension DemoQuickEditorViewController: UITextFieldDelegate { + func textFieldDidChangeSelection(_ textField: UITextField) { + if textField == emailField { + showButton.isEnabled = Email(textField.text ?? "").isValid + } + } + + func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) { + if textField == tokenField { + savedToken = textField.text + } + } +} + +extension Email { + // This validation is not perfect, but it's intended for demo purposes only. + public var isValid: Bool { + let string = rawValue + guard string.count <= 254 else { + return false + } + let atIndex = string.lastIndex(of: "@") ?? string.endIndex + let dotIndex = string.lastIndex(of: ".") ?? string.endIndex + return (atIndex != string.startIndex) + && (dotIndex > atIndex) + && (string[atIndex...].count > 4) + && (string[dotIndex...].count > 2) + } +} diff --git a/Demo/Demo/Gravatar-UIKit-Demo/MainTableViewController.swift b/Demo/Demo/Gravatar-UIKit-Demo/MainTableViewController.swift index f9bd168f..0e0e5754 100644 --- a/Demo/Demo/Gravatar-UIKit-Demo/MainTableViewController.swift +++ b/Demo/Demo/Gravatar-UIKit-Demo/MainTableViewController.swift @@ -18,6 +18,7 @@ class MainTableViewController: UITableViewController { case profileCard case configuration case profileViewController + case quickEditor #if DEBUG case displayRemoteSVG case imageCropper @@ -55,6 +56,8 @@ class MainTableViewController: UITableViewController { content.text = "Profile Card Configuration" case .profileViewController: content.text = "Profile View Controller" + case .quickEditor: + content.text = "Quick Editor" #if DEBUG case .displayRemoteSVG: content.text = "Display remote SVG" @@ -87,6 +90,8 @@ class MainTableViewController: UITableViewController { show(DemoProfileConfigurationViewController(style: .insetGrouped), sender: nil) case .profileViewController: navigationController?.pushViewController(DemoProfilePresentationStylesViewController(), animated: true) + case .quickEditor: + navigationController?.pushViewController(DemoQuickEditorViewController(), animated: true) #if DEBUG case .displayRemoteSVG: navigationController?.pushViewController(DemoRemoteSVGViewController(), animated: true) diff --git a/Demo/Gravatar-Demo.xcodeproj/project.pbxproj b/Demo/Gravatar-Demo.xcodeproj/project.pbxproj index d7145605..533f55e7 100644 --- a/Demo/Gravatar-Demo.xcodeproj/project.pbxproj +++ b/Demo/Gravatar-Demo.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 1E3FA2402C74B8CC002901F2 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3FA23F2C74B8CC002901F2 /* Secrets.swift */; }; 1E3FA2412C74E539002901F2 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3FA23F2C74B8CC002901F2 /* Secrets.swift */; }; 1E3FA2452C75E403002901F2 /* DemoProfileEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3FA2442C75E403002901F2 /* DemoProfileEditorView.swift */; }; + 1EA26DE22CA5677700AACF58 /* DemoQuickEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E93D3C82CA566D90085139A /* DemoQuickEditorViewController.swift */; }; 1ECAB5072BC984440043A331 /* DemoProfileConfigurationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ECAB5062BC984440043A331 /* DemoProfileConfigurationViewController.swift */; }; 49018E2D2C8110C600B1207D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 49018E2B2C8110C600B1207D /* Localizable.strings */; }; 49018E2E2C8110C600B1207D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 49018E2B2C8110C600B1207D /* Localizable.strings */; }; @@ -37,6 +38,7 @@ 917DEEC92C7F639F00E43774 /* TestImageCropper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 917DEEC82C7F619100E43774 /* TestImageCropper.swift */; }; 91956A522B6793AF00BF3CF0 /* SwitchWithLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91956A512B6793AF00BF3CF0 /* SwitchWithLabel.swift */; }; 91956A542B67943A00BF3CF0 /* DemoUIImageViewExtensionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91956A532B67943A00BF3CF0 /* DemoUIImageViewExtensionViewController.swift */; }; + 919C70552CAA8A090036B03C /* QELayoutOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9133DF1A2CA54D1B0028196F /* QELayoutOptions.swift */; }; 919C70572CAAF28E0036B03C /* QEColorSchemePickerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 919C70562CAAF28E0036B03C /* QEColorSchemePickerRow.swift */; }; 91B73B372C404F6E00E7D325 /* DemoAvatarPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91B73B362C404F6E00E7D325 /* DemoAvatarPickerView.swift */; }; 91E2FB042BC0276E00265E8E /* DemoProfileViewsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91E2FB032BC0276E00265E8E /* DemoProfileViewsViewController.swift */; }; @@ -51,6 +53,7 @@ 1E3FA23D2C74B7B5002901F2 /* Secrets.tpl */ = {isa = PBXFileReference; lastKnownFileType = text; path = Secrets.tpl; sourceTree = ""; }; 1E3FA23F2C74B8CC002901F2 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; 1E3FA2442C75E403002901F2 /* DemoProfileEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoProfileEditorView.swift; sourceTree = ""; }; + 1E93D3C82CA566D90085139A /* DemoQuickEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoQuickEditorViewController.swift; sourceTree = ""; }; 1ECAB5062BC984440043A331 /* DemoProfileConfigurationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoProfileConfigurationViewController.swift; sourceTree = ""; }; 3FD4781E2C50D6FD0071B8B9 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = ""; }; 3FD478212C51D5CE0071B8B9 /* Gravatar-UIKit-Demo.Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Gravatar-UIKit-Demo.Release.xcconfig"; sourceTree = ""; }; @@ -204,6 +207,7 @@ 914AC01F2BDAAC3C005DA4A5 /* DemoRemoteSVGViewController.swift */, 91F0B3DB2B62815F0025C4F8 /* MainTableViewController.swift */, 9133058D2C91F2C8009F5C0B /* DemoImageCropperViewController.swift */, + 1E93D3C82CA566D90085139A /* DemoQuickEditorViewController.swift */, 495775EA2B5B34980082812A /* Assets.xcassets */, 495775EC2B5B34980082812A /* LaunchScreen.storyboard */, 495775EF2B5B34980082812A /* Info.plist */, @@ -450,6 +454,7 @@ 1E0087932B63CFFE0012ECEA /* DemoFetchProfileViewController.swift in Sources */, 1E0087952B63DBCB0012ECEA /* DemoUploadImageViewController.swift in Sources */, 495775E22B5B34970082812A /* AppDelegate.swift in Sources */, + 1EA26DE22CA5677700AACF58 /* DemoQuickEditorViewController.swift in Sources */, 91956A522B6793AF00BF3CF0 /* SwitchWithLabel.swift in Sources */, 914AC01A2BD7FF08005DA4A5 /* DemoProfilePresentationStylesViewController.swift in Sources */, 91F0B3DE2B62815F0025C4F8 /* DemoAvatarDownloadViewController.swift in Sources */, @@ -457,6 +462,7 @@ 914AC0202BDAAC3C005DA4A5 /* DemoRemoteSVGViewController.swift in Sources */, 495775E42B5B34970082812A /* SceneDelegate.swift in Sources */, 1ECAB5072BC984440043A331 /* DemoProfileConfigurationViewController.swift in Sources */, + 919C70552CAA8A090036B03C /* QELayoutOptions.swift in Sources */, 91E2FB042BC0276E00265E8E /* DemoProfileViewsViewController.swift in Sources */, 9133058E2C91F8D1009F5C0B /* DemoImageCropperViewController.swift in Sources */, ); diff --git a/Demo/Gravatar-Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/Gravatar-Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 78bbb441..25fedc2e 100644 --- a/Demo/Gravatar-Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/Gravatar-Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "873e748e6cd0092c005bb2eeeb80dd830b1cffbb6a8974436ad78419281827e3", + "originHash" : "89aad130e119d7a4b1dea1ad42b23e52b29cf016999eed5d14af43b0dc276ac1", "pins" : [ { "identity" : "swift-snapshot-testing", diff --git a/Sources/GravatarUI/QuickEditor/QuickEditorViewController.swift b/Sources/GravatarUI/QuickEditor/QuickEditorViewController.swift new file mode 100644 index 00000000..3256c246 --- /dev/null +++ b/Sources/GravatarUI/QuickEditor/QuickEditorViewController.swift @@ -0,0 +1,189 @@ +import SwiftUI +import UIKit + +/// Configuration which will be applied to the avatar picker screen. +public struct AvatarPickerConfiguration: Sendable { + let contentLayout: AvatarPickerContentLayoutWithPresentation + + public init(contentLayout: AvatarPickerContentLayoutWithPresentation) { + self.contentLayout = contentLayout + } + + static let `default` = AvatarPickerConfiguration( + contentLayout: .horizontal(presentationStyle: .intrinsicHeight) + ) +} + +final class QuickEditorViewController: UIViewController, ModalPresentationWithIntrinsicSize { + let email: Email + let scope: QuickEditorScope + let token: String? + let avatarPickerConfiguration: AvatarPickerConfiguration + + var onDismiss: (() -> Void)? = nil + + private lazy var isPresented: Binding = Binding { + true + } set: { isPresented in + Task { @MainActor in + guard !isPresented else { return } + self.dismiss(animated: true) + self.onDismiss?() + } + } + + var verticalSizeClass: UserInterfaceSizeClass? + var sheetHeight: CGFloat = QEModalPresentationConstants.bottomSheetEstimatedHeight + var contentLayoutWithPresentation: AvatarPickerContentLayoutWithPresentation { + avatarPickerConfiguration.contentLayout + } + + private lazy var quickEditor: InnerHeightUIHostingController = .init(rootView: QuickEditor( + email: email, + scope: scope, + token: token, + isPresented: isPresented, + customImageEditor: nil as NoCustomEditorBlock?, + contentLayoutProvider: avatarPickerConfiguration.contentLayout + ), onHeightChange: { [weak self] newHeight in + guard let self else { return } + if self.shouldAcceptHeight(newHeight) { + self.sheetHeight = newHeight + } + self.updateDetents() + }, onVerticalSizeClassChange: { [weak self] verticalSizeClass in + guard let self, verticalSizeClass != nil else { return } + self.verticalSizeClass = verticalSizeClass + self.updateDetents() + }) + + init( + email: Email, + scope: QuickEditorScope, + avatarPickerConfiguration: AvatarPickerConfiguration? = nil, + token: String? = nil, + onDismiss: (() -> Void)? = nil + ) { + self.email = email + self.scope = scope + self.avatarPickerConfiguration = avatarPickerConfiguration ?? .default + self.token = token + self.onDismiss = onDismiss + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + quickEditor.willMove(toParent: self) + addChild(quickEditor) + view.addSubview(quickEditor.view) + quickEditor.didMove(toParent: self) + quickEditor.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + quickEditor.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + quickEditor.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + quickEditor.view.topAnchor.constraint(equalTo: view.topAnchor), + quickEditor.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + updateDetents() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if navigationController != nil { + assertionFailure("This View Controller should be presented modally, without wrapping it in a Navigation Controller.") + } + } + + func updateDetents() { + if let sheet = sheetPresentationController { + sheet.animateChanges { + sheet.detents = QEDetent.detents( + for: avatarPickerConfiguration.contentLayout, + intrinsicHeight: sheetHeight, + verticalSizeClass: verticalSizeClass + ).map() + } + sheet.prefersScrollingExpandsWhenScrolledToEdge = !avatarPickerConfiguration.contentLayout.prioritizeScrollOverResize + } + } +} + +/// UIHostingController subclass which reads the InnerHeightPreferenceKey changes +private class InnerHeightUIHostingController: UIHostingController { + let onHeightChange: (CGFloat) -> Void + let onVerticalSizeClassChange: (UserInterfaceSizeClass?) -> Void + + init(rootView: some View, onHeightChange: @escaping (CGFloat) -> Void, onVerticalSizeClassChange: @escaping (UserInterfaceSizeClass?) -> Void) { + self.onHeightChange = onHeightChange + self.onVerticalSizeClassChange = onVerticalSizeClassChange + weak var weakSelf: InnerHeightUIHostingController? + super.init(rootView: AnyView( + rootView + .onPreferenceChange(InnerHeightPreferenceKey.self) { + weakSelf?._innerSwiftUIContentHeight = $0 + } + .onPreferenceChange(VerticalSizeClassPreferenceKey.self) { newSizeClass in + weakSelf?._innerVerticalSizeClass = newSizeClass + } + )) + weakSelf = self + } + + @available(*, unavailable) + @objc + dynamic required init?(coder aDecoder: NSCoder) { + fatalError() + } + + private var _innerSwiftUIContentHeight: CGFloat = 0 { + didSet { onHeightChange(_innerSwiftUIContentHeight) } + } + + private var _innerVerticalSizeClass: UserInterfaceSizeClass? = nil { + didSet { onVerticalSizeClassChange(_innerVerticalSizeClass) } + } +} + +public struct QuickEditorPresenter { + let email: Email + let scope: QuickEditorScope + let avatarPickerConfiguration: AvatarPickerConfiguration + let token: String? + + public init( + email: Email, + scope: QuickEditorScope, + avatarPickerConfiguration: AvatarPickerConfiguration? = nil, + token: String? = nil + ) { + self.email = email + self.scope = scope + self.avatarPickerConfiguration = avatarPickerConfiguration ?? .default + self.token = token + } + + @MainActor + public func present( + in parent: UIViewController, + animated: Bool = true, + completion: (() -> Void)? = nil, + onDismiss: @escaping () -> Void + ) { + let quickEditor = QuickEditorViewController( + email: email, + scope: scope, + avatarPickerConfiguration: avatarPickerConfiguration, + token: token, + onDismiss: onDismiss + ) + quickEditor.onDismiss = onDismiss + parent.present(quickEditor, animated: animated, completion: completion) + } +} diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerContentLayout.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerContentLayout.swift index 5a4f0bd3..debb3053 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerContentLayout.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerContentLayout.swift @@ -42,6 +42,15 @@ public enum AvatarPickerContentLayoutWithPresentation: AvatarPickerContentLayout .vertical } } + + var prioritizeScrollOverResize: Bool { + switch self { + case .vertical(.expandableMedium(_, let prioritizeScrollOverResize)): + prioritizeScrollOverResize + default: + false + } + } } /// Content layout to use pre iOS 16.0 where the system don't offer different presentation styles for SwiftUI. diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift index 8f6fdd5a..600c497e 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift @@ -6,15 +6,18 @@ struct AvatarPickerView: View { fileprivate typealias Constants = AvatarPicker.Constants fileprivate typealias Localized = AvatarPicker.Localized - @ObservedObject var model: AvatarPickerViewModel - var contentLayoutProvider: AvatarPickerContentLayoutProviding = AvatarPickerContentLayout.vertical @Environment(\.colorScheme) var colorScheme: ColorScheme - @Binding var isPresented: Bool - @State private var safariURL: URL? @Environment(\.verticalSizeClass) var verticalSizeClass @Environment(\.horizontalSizeClass) var horizontalSizeClass + + @StateObject var model: AvatarPickerViewModel + @Binding var isPresented: Bool + + @State private var safariURL: URL? @State private var uploadError: FailedUploadInfo? @State private var isUploadErrorDialogPresented: Bool = false + + var contentLayoutProvider: AvatarPickerContentLayoutProviding = AvatarPickerContentLayout.vertical var customImageEditor: ImageEditorBlock? var tokenErrorHandler: (() -> Void)? @@ -276,6 +279,8 @@ struct AvatarPickerView: View { CircularProgressViewStyle() ) .controlSize(.regular) + + Spacer() } } @@ -501,7 +506,7 @@ private enum AvatarPicker { profileModel: PreviewModel() ) - return AvatarPickerView(model: model, contentLayoutProvider: AvatarPickerContentLayout.horizontal, isPresented: .constant(true)) + return AvatarPickerView(model: model, isPresented: .constant(true), contentLayoutProvider: AvatarPickerContentLayout.horizontal) } #Preview("Empty elements") { diff --git a/Sources/GravatarUI/SwiftUI/AvatarPickerModalPresentationModifier.swift b/Sources/GravatarUI/SwiftUI/AvatarPickerModalPresentationModifier.swift index f6bd165d..aab3ec05 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPickerModalPresentationModifier.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPickerModalPresentationModifier.swift @@ -1,7 +1,7 @@ import Combine import SwiftUI -private enum ModalPresentationConstants { +enum QEModalPresentationConstants { // Estimated height for the bottom sheet in horizontal mode. // The value is the height of a successfully loading Avatar picker in various iPhone models. // This is just the initial value of the bottom sheet. If the content turns out to be @@ -14,12 +14,12 @@ private enum ModalPresentationConstants { } @available(iOS 16.0, *) -struct AvatarPickerModalPresentationModifier: ViewModifier { - fileprivate typealias Constants = ModalPresentationConstants +struct AvatarPickerModalPresentationModifier: ViewModifier, ModalPresentationWithIntrinsicSize { + fileprivate typealias Constants = QEModalPresentationConstants @Binding var isPresented: Bool @State private var isPresentedInner: Bool @State private var sheetHeight: CGFloat = Constants.bottomSheetEstimatedHeight - @State private var verticalSizeClass: UserInterfaceSizeClass? + @State private(set) var verticalSizeClass: UserInterfaceSizeClass? @State private var presentationDetents: Set @State private var prioritizeScrollOverResize: Bool = false let onDismiss: (() -> Void)? @@ -32,11 +32,11 @@ struct AvatarPickerModalPresentationModifier: ViewModifier { self.onDismiss = onDismiss self.modalView = modalView self.contentLayoutWithPresentation = contentLayout - self.presentationDetents = Self.detents( + self.presentationDetents = QEDetent.detents( for: contentLayout, intrinsicHeight: Constants.bottomSheetEstimatedHeight, verticalSizeClass: nil - ) + ).map() } func body(content: Content) -> some View { @@ -47,11 +47,11 @@ struct AvatarPickerModalPresentationModifier: ViewModifier { // Otherwise the view remembers its previous height. And an animation glitch happens // when switching between different presentation styles (especially between horizontal and vertical_large). // Doing the same thing in .onAppear of the "modalView" doesn't give as nice results as this one don't know why. - self.presentationDetents = Self.detents( + self.presentationDetents = QEDetent.detents( for: contentLayoutWithPresentation, intrinsicHeight: max(sheetHeight, Constants.bottomSheetEstimatedHeight), verticalSizeClass: verticalSizeClass - ) + ).map() } isPresentedInner = newValue } @@ -62,7 +62,7 @@ struct AvatarPickerModalPresentationModifier: ViewModifier { modalView .frame(minHeight: Constants.bottomSheetMinHeight) .onPreferenceChange(InnerHeightPreferenceKey.self) { newHeight in - if newHeight > Constants.bottomSheetMinHeight, shouldUseIntrinsicSize { + if shouldAcceptHeight(newHeight) { sheetHeight = newHeight } updateDetents() @@ -77,50 +77,28 @@ struct AvatarPickerModalPresentationModifier: ViewModifier { } } - private static func detents( - for presentation: AvatarPickerContentLayoutWithPresentation, - intrinsicHeight: CGFloat, - verticalSizeClass: UserInterfaceSizeClass? - ) -> Set { - switch presentation { - case .horizontal: - if verticalSizeClass == .compact { - // in landscape mode where the device height is small we display the full size sheet(which is - // also the default value of the detent). - .init([.large]) - } else { - .init([.height(intrinsicHeight)]) - } - case .vertical(let presentationStyle): - switch presentationStyle { - case .large: - .init([.large]) - case .expandableMedium(let initialFraction, _): - .init([.fraction(initialFraction), .large]) - } - } - } - private func updateDetents() { - self.presentationDetents = Self.detents( + self.presentationDetents = QEDetent.detents( for: contentLayoutWithPresentation, intrinsicHeight: sheetHeight, verticalSizeClass: verticalSizeClass - ) - switch contentLayoutWithPresentation { - case .vertical(let presentationStyle): - switch presentationStyle { - case .large: - break - case .expandableMedium(_, let prioritizeScrollOverResize): - self.prioritizeScrollOverResize = prioritizeScrollOverResize - } - case .horizontal: - prioritizeScrollOverResize = true - } + ).map() + self.prioritizeScrollOverResize = contentLayoutWithPresentation.prioritizeScrollOverResize + } +} + +@MainActor +protocol ModalPresentationWithIntrinsicSize { + var contentLayoutWithPresentation: AvatarPickerContentLayoutWithPresentation { get } + var verticalSizeClass: UserInterfaceSizeClass? { get } +} + +extension ModalPresentationWithIntrinsicSize { + func shouldAcceptHeight(_ newHeight: CGFloat) -> Bool { + newHeight > QEModalPresentationConstants.bottomSheetMinHeight && shouldUseIntrinsicSize } - private var shouldUseIntrinsicSize: Bool { + var shouldUseIntrinsicSize: Bool { switch contentLayoutWithPresentation { case .horizontal: switch verticalSizeClass { diff --git a/Sources/GravatarUI/SwiftUI/OAuthSession/OAuthSession.swift b/Sources/GravatarUI/SwiftUI/OAuthSession/OAuthSession.swift index 7be6ea5f..d7e46ae9 100644 --- a/Sources/GravatarUI/SwiftUI/OAuthSession/OAuthSession.swift +++ b/Sources/GravatarUI/SwiftUI/OAuthSession/OAuthSession.swift @@ -14,6 +14,11 @@ public struct OAuthSession: Sendable { self.storage = storage } + public init() { + self.authenticationSession = OldAuthenticationSession() + self.storage = Keychain() + } + public func hasSession(with email: Email) -> Bool { (try? storage.secret(with: email.rawValue) ?? nil) != nil } diff --git a/Sources/GravatarUI/SwiftUI/ProfileEditor/QuickEditor.swift b/Sources/GravatarUI/SwiftUI/ProfileEditor/QuickEditor.swift index 1ed315bb..8848b781 100644 --- a/Sources/GravatarUI/SwiftUI/ProfileEditor/QuickEditor.swift +++ b/Sources/GravatarUI/SwiftUI/ProfileEditor/QuickEditor.swift @@ -1,6 +1,6 @@ import SwiftUI -public enum QuickEditorScope { +public enum QuickEditorScope: Sendable { case avatarPicker } @@ -18,12 +18,14 @@ struct QuickEditor: View { @State var oauthError: OAuthError? @Binding var isPresented: Bool let email: Email + let token: String? var customImageEditor: ImageEditorBlock? var contentLayoutProvider: AvatarPickerContentLayoutProviding init( email: Email, scope: QuickEditorScope, + token: String? = nil, isPresented: Binding, customImageEditor: ImageEditorBlock? = nil, contentLayoutProvider: AvatarPickerContentLayoutProviding = AvatarPickerContentLayout.vertical @@ -33,11 +35,14 @@ struct QuickEditor: View { self._isPresented = isPresented self.customImageEditor = customImageEditor self.contentLayoutProvider = contentLayoutProvider + self.token = token } var body: some View { NavigationView { - if hasSession, let token = oauthSession.sessionToken(with: email) { + if let token { + editorView(with: token) + } else if hasSession, let token = oauthSession.sessionToken(with: email) { editorView(with: token) } else { noticeView() @@ -52,8 +57,8 @@ struct QuickEditor: View { case .avatarPicker: AvatarPickerView( model: .init(email: email, authToken: token), - contentLayoutProvider: contentLayoutProvider, isPresented: $isPresented, + contentLayoutProvider: contentLayoutProvider, customImageEditor: customImageEditor, tokenErrorHandler: { oauthSession.deleteSession(with: email) diff --git a/Sources/GravatarUI/SwiftUI/QEDetent.swift b/Sources/GravatarUI/SwiftUI/QEDetent.swift new file mode 100644 index 00000000..fbc6f858 --- /dev/null +++ b/Sources/GravatarUI/SwiftUI/QEDetent.swift @@ -0,0 +1,90 @@ +import Foundation +import SwiftUI + +/// SwiftUI and UIKit uses different detent types. This enum allows us to share platform agnostic logic about detents. +enum QEDetent { + case medium + case large + case fraction(CGFloat) + case height(CGFloat) + + static func detents( + for presentation: AvatarPickerContentLayoutWithPresentation, + intrinsicHeight: CGFloat, + verticalSizeClass: UserInterfaceSizeClass? + ) -> [QEDetent] { + switch presentation { + case .horizontal: + if verticalSizeClass == .compact { + // in landscape mode where the device height is small we display the full size sheet(which is + // also the default value of the detent). + .init([.large]) + } else { + .init([.height(intrinsicHeight)]) + } + case .vertical(let presentationStyle): + switch presentationStyle { + case .large: + .init([.large]) + case .expandableMedium(let initialFraction, _): + .init([.fraction(initialFraction), .large]) + } + } + } + + @MainActor + fileprivate func toUISheetDetent() -> UISheetPresentationController.Detent { + switch self { + case .large: + UISheetPresentationController.Detent.large() + case .medium: + UISheetPresentationController.Detent.medium() + case .fraction(let value): + if #available(iOS 16.0, *) { + UISheetPresentationController.Detent.custom { context in + value * context.maximumDetentValue + } + } else { + UISheetPresentationController.Detent.medium() + } + case .height(let value): + if #available(iOS 16.0, *) { + .custom { _ in value } + } else { + .large() + } + } + } + + @available(iOS 16.0, *) + fileprivate func toSheetDetent() -> PresentationDetent { + switch self { + case .large: + .large + case .medium: + .medium + case .fraction(let value): + .fraction(value) + case .height(let height): + .height(height) + } + } +} + +extension [QEDetent] { + @MainActor + func map() -> [UISheetPresentationController.Detent] { + self.map { element in + element.toUISheetDetent() + } + } + + @available(iOS 16.0, *) + func map() -> Set { + Set( + self.map { element in + element.toSheetDetent() + } + ) + } +} diff --git a/Sources/GravatarUI/SwiftUI/View+Additions.swift b/Sources/GravatarUI/SwiftUI/View+Additions.swift index dded273a..ae330d98 100644 --- a/Sources/GravatarUI/SwiftUI/View+Additions.swift +++ b/Sources/GravatarUI/SwiftUI/View+Additions.swift @@ -20,8 +20,8 @@ extension View { ) -> some View { let avatarPickerView = AvatarPickerView( model: AvatarPickerViewModel(email: Email(email), authToken: authToken), - contentLayoutProvider: AvatarPickerContentLayout.vertical, isPresented: isPresented, + contentLayoutProvider: AvatarPickerContentLayout.vertical, customImageEditor: customImageEditor ) let navigationWrapped = NavigationView { avatarPickerView } @@ -38,8 +38,8 @@ extension View { ) -> some View { let avatarPickerView = AvatarPickerView( model: AvatarPickerViewModel(email: Email(email), authToken: authToken), - contentLayoutProvider: contentLayout, isPresented: isPresented, + contentLayoutProvider: contentLayout, customImageEditor: customImageEditor ) let navigationWrapped = NavigationView { avatarPickerView }