From 3fc392758ddc1c2edd1e40fd0c5810eeb51cb399 Mon Sep 17 00:00:00 2001 From: Rasmus Tauts Date: Wed, 13 Sep 2023 17:28:45 +0300 Subject: [PATCH] Convert ConfirmationView form UIKit to SwiftUI This PR is experimental, yet important first step towards transitioning to SwiftUI. ConfirmationView has been rewritten entirely in SwiftUI. The primary motivator for this PR was the frustrating nature of UIKit constraints. Key factors to keep in mind while reviewing: 1. Checkmark image has been made smaller, which was the request from the design team 2. UIFont does not translate 1:1 to SwiftUI Font, meaning the same font looks just a little big different now. But dynamic scaling works, and all our default fonts translate to expected outcome (font size, weight etc.) 3. Some approaches used in view layouts is due to the restrictions of SwiftUI 1.0 capabilities. Those can and will be upgraded in the future 4. The naming of Views, and Objects, as well as file locations in the directory are up for discussion. Everyone's input is much appreciated. 5. Prefix SwiftUI is used in lots of places due to a conflict with existing custom objects Button and Image 6. New HeaderSwiftUI view does not include all the possible configuration possibilities, as confirmations view's needs are not very demanding. So, that view can be upgraded in future. This PR also covers the ticket MOB-2488 which focuses on ADA-compliance in ConfigurationsView --- GliaWidgets.xcodeproj/project.pbxproj | 70 ++++- ...SecureConversations.ConfirmationView.swift | 284 +++++------------- ...ersations.ConfirmationViewController.swift | 65 ++-- ...eConversations.ConfirmationViewModel.swift | 107 +++---- ...heme+SecureConversationsConfirmation.swift | 2 +- .../SecureConversations.Coordinator.swift | 37 ++- .../EngagementCoordinator.swift | 1 + .../FoundationBased.Interface.swift | 3 + .../FoundationBased.Live.swift | 4 +- .../FoundationBased.Mock.swift | 6 +- .../UIKitBased/UIKitBased.Interface.swift | 1 + .../Sources/UIKitBased/UIKitBased.Live.swift | 3 +- .../Sources/UIKitBased/UIKitBased.Mock.swift | 3 +- .../Sources/ViewFactory/ViewFactory.swift | 4 - .../Buttons/ActionButtonSwiftUI.swift | 65 ++++ .../Buttons/HeaderButtonSwiftUI.swift | 48 +++ .../Components/Header/HeaderSwiftUI.swift | 86 ++++++ .../SwiftUI/Extensions/Font+Extensions.swift | 7 + .../SwiftUI/Managers/OrientationManager.swift | 38 +++ .../VideoCall/Mocks/HeaderStyle.Mock.swift | 5 +- .../FoundationBased.Failing.swift | 5 + ...ersations.ConfirmationViewModel.Mock.swift | 77 ++++- ...ersations.ConfirmationViewModelTests.swift | 94 +++--- GliaWidgetsTests/UIKitBased.Failing.swift | 4 + ...nfirmationScreenDynamicTypeFontTests.swift | 40 +-- ...sationsConfirmationScreenLayoutTests.swift | 40 +-- ...ConversationsConfirmationScreenTests.swift | 40 +-- 27 files changed, 672 insertions(+), 467 deletions(-) create mode 100644 GliaWidgets/SwiftUI/Components/Buttons/ActionButtonSwiftUI.swift create mode 100644 GliaWidgets/SwiftUI/Components/Buttons/HeaderButtonSwiftUI.swift create mode 100644 GliaWidgets/SwiftUI/Components/Header/HeaderSwiftUI.swift create mode 100644 GliaWidgets/SwiftUI/Extensions/Font+Extensions.swift create mode 100644 GliaWidgets/SwiftUI/Managers/OrientationManager.swift diff --git a/GliaWidgets.xcodeproj/project.pbxproj b/GliaWidgets.xcodeproj/project.pbxproj index 7dd6c05aa..f0aaebb7c 100644 --- a/GliaWidgets.xcodeproj/project.pbxproj +++ b/GliaWidgets.xcodeproj/project.pbxproj @@ -590,6 +590,8 @@ C03A8049292BC8DB00DDECA6 /* CallViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C03A8048292BC8DB00DDECA6 /* CallViewControllerTests.swift */; }; C05AB01C295F416700AA381F /* VisitorCodeCloseButtonProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05AB01B295F416700AA381F /* VisitorCodeCloseButtonProperties.swift */; }; C05E3EDE29C99E070013BC81 /* ProximityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05E3EDD29C99E070013BC81 /* ProximityManager.swift */; }; + C06152D52AB1BC1300063BF8 /* Font+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06152D42AB1BC1300063BF8 /* Font+Extensions.swift */; }; + C06152DA2AB1BC4300063BF8 /* OrientationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06152D92AB1BC4300063BF8 /* OrientationManager.swift */; }; C06A757F296EC76B006B69A2 /* VisitorCodeStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06A757E296EC76B006B69A2 /* VisitorCodeStyle.swift */; }; C06A7582296EC856006B69A2 /* NumberSlotStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06A7581296EC856006B69A2 /* NumberSlotStyle.swift */; }; C06A7584296EC9DC006B69A2 /* NumberSlotStyle.Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06A7583296EC9DC006B69A2 /* NumberSlotStyle.Accessibility.swift */; }; @@ -641,6 +643,9 @@ C0D2F08B29A4E95700803B47 /* ConnectView.Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D2F08929A4E92D00803B47 /* ConnectView.Mock.swift */; }; C0D2F08C29A4EBA900803B47 /* VIdeoCallView.Environment.Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D2F06F29A4DB5C00803B47 /* VIdeoCallView.Environment.Mock.swift */; }; C0D2F08F29A61A8D00803B47 /* VideoCallViewController.Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D2F08D29A61A7800803B47 /* VideoCallViewController.Mock.swift */; }; + C0E948042AB1D5D200890026 /* ActionButtonSwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E948032AB1D5D200890026 /* ActionButtonSwiftUI.swift */; }; + C0E948062AB1D64700890026 /* HeaderButtonSwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E948052AB1D64700890026 /* HeaderButtonSwiftUI.swift */; }; + C0E948092AB1D6AB00890026 /* HeaderSwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E948082AB1D6AB00890026 /* HeaderSwiftUI.swift */; }; C2B201AEDBE3A53369DF524F /* Pods_GliaWidgetsTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CCF7E6C5499635E67EF6A604 /* Pods_GliaWidgetsTests.framework */; }; C4119E06268F41D1004DFEFB /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C4119E05268F41D1004DFEFB /* Main.storyboard */; }; C42463742673ABE10082C135 /* ScreenShareHandler.Interface.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42463732673ABE10082C135 /* ScreenShareHandler.Interface.swift */; }; @@ -1322,6 +1327,8 @@ C05AB016295DA9FC00AA381F /* AlertViewController+VisitorCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AlertViewController+VisitorCode.swift"; sourceTree = ""; }; C05AB01B295F416700AA381F /* VisitorCodeCloseButtonProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitorCodeCloseButtonProperties.swift; sourceTree = ""; }; C05E3EDD29C99E070013BC81 /* ProximityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityManager.swift; sourceTree = ""; }; + C06152D42AB1BC1300063BF8 /* Font+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+Extensions.swift"; sourceTree = ""; }; + C06152D92AB1BC4300063BF8 /* OrientationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrientationManager.swift; sourceTree = ""; }; C06A757E296EC76B006B69A2 /* VisitorCodeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitorCodeStyle.swift; sourceTree = ""; }; C06A7581296EC856006B69A2 /* NumberSlotStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberSlotStyle.swift; sourceTree = ""; }; C06A7583296EC9DC006B69A2 /* NumberSlotStyle.Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberSlotStyle.Accessibility.swift; sourceTree = ""; }; @@ -1372,6 +1379,9 @@ C0D2F08629A4E8AE00803B47 /* CallButtonBar.Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallButtonBar.Mock.swift; sourceTree = ""; }; C0D2F08929A4E92D00803B47 /* ConnectView.Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.Mock.swift; sourceTree = ""; }; C0D2F08D29A61A7800803B47 /* VideoCallViewController.Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCallViewController.Mock.swift; sourceTree = ""; }; + C0E948032AB1D5D200890026 /* ActionButtonSwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonSwiftUI.swift; sourceTree = ""; }; + C0E948052AB1D64700890026 /* HeaderButtonSwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderButtonSwiftUI.swift; sourceTree = ""; }; + C0E948082AB1D6AB00890026 /* HeaderSwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderSwiftUI.swift; sourceTree = ""; }; C4119E05268F41D1004DFEFB /* Main.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; C42463732673ABE10082C135 /* ScreenShareHandler.Interface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenShareHandler.Interface.swift; sourceTree = ""; }; C43C12F82694B14900C37E1B /* GliaPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GliaPresenter.swift; sourceTree = ""; }; @@ -1644,6 +1654,7 @@ 1A205D5A25655CB1003AA3CD /* GliaWidgets */ = { isa = PBXGroup; children = ( + C06152D22AB1BBEF00063BF8 /* SwiftUI */, 313EBD53294310EE008E9597 /* SecureConversations */, 7594091329891C48008B173A /* Public */, 1A60AFC12566857200E53F53 /* Sources */, @@ -3503,6 +3514,32 @@ path = ProximityManager; sourceTree = ""; }; + C06152D22AB1BBEF00063BF8 /* SwiftUI */ = { + isa = PBXGroup; + children = ( + C0E948012AB1D5B100890026 /* Components */, + C06152D82AB1BC2F00063BF8 /* Managers */, + C06152D32AB1BBFD00063BF8 /* Extensions */, + ); + path = SwiftUI; + sourceTree = ""; + }; + C06152D32AB1BBFD00063BF8 /* Extensions */ = { + isa = PBXGroup; + children = ( + C06152D42AB1BC1300063BF8 /* Font+Extensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + C06152D82AB1BC2F00063BF8 /* Managers */ = { + isa = PBXGroup; + children = ( + C06152D92AB1BC4300063BF8 /* OrientationManager.swift */, + ); + path = Managers; + sourceTree = ""; + }; C06A757D296EC743006B69A2 /* VisitorCode */ = { isa = PBXGroup; children = ( @@ -3646,6 +3683,32 @@ path = Mocks; sourceTree = ""; }; + C0E948012AB1D5B100890026 /* Components */ = { + isa = PBXGroup; + children = ( + C0E948072AB1D69C00890026 /* Header */, + C0E948022AB1D5BC00890026 /* Buttons */, + ); + path = Components; + sourceTree = ""; + }; + C0E948022AB1D5BC00890026 /* Buttons */ = { + isa = PBXGroup; + children = ( + C0E948032AB1D5D200890026 /* ActionButtonSwiftUI.swift */, + C0E948052AB1D64700890026 /* HeaderButtonSwiftUI.swift */, + ); + path = Buttons; + sourceTree = ""; + }; + C0E948072AB1D69C00890026 /* Header */ = { + isa = PBXGroup; + children = ( + C0E948082AB1D6AB00890026 /* HeaderSwiftUI.swift */, + ); + path = Header; + sourceTree = ""; + }; C42463722673ABCD0082C135 /* Screensharing */ = { isa = PBXGroup; children = ( @@ -4159,7 +4222,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "make write-diff\n"; + shellScript = "#make write-diff\n"; }; A5633E9F76E68066D5BFAF62 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -4224,6 +4287,7 @@ 1A6075E7258220E300569B0E /* UserImageStyle.swift in Sources */, 1ABD6C5D25B59D1C00D56EFA /* BubbleWindow.swift in Sources */, 75940964298D3889008B173A /* MessageRenderer.Web.swift in Sources */, + C0E948042AB1D5D200890026 /* ActionButtonSwiftUI.swift in Sources */, 1AE15E38257A578B00A642C0 /* MessageAlertConfiguration.swift in Sources */, C0175A132A56E29E001FACDE /* ChatMessageCardType.swift in Sources */, AFBBF5782851C391004993B3 /* Glia.Deprecated.swift in Sources */, @@ -4314,6 +4378,7 @@ 9AE0A7622822AF3000725946 /* FontScaling.Environment.Live.swift in Sources */, 1A0452DD25DBD0A4000DA0C1 /* HeaderButton.swift in Sources */, AF3D520B2983235C00AD8E69 /* FileUploader.Mock.swift in Sources */, + C0E948092AB1D6AB00890026 /* HeaderSwiftUI.swift in Sources */, AF39330B29B2A6A00008B60D /* ChatViewModel.SecureConverstaions.swift in Sources */, 75AF8CEF27DAA819009EEE2C /* SurveyViewController.swift in Sources */, 1A60AF96256675C400E53F53 /* UIColor+Extensions.swift in Sources */, @@ -4413,6 +4478,7 @@ 9A19926B27D3BA8700161AAE /* ViewFactory.Environment.Mock.swift in Sources */, 84681A9B2A669D8800DD7406 /* QuickReplyView.swift in Sources */, 1A6EB05725A717CB0007081A /* ChatMessage.swift in Sources */, + C0E948062AB1D64700890026 /* HeaderButtonSwiftUI.swift in Sources */, 1AA738B225790D5A00E1120F /* AlertView.swift in Sources */, 845E2F8E283FB5B500C04D56 /* Theme.Survey.SingleQuestion.Accessibility.swift in Sources */, C47901B725ED2FB0007EE195 /* AlertViewController+ScreenShareOffer.swift in Sources */, @@ -4518,6 +4584,7 @@ 9AB196DE27C3FFF400FD60AB /* Call.Environment.Mock.swift in Sources */, 1A0C143125B8547200B00695 /* EngagementStyle.swift in Sources */, 7594098B298D38C2008B173A /* CallVisualizer+Presentation.swift in Sources */, + C06152DA2AB1BC4300063BF8 /* OrientationManager.swift in Sources */, 9A186A3727F5D38D0055886D /* ChatMessageEntryStyle.Accessibility.swift in Sources */, 9AB061C1280EFE09008960FA /* ChatFileDownloadStyle.Accessibility.swift in Sources */, 1A7CA8272574D6F40047CBBE /* ConnectStyle.swift in Sources */, @@ -4609,6 +4676,7 @@ 6E9C01AD26D3B8B500EBE1D4 /* OperatorTypingIndicatorView.swift in Sources */, AF10ED8B29B7A4C000E85309 /* ChatViewModel+ChoiceCards.swift in Sources */, 7594097E298D38C2008B173A /* CallVisualizer.BubbleIcon.swift in Sources */, + C06152D52AB1BC1300063BF8 /* Font+Extensions.swift in Sources */, AFA2FDF628907FC800428E6D /* BubbleStyle.Mock.swift in Sources */, 755D186B29A6A5830009F5E8 /* WelcomeStyle+MessageTitleStyle.swift in Sources */, 75940981298D38C2008B173A /* VisitorCodeView+NumberView.swift in Sources */, diff --git a/GliaWidgets/SecureConversations/Confirmation/SecureConversations.ConfirmationView.swift b/GliaWidgets/SecureConversations/Confirmation/SecureConversations.ConfirmationView.swift index 7aca96ded..8690cdcc8 100644 --- a/GliaWidgets/SecureConversations/Confirmation/SecureConversations.ConfirmationView.swift +++ b/GliaWidgets/SecureConversations/Confirmation/SecureConversations.ConfirmationView.swift @@ -1,216 +1,88 @@ -import Foundation -import UIKit +import SwiftUI extension SecureConversations { - final class ConfirmationView: BaseView { - static let sideMargin = 24.0 - static let portraitConfirmationImageSize = 144.0 - static let landscapeConfirmationImageSize = 90.0 - - struct Props: Equatable { - let style: ConfirmationStyle - let header: Header.Props - let checkMessageButtonTap: Cmd - } - - private let header: Header - - var topRootStackViewConstraint: NSLayoutConstraint? - var confirmationImageViewWidthConstraints: NSLayoutConstraint? - var confirmationImageViewHeightConstraints: NSLayoutConstraint? - - lazy var rootStackView = UIStackView.make( - .vertical, - spacing: 0, - distribution: .fill, - alignment: .center - )( - confirmationImageView, - titleLabel, - subtitleLabel, - spacer, - checkMessagesButton - ) - - let confirmationImageView = UIImageView().makeView { imageView in - imageView.image = Asset.mcConfirmation.image.withRenderingMode(.alwaysTemplate) - } - let titleLabel = UILabel().makeView { label in - label.numberOfLines = 0 - label.textAlignment = .center - } - - let subtitleLabel = UILabel().makeView { label in - label.numberOfLines = 0 - label.textAlignment = .center - } - - // Flexible space to accommodate the check messages button - // at the bottom of the view. - let spacer = UIView() - - lazy var checkMessagesButton = UIButton(type: .custom).makeView { button in - button.addTarget( - self, - action: #selector(handleCheckMessagesButtonTap), - for: .touchUpInside - ) - - button.layer.cornerRadius = 4 - } - - var props: Props { - didSet { - renderProps() + struct ConfirmationViewSwiftUI: View { + @ObservedObject var model: Model + @ObservedObject var orientation: OrientationManager + + var body: some View { + ZStack { + backgroundColor() + VStack(spacing: 0) { + HeaderSwiftUI(model: model.makeHeaderModel()) + VStack(spacing: 0) { + Spacer(minLength: 1) + checkmarkImage() + titleView() + subtitleView() + Spacer(minLength: 1) + confirmationButtonView() + } + .padding(.bottom, orientation.isPortrait ? 24 : 8) + .padding(.horizontal, 24) + } + .edgesIgnoringSafeArea(.top) } } + } +} - init(props: Props) { - self.header = Header( - props: props.header - ) - self.props = props - super.init() - } - - @available(*, unavailable) - required init() { - fatalError("init() has not been implemented") - } - - override func defineLayout() { - super.defineLayout() - defineHeaderLayout() - defineRootStackViewLayout() - defineConfirmationImageViewLayout() - defineTitleLabelLayout() - defineSubtitleLabelLayout() - defineSpacerLayout() - defineCheckMessagesButtonLayout() - renderProps() - } - - override func setup() { - super.setup() - addSubview(rootStackView) - } - - override func layoutSubviews() { - super.layoutSubviews() - - changeConfirmationImageViewDimensions() - } - - private func changeConfirmationImageViewDimensions() { - // The portrait factor is the factor between the space from the header - // to the beginning of the stack view versus the height of the screen - // in the Figma design. The landscape factor was calculated through trial - // and error to avoid a bug where the image was so big that it would hide - // the text below it. - let factor = currentOrientation.isPortrait ? 0.2783 : 0.075 - topRootStackViewConstraint?.constant = self.rootStackView.frame.height * factor - - let imageSize = currentOrientation.isPortrait ? - Self.portraitConfirmationImageSize : - Self.landscapeConfirmationImageSize - - confirmationImageViewWidthConstraints?.constant = imageSize - confirmationImageViewHeightConstraints?.constant = imageSize - } - - private func defineHeaderLayout() { - addSubview(header) - var constraints = [NSLayoutConstraint](); defer { constraints.activate() } - constraints += header.layoutInSuperview(edges: .horizontal) - constraints += header.layoutInSuperview(edges: .top) - } - - func defineRootStackViewLayout() { - topRootStackViewConstraint = rootStackView.topAnchor.constraint(equalTo: header.bottomAnchor) - NSLayoutConstraint.activate([ - rootStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.sideMargin), - rootStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.sideMargin), - topRootStackViewConstraint, - rootStackView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -Self.sideMargin) - ].compactMap { $0 }) - } - - private func defineConfirmationImageViewLayout() { - confirmationImageViewWidthConstraints = confirmationImageView.widthAnchor.constraint( - equalToConstant: Self.portraitConfirmationImageSize - ) - confirmationImageViewHeightConstraints = confirmationImageView.heightAnchor.constraint( - equalToConstant: Self.portraitConfirmationImageSize - ) - - NSLayoutConstraint.activate([ - confirmationImageView.topAnchor.constraint(equalTo: rootStackView.topAnchor), - confirmationImageViewWidthConstraints, - confirmationImageViewHeightConstraints - ].compactMap { $0 }) - - rootStackView.setCustomSpacing(32, after: confirmationImageView) - } - - private func defineTitleLabelLayout() { - rootStackView.setCustomSpacing(16, after: titleLabel) - } - - private func defineSubtitleLabelLayout() { - rootStackView.setCustomSpacing(0, after: subtitleLabel) - } - - private func defineSpacerLayout() { - NSLayoutConstraint.activate([ - spacer.heightAnchor.constraint(greaterThanOrEqualToConstant: 1) - ]) - } - private func defineCheckMessagesButtonLayout() { - NSLayoutConstraint.activate([ - checkMessagesButton.widthAnchor.constraint( - equalTo: rootStackView.widthAnchor - ), - checkMessagesButton.heightAnchor.constraint(equalToConstant: 48) - ]) - } - @objc func handleCheckMessagesButtonTap() { - props.checkMessageButtonTap() - } - - private func renderProps() { - header.props = props.header - header.showCloseButton() - - confirmationImageView.tintColor = props.style.confirmationImageTint - titleLabel.text = props.style.titleStyle.text - titleLabel.textColor = props.style.titleStyle.color - titleLabel.font = props.style.titleStyle.font - setFontScalingEnabled( - props.style.titleStyle.accessibility.isFontScalingEnabled, - for: titleLabel - ) +private extension SecureConversations.ConfirmationViewSwiftUI { + @ViewBuilder + func backgroundColor() -> some View { + SwiftUI.Color(model.style.backgroundColor) + .edgesIgnoringSafeArea(.all) + } + @ViewBuilder + func checkmarkImage() -> some View { + SwiftUI.Image(uiImage: Asset.mcConfirmation.image.withRenderingMode(.alwaysTemplate)) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: orientation.isPortrait ? 100.0 : 70.0) + .foregroundColor(SwiftUI.Color(model.style.confirmationImageTint)) + .padding(.bottom, orientation.isPortrait ? 32 : 8) + .accessibility(hidden: true) + } - subtitleLabel.text = props.style.subtitleStyle.text - subtitleLabel.textColor = props.style.subtitleStyle.color - subtitleLabel.font = props.style.subtitleStyle.font - setFontScalingEnabled( - props.style.subtitleStyle.accessibility.isFontScalingEnabled, - for: subtitleLabel - ) + @ViewBuilder + func titleView() -> some View { + Text(model.style.titleStyle.text) + .font(.convert(model.style.titleStyle.font)) + .multilineTextAlignment(.center) + .foregroundColor(SwiftUI.Color(model.style.titleStyle.color)) + .padding(.bottom, orientation.isPortrait ? 16 : 8) + } - checkMessagesButton.setTitle(props.style.checkMessagesButtonStyle.title, for: .normal) - checkMessagesButton.setTitleColor(props.style.checkMessagesButtonStyle.textColor, for: .normal) - checkMessagesButton.backgroundColor = props.style.checkMessagesButtonStyle.backgroundColor - checkMessagesButton.accessibilityTraits = .button - checkMessagesButton.accessibilityIdentifier = "secureConversations_confirmationCheckMessages_button" - checkMessagesButton.accessibilityLabel = props.style.checkMessagesButtonStyle.accessibility.label - checkMessagesButton.accessibilityHint = props.style.checkMessagesButtonStyle.accessibility.hint - setFontScalingEnabled( - props.style.checkMessagesButtonStyle.accessibility.isFontScalingEnabled, - for: checkMessagesButton - ) + @ViewBuilder + func subtitleView() -> some View { + Text(model.style.subtitleStyle.text) + .font(.convert(model.style.subtitleStyle.font)) + .multilineTextAlignment(.center) + .lineLimit(nil) + .foregroundColor(SwiftUI.Color(model.style.subtitleStyle.color)) + } - backgroundColor = props.style.backgroundColor - } + @ViewBuilder + func confirmationButtonView() -> some View { + SwiftUI.Button { + model.event(.chatTranscriptScreenRequested) + } label: { + Text(model.style.checkMessagesButtonStyle.title) + .font(.convert(model.style.checkMessagesButtonStyle.font)) + .multilineTextAlignment(.center) + .foregroundColor(SwiftUI.Color(model.style.checkMessagesButtonStyle.textColor)) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .frame( + maxWidth: .infinity, + minHeight: 48, + idealHeight: 48 + ) + .background(SwiftUI.Color(model.style.checkMessagesButtonStyle.backgroundColor)) + .cornerRadius(4) + } + .accessibility(identifier: "secureConversations_confirmationCheckMessages_button") + .accessibility(label: Text(model.style.checkMessagesButtonStyle.accessibility.label)) + .accessibility(hint: Text(model.style.checkMessagesButtonStyle.accessibility.hint)) } } diff --git a/GliaWidgets/SecureConversations/Confirmation/SecureConversations.ConfirmationViewController.swift b/GliaWidgets/SecureConversations/Confirmation/SecureConversations.ConfirmationViewController.swift index c1904fca1..ba3f7d896 100644 --- a/GliaWidgets/SecureConversations/Confirmation/SecureConversations.ConfirmationViewController.swift +++ b/GliaWidgets/SecureConversations/Confirmation/SecureConversations.ConfirmationViewController.swift @@ -1,25 +1,20 @@ import UIKit +import SwiftUI extension SecureConversations { final class ConfirmationViewController: UIViewController { - var props: Props { - didSet { - guard props != oldValue else { return } - renderProps() - } - } - - private let viewFactory: ViewFactory - private let viewModel: ConfirmationViewModel + private let model: ConfirmationViewSwiftUI.Model + private let orientation: OrientationManager init( - viewModel: ConfirmationViewModel, - viewFactory: ViewFactory, - props: Props + model: ConfirmationViewSwiftUI.Model ) { - self.viewModel = viewModel - self.viewFactory = viewFactory - self.props = props + self.model = model + self.orientation = .init(environment: .init( + uiApplication: model.environment.uiApplication, + uiDevice: model.environment.uiDevice, + notificationCenter: model.environment.notificationCenter + )) super.init(nibName: nil, bundle: nil) } @@ -30,26 +25,28 @@ extension SecureConversations { override func loadView() { super.loadView() - renderProps() - } + let hostingController: UIHostingController + let confirmationView = ConfirmationViewSwiftUI( + model: model, + orientation: orientation + ) - func renderProps() { - let confirmationView: ConfirmationView - if let currentView = view as? ConfirmationView { - confirmationView = currentView - } else { - confirmationView = viewFactory.makeSecureConversationsConfirmationView( - props: props.confirmationViewProps - ) - view = confirmationView - } - confirmationView.props = props.confirmationViewProps - } - } -} + hostingController = UIHostingController(rootView: confirmationView) + addChild(hostingController) + view.addSubview(hostingController.view) + hostingController.didMove(toParent: self) -extension SecureConversations.ConfirmationViewController { - struct Props: Equatable { - let confirmationViewProps: SecureConversations.ConfirmationView.Props + hostingController.rootView = ConfirmationViewSwiftUI( + model: model, + orientation: orientation + ) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } } } diff --git a/GliaWidgets/SecureConversations/Confirmation/SecureConversations.ConfirmationViewModel.swift b/GliaWidgets/SecureConversations/Confirmation/SecureConversations.ConfirmationViewModel.swift index f21b60899..fb184ef7f 100644 --- a/GliaWidgets/SecureConversations/Confirmation/SecureConversations.ConfirmationViewModel.swift +++ b/GliaWidgets/SecureConversations/Confirmation/SecureConversations.ConfirmationViewModel.swift @@ -1,87 +1,90 @@ import Foundation -extension SecureConversations { - final class ConfirmationViewModel: ViewModel { - var action: ((Action) -> Void)? +extension SecureConversations.ConfirmationViewSwiftUI { + final class Model: ObservableObject { + let environment: Environment + let style: SecureConversations.ConfirmationStyle var delegate: ((DelegateEvent) -> Void)? - var environment: Environment - init(environment: Environment) { + init( + environment: Environment, + style: SecureConversations.ConfirmationStyle, + delegate: ((DelegateEvent) -> Void)? + ) { self.environment = environment + self.style = style + self.delegate = delegate } + } +} - func event(_ event: Event) { - switch event { - case .closeTapped: - delegate?(.closeTapped) - } - } - - func reportChange() { - delegate?(.renderProps(props())) +// MARK: - Public Methods +extension SecureConversations.ConfirmationViewSwiftUI.Model { + func event(_ event: Event) { + switch event { + case .closeTapped: + delegate?(.closeTapped) + case .chatTranscriptScreenRequested: + delegate?(.chatTranscriptScreenRequested) } } } -extension SecureConversations.ConfirmationViewModel { - func props() -> SecureConversations.ConfirmationViewController.Props { - let confirmationStyle = environment.confirmationStyle - let confirmationViewProps = SecureConversations.ConfirmationView.Props( - style: confirmationStyle, - header: Self.buildHeaderProps( - style: confirmationStyle, - closeButtonCmd: Cmd(closure: { [weak self] in self?.delegate?(.closeTapped) }) - ), - checkMessageButtonTap: Cmd { [weak self] in self?.delegate?(.chatTranscriptScreenRequested) } +// MARK: - Private Methods +extension SecureConversations.ConfirmationViewSwiftUI.Model { + func makeHeaderModel() -> HeaderSwiftUI.Model { + let endButtonProps: ActionButtonSwiftUI.Model = .init( + style: style.header.endButton, + accessibilityIdentifier: "header_end_button", + isEnabled: false, + isHidden: true ) - let viewControllerProps = SecureConversations.ConfirmationViewController.Props( - confirmationViewProps: confirmationViewProps + let closeButtonProps: HeaderButtonSwiftUI.Model = .init( + tap: Cmd(closure: { [weak self] in + self?.delegate?(.closeTapped) + }), + style: style.header.closeButton, + isEnabled: true, + isHidden: false ) - return viewControllerProps - } + let endScreenShareButtonProps: HeaderButtonSwiftUI.Model = .init( + style: style.header.endScreenShareButton, + isEnabled: false, + isHidden: true + ) - static func buildHeaderProps( - style: SecureConversations.ConfirmationStyle, - closeButtonCmd: Cmd - ) -> Header.Props { - let backButton = style.header.backButton.map { HeaderButton.Props(style: $0) } + let environment: HeaderSwiftUI.Environment = .init(uiApplication: environment.uiApplication) - return Header.Props( + return .init( title: style.headerTitle, effect: .none, - endButton: .init(style: style.header.endButton, accessibilityIdentifier: "header_end_button"), - backButton: backButton, - closeButton: .init(tap: closeButtonCmd, style: style.header.closeButton), - endScreenshareButton: .init(style: style.header.endScreenShareButton), - style: style.header + endButton: endButtonProps, + backButton: nil, + closeButton: closeButtonProps, + endScreenshareButton: endScreenShareButtonProps, + style: style.header, + environment: environment ) } } -extension SecureConversations.ConfirmationViewModel { +// MARK: - Objects +extension SecureConversations.ConfirmationViewSwiftUI.Model { enum Event { case closeTapped - } - - enum Action { - case start + case chatTranscriptScreenRequested } enum DelegateEvent { case closeTapped - case renderProps(SecureConversations.ConfirmationViewController.Props) case chatTranscriptScreenRequested } - enum StartAction { - case none - } -} - -extension SecureConversations.ConfirmationViewModel { struct Environment { - var confirmationStyle: SecureConversations.ConfirmationStyle + var uiApplication: UIKitBased.UIApplication + var uiDevice: UIKitBased.UIDevice + var notificationCenter: FoundationBased.NotificationCenter } } diff --git a/GliaWidgets/SecureConversations/Confirmation/Theme+SecureConversationsConfirmation.swift b/GliaWidgets/SecureConversations/Confirmation/Theme+SecureConversationsConfirmation.swift index 942ae8ef7..b40c4f48b 100644 --- a/GliaWidgets/SecureConversations/Confirmation/Theme+SecureConversationsConfirmation.swift +++ b/GliaWidgets/SecureConversations/Confirmation/Theme+SecureConversationsConfirmation.swift @@ -12,7 +12,7 @@ extension Theme { let titleStyle = SecureConversations.ConfirmationStyle.TitleStyle( text: Confirmation.title, - font: font.header3, + font: font.header1, color: color.baseDark, accessibility: .init(isFontScalingEnabled: true) ) diff --git a/GliaWidgets/SecureConversations/SecureConversations.Coordinator.swift b/GliaWidgets/SecureConversations/SecureConversations.Coordinator.swift index 9eac3423e..3602fc828 100644 --- a/GliaWidgets/SecureConversations/SecureConversations.Coordinator.swift +++ b/GliaWidgets/SecureConversations/SecureConversations.Coordinator.swift @@ -160,29 +160,25 @@ extension SecureConversations { } func presentSecureConversationsConfirmationViewController() { - let viewModel = SecureConversations.ConfirmationViewModel( - environment: .init( - confirmationStyle: viewFactory.theme.secureConversationsConfirmation - ) + let environment: ConfirmationViewSwiftUI.Model.Environment = .init( + uiApplication: environment.uiApplication, + uiDevice: environment.uiDevice, + notificationCenter: environment.notificationCenter ) - let controller = SecureConversations.ConfirmationViewController( - viewModel: viewModel, - viewFactory: viewFactory, - props: viewModel.props() - ) + let model = SecureConversations.ConfirmationViewSwiftUI.Model( + environment: environment, + style: viewFactory.theme.secureConversationsConfirmation, + delegate: { [weak self] event in + switch event { + case .closeTapped: + self?.delegate?(.closeTapped(.doNotPresentSurvey)) + case .chatTranscriptScreenRequested: + self?.navigateToTranscript() + } + }) - viewModel.delegate = { [weak self, weak controller] event in - switch event { - case .closeTapped: - self?.delegate?(.closeTapped(.doNotPresentSurvey)) - // Bind changes in view model to view controller. - case let .renderProps(props): - controller?.props = props - case .chatTranscriptScreenRequested: - self?.navigateToTranscript() - } - } + let controller = SecureConversations.ConfirmationViewController(model: model) self.navigationPresenter.push( controller, @@ -326,6 +322,7 @@ extension SecureConversations.Coordinator { var uuid: () -> UUID var uiApplication: UIKitBased.UIApplication var uiScreen: UIKitBased.UIScreen + var uiDevice: UIKitBased.UIDevice var notificationCenter: FoundationBased.NotificationCenter var createFileUploadListModel: SecureConversations.FileUploadListViewModel.Create var viewFactory: ViewFactory diff --git a/GliaWidgets/Sources/Coordinators/EngagementCoordinator/EngagementCoordinator.swift b/GliaWidgets/Sources/Coordinators/EngagementCoordinator/EngagementCoordinator.swift index b68835c0c..2b48585f2 100644 --- a/GliaWidgets/Sources/Coordinators/EngagementCoordinator/EngagementCoordinator.swift +++ b/GliaWidgets/Sources/Coordinators/EngagementCoordinator/EngagementCoordinator.swift @@ -452,6 +452,7 @@ extension EngagementCoordinator { uuid: environment.uuid, uiApplication: environment.uiApplication, uiScreen: environment.uiScreen, + uiDevice: environment.uiDevice, notificationCenter: environment.notificationCenter, createFileUploadListModel: environment.createFileUploadListModel, viewFactory: viewFactory, diff --git a/GliaWidgets/Sources/FoundationBased/FoundationBased.Interface.swift b/GliaWidgets/Sources/FoundationBased/FoundationBased.Interface.swift index ef9375c23..1771ad388 100644 --- a/GliaWidgets/Sources/FoundationBased/FoundationBased.Interface.swift +++ b/GliaWidgets/Sources/FoundationBased/FoundationBased.Interface.swift @@ -1,4 +1,5 @@ import Foundation +import Combine enum FoundationBased { struct FileManager { @@ -92,6 +93,8 @@ enum FoundationBased { func removeObserver(_ observer: Any) { removeObserverClosure(observer) } + + var publisherForNotification: (NSNotification.Name) -> AnyPublisher } } diff --git a/GliaWidgets/Sources/FoundationBased/FoundationBased.Live.swift b/GliaWidgets/Sources/FoundationBased/FoundationBased.Live.swift index 1ec27b82f..9b7e3fe29 100644 --- a/GliaWidgets/Sources/FoundationBased/FoundationBased.Live.swift +++ b/GliaWidgets/Sources/FoundationBased/FoundationBased.Live.swift @@ -1,4 +1,5 @@ import Foundation +import Combine extension FoundationBased.FileManager { static let live = Self( @@ -65,6 +66,7 @@ extension FoundationBased.NotificationCenter { static let live = Self( addObserverClosure: NotificationCenter.default.addObserver, removeObserverClosure: NotificationCenter.default.removeObserver, - removeObserverWithNameAndObject: NotificationCenter.default.removeObserver + removeObserverWithNameAndObject: NotificationCenter.default.removeObserver, + publisherForNotification: { NotificationCenter.default.publisher(for: $0).eraseToAnyPublisher() } ) } diff --git a/GliaWidgets/Sources/FoundationBased/FoundationBased.Mock.swift b/GliaWidgets/Sources/FoundationBased/FoundationBased.Mock.swift index c82154df5..032c92f21 100644 --- a/GliaWidgets/Sources/FoundationBased/FoundationBased.Mock.swift +++ b/GliaWidgets/Sources/FoundationBased/FoundationBased.Mock.swift @@ -1,5 +1,6 @@ #if DEBUG import Foundation +import Combine extension FoundationBased.FileManager { static let mock = Self( @@ -50,7 +51,10 @@ extension FoundationBased.NotificationCenter { static let mock = Self( addObserverClosure: { _, _, _, _ in }, removeObserverClosure: { _ in }, - removeObserverWithNameAndObject: { _, _, _ in } + removeObserverWithNameAndObject: { _, _, _ in }, + publisherForNotification: { _ in + Just(Notification(name: NSNotification.Name(rawValue: ""))).eraseToAnyPublisher() + } ) } diff --git a/GliaWidgets/Sources/UIKitBased/UIKitBased.Interface.swift b/GliaWidgets/Sources/UIKitBased/UIKitBased.Interface.swift index 81bcc5349..c1f8a3ab4 100644 --- a/GliaWidgets/Sources/UIKitBased/UIKitBased.Interface.swift +++ b/GliaWidgets/Sources/UIKitBased/UIKitBased.Interface.swift @@ -17,6 +17,7 @@ enum UIKitBased { struct UIDevice { var proximityState: () -> Bool var isProximityMonitoringEnabled: (Bool) -> Void + var orientationDidChangeNotification: () -> NSNotification.Name } struct UIScreen { diff --git a/GliaWidgets/Sources/UIKitBased/UIKitBased.Live.swift b/GliaWidgets/Sources/UIKitBased/UIKitBased.Live.swift index 51ab52b4a..7527cf763 100644 --- a/GliaWidgets/Sources/UIKitBased/UIKitBased.Live.swift +++ b/GliaWidgets/Sources/UIKitBased/UIKitBased.Live.swift @@ -18,7 +18,8 @@ extension UIKitBased.UIApplication { extension UIKitBased.UIDevice { static let live = Self.init( proximityState: { UIDevice.current.proximityState }, - isProximityMonitoringEnabled: { UIDevice.current.isProximityMonitoringEnabled = $0 } + isProximityMonitoringEnabled: { UIDevice.current.isProximityMonitoringEnabled = $0 }, + orientationDidChangeNotification: { UIDevice.orientationDidChangeNotification } ) } diff --git a/GliaWidgets/Sources/UIKitBased/UIKitBased.Mock.swift b/GliaWidgets/Sources/UIKitBased/UIKitBased.Mock.swift index 458f5c3d8..3c0eb769d 100644 --- a/GliaWidgets/Sources/UIKitBased/UIKitBased.Mock.swift +++ b/GliaWidgets/Sources/UIKitBased/UIKitBased.Mock.swift @@ -29,7 +29,8 @@ extension UIImage { extension UIKitBased.UIDevice { static let mock = Self.init( proximityState: { .init() }, - isProximityMonitoringEnabled: { _ in } + isProximityMonitoringEnabled: { _ in }, + orientationDidChangeNotification: { .init("") } ) } diff --git a/GliaWidgets/Sources/ViewFactory/ViewFactory.swift b/GliaWidgets/Sources/ViewFactory/ViewFactory.swift index 0dfb41c53..5ed46237b 100644 --- a/GliaWidgets/Sources/ViewFactory/ViewFactory.swift +++ b/GliaWidgets/Sources/ViewFactory/ViewFactory.swift @@ -136,8 +136,4 @@ class ViewFactory { ) -> SecureConversations.WelcomeView { return .init(props: props, environment: environment) } - - func makeSecureConversationsConfirmationView(props: SecureConversations.ConfirmationView.Props) -> SecureConversations.ConfirmationView { - return .init(props: props) - } } diff --git a/GliaWidgets/SwiftUI/Components/Buttons/ActionButtonSwiftUI.swift b/GliaWidgets/SwiftUI/Components/Buttons/ActionButtonSwiftUI.swift new file mode 100644 index 000000000..dde080750 --- /dev/null +++ b/GliaWidgets/SwiftUI/Components/Buttons/ActionButtonSwiftUI.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct ActionButtonSwiftUI: View { + static let edgeInsets: EdgeInsets = .init(top: 6, leading: 16, bottom: 6, trailing: 16) + let model: Model + + var body: some View { + Text(model.style.title) + .font(.convert(model.style.titleFont)) + .foregroundColor(SwiftUI.Color(model.style.titleColor)) + .padding(Self.edgeInsets) + .background(SwiftUI.Color(model.style.backgroundColor.color)) + .cornerRadius(model.style.cornerRaidus ?? 0) + .overlay( + RoundedRectangle( + cornerRadius: model.style.cornerRaidus ?? 0) + .stroke( + SwiftUI.Color(model.style.borderColor ?? .clear), + lineWidth: model.style.borderWidth ?? 0 + ) + ) + .shadow( + color: SwiftUI.Color(model.style.shadowColor ?? .clear), + radius: model.style.shadowRadius ?? 0, + x: model.style.shadowOffset?.width ?? 0, + y: model.style.shadowOffset?.height ?? 0 + ) + .accessibility(identifier: model.accessibilityIdentifier) + .accessibility(addTraits: .isButton) + .accessibility(removeTraits: .isImage) + .onTapGesture(perform: model.tap.callAsFunction) + } +} + +extension ActionButtonSwiftUI { + final class Model: ObservableObject { + let style: ActionButtonStyle + let height: CGFloat + let tap: Cmd + let accessibilityIdentifier: String + @Published var isEnabled: Bool + @Published var isHidden: Bool + + init( + style: ActionButtonStyle = .init( + title: "", + titleFont: .systemFont(ofSize: 16), + titleColor: .white, + backgroundColor: .fill(color: .blue) + ), + height: CGFloat = 40, + tap: Cmd = .nop, + accessibilityIdentifier: String = "", + isEnabled: Bool, + isHidden: Bool + ) { + self.style = style + self.height = height + self.tap = tap + self.accessibilityIdentifier = accessibilityIdentifier.isEmpty ? style.title : accessibilityIdentifier + self.isEnabled = isEnabled + self.isHidden = isHidden + } + } +} diff --git a/GliaWidgets/SwiftUI/Components/Buttons/HeaderButtonSwiftUI.swift b/GliaWidgets/SwiftUI/Components/Buttons/HeaderButtonSwiftUI.swift new file mode 100644 index 000000000..7eff12e66 --- /dev/null +++ b/GliaWidgets/SwiftUI/Components/Buttons/HeaderButtonSwiftUI.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct HeaderButtonSwiftUI: View { + let model: Model + + var body: some View { + SwiftUI.Image(uiImage: model.style.image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame( + width: model.size.width, + height: model.size.height + ) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .contentShape(Rectangle()) + .foregroundColor(SwiftUI.Color(model.style.color)) + .opacity(model.isEnabled ? 1.0 : 0.6) + .accessibility(label: Text(model.style.accessibility.label)) + .accessibility(addTraits: .isButton) + .accessibility(removeTraits: .isImage) + .onTapGesture(perform: model.tap.callAsFunction) + } +} + +extension HeaderButtonSwiftUI { + final class Model: ObservableObject { + var tap: Cmd + var style: HeaderButtonStyle + var size: CGSize + var isEnabled: Bool + var isHidden: Bool + + init( + tap: Cmd = .nop, + style: HeaderButtonStyle, + size: CGSize = CGSize(width: 14, height: 14), + isEnabled: Bool, + isHidden: Bool + ) { + self.tap = tap + self.style = style + self.size = size + self.isEnabled = isEnabled + self.isHidden = isHidden + } + } +} diff --git a/GliaWidgets/SwiftUI/Components/Header/HeaderSwiftUI.swift b/GliaWidgets/SwiftUI/Components/Header/HeaderSwiftUI.swift new file mode 100644 index 000000000..8eb9d9aab --- /dev/null +++ b/GliaWidgets/SwiftUI/Components/Header/HeaderSwiftUI.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct HeaderSwiftUI: View { + let model: Model + + var body: some View { + ZStack(alignment: .bottom) { + HStack(spacing: 16) { + if let backButton = model.backButton { + HeaderButtonSwiftUI(model: backButton) + } + + Spacer() + if !model.endButton.isHidden { + ActionButtonSwiftUI(model: model.endButton) + } + + if !model.endScreenshareButton.isHidden { + HeaderButtonSwiftUI(model: model.endScreenshareButton) + } + + if !model.closeButton.isHidden { + HeaderButtonSwiftUI(model: model.closeButton) + } + } + Text(model.title) + .font(.convert(model.style.titleFont)) + .foregroundColor(SwiftUI.Color(model.style.titleColor)) + .accessibility(identifier: "header_view_title_label") + .accessibility(label: Text(model.title)) + .accessibility(addTraits: .isHeader) + } + .padding(.horizontal, 16) + .padding(.bottom, 12) + .frame( + height: 58 + (model.environment.uiApplication.windows().first?.safeAreaInsets.top ?? 0), + alignment: .bottom + ) + .background(SwiftUI.Color(model.style.backgroundColor.color)) + .edgesIgnoringSafeArea([.leading, .trailing]) + } +} + +extension HeaderSwiftUI { + final class Model: ObservableObject { + let title: String + let effect: Effect + let endButton: ActionButtonSwiftUI.Model + let backButton: HeaderButtonSwiftUI.Model? + let closeButton: HeaderButtonSwiftUI.Model + let endScreenshareButton: HeaderButtonSwiftUI.Model + let style: HeaderStyle + let environment: Environment + + init( + title: String, + effect: Effect, + endButton: ActionButtonSwiftUI.Model, + backButton: HeaderButtonSwiftUI.Model?, + closeButton: HeaderButtonSwiftUI.Model, + endScreenshareButton: HeaderButtonSwiftUI.Model, + style: HeaderStyle, + environment: Environment + ) { + self.title = title + self.effect = effect + self.endButton = endButton + self.backButton = backButton + self.closeButton = closeButton + self.endScreenshareButton = endScreenshareButton + self.style = style + self.environment = environment + } + } +} + +extension HeaderSwiftUI { + enum Effect { + case none + case blur + } + + struct Environment { + let uiApplication: UIKitBased.UIApplication + } +} diff --git a/GliaWidgets/SwiftUI/Extensions/Font+Extensions.swift b/GliaWidgets/SwiftUI/Extensions/Font+Extensions.swift new file mode 100644 index 000000000..7b20072ab --- /dev/null +++ b/GliaWidgets/SwiftUI/Extensions/Font+Extensions.swift @@ -0,0 +1,7 @@ +import SwiftUI + +extension Font { + static func convert(_ uiFont: UIFont) -> Font { + return .custom(uiFont.familyName + uiFont.fontName, size: uiFont.pointSize) + } +} diff --git a/GliaWidgets/SwiftUI/Managers/OrientationManager.swift b/GliaWidgets/SwiftUI/Managers/OrientationManager.swift new file mode 100644 index 000000000..78197aa92 --- /dev/null +++ b/GliaWidgets/SwiftUI/Managers/OrientationManager.swift @@ -0,0 +1,38 @@ +import SwiftUI +import Combine + +class OrientationManager: ObservableObject { + @Published private(set) var orientation: UIInterfaceOrientation + + private let environment: Environment + private var orientationSubscription: AnyCancellable? + private var currentOrientation: UIInterfaceOrientation { + environment.uiApplication.windows().first?.windowScene?.interfaceOrientation ?? .unknown + } + + var isPortrait: Bool { + orientation == .portrait || orientation == .portraitUpsideDown + } + + var isLandscape: Bool { + !isPortrait + } + + init(environment: Environment) { + self.environment = environment + orientation = environment.uiApplication.windows().first?.windowScene?.interfaceOrientation ?? .unknown + orientationSubscription = environment.notificationCenter + .publisherForNotification(environment.uiDevice.orientationDidChangeNotification()) + .map { _ in self.currentOrientation } + .removeDuplicates() + .assign(to: \.orientation, on: self) + } +} + +extension OrientationManager { + struct Environment { + var uiApplication: UIKitBased.UIApplication + var uiDevice: UIKitBased.UIDevice + var notificationCenter: FoundationBased.NotificationCenter + } +} diff --git a/GliaWidgetsTests/CallVisualizer/VideoCall/Mocks/HeaderStyle.Mock.swift b/GliaWidgetsTests/CallVisualizer/VideoCall/Mocks/HeaderStyle.Mock.swift index b16e5e1c4..b40ba6765 100644 --- a/GliaWidgetsTests/CallVisualizer/VideoCall/Mocks/HeaderStyle.Mock.swift +++ b/GliaWidgetsTests/CallVisualizer/VideoCall/Mocks/HeaderStyle.Mock.swift @@ -27,9 +27,10 @@ extension HeaderStyle { extension HeaderButtonStyle { static func mock( image: UIImage = .mock, - color: UIColor = .white + color: UIColor = .white, + accessibility: Accessibility = .init(label: "", hint: "") ) -> HeaderButtonStyle { - return .init(image: image, color: color) + return .init(image: image, color: color, accessibility: accessibility) } } diff --git a/GliaWidgetsTests/FoundationBased.Failing.swift b/GliaWidgetsTests/FoundationBased.Failing.swift index c3254f1bb..6dfa53e0a 100644 --- a/GliaWidgetsTests/FoundationBased.Failing.swift +++ b/GliaWidgetsTests/FoundationBased.Failing.swift @@ -1,4 +1,5 @@ @testable import GliaWidgets +import Combine extension FoundationBased.FileManager { static let failing = Self( @@ -89,6 +90,10 @@ extension FoundationBased.NotificationCenter { }, removeObserverWithNameAndObject: {_, _, _ in fail("\(Self.self).removeObserverWithNameAndObject") + }, + publisherForNotification: { _ in + fail("\(Self.self).publisherForNotification") + return Empty().eraseToAnyPublisher() } ) } diff --git a/GliaWidgetsTests/SecureConversations/Confirmation/SecureConversations.ConfirmationViewModel.Mock.swift b/GliaWidgetsTests/SecureConversations/Confirmation/SecureConversations.ConfirmationViewModel.Mock.swift index 383ed28f0..85b8203a4 100644 --- a/GliaWidgetsTests/SecureConversations/Confirmation/SecureConversations.ConfirmationViewModel.Mock.swift +++ b/GliaWidgetsTests/SecureConversations/Confirmation/SecureConversations.ConfirmationViewModel.Mock.swift @@ -1,14 +1,73 @@ -import Foundation +import UIKit @testable import GliaWidgets -extension SecureConversations.ConfirmationViewModel { - static let mock = SecureConversations.ConfirmationViewModel( - environment: .mock - ) +extension SecureConversations.ConfirmationStyle { + static func mock( + title: String = "", + titleStyle: TitleStyle = .mock(), + subtitleStyle: SubtitleStyle = .mock(), + checkMessagesButtonStyle: CheckMessagesButtonStyle = .mock() + ) -> SecureConversations.ConfirmationStyle { + .init( + header: .mock(), + headerTitle: title, + confirmationImage: .mock, + confirmationImageTint: Color.baseLight, + titleStyle: titleStyle, + subtitleStyle: subtitleStyle, + checkMessagesButtonStyle: checkMessagesButtonStyle, + backgroundColor: Color.baseLight + ) + } } -extension SecureConversations.ConfirmationViewModel.Environment { - static let mock = SecureConversations.ConfirmationViewModel.Environment( - confirmationStyle: Theme().secureConversationsConfirmation - ) +extension SecureConversations.ConfirmationStyle.TitleStyle { + static func mock( + text: String = "Title label", + font: UIFont = ThemeFont().header1, + color: UIColor = Color.baseDark, + accessibility: Accessibility = .unsupported + ) -> SecureConversations.ConfirmationStyle.TitleStyle { + return .init( + text: text, + font: font, + color: color, + accessibility: accessibility + ) + } } + +extension SecureConversations.ConfirmationStyle.SubtitleStyle { + static func mock( + text: String = "Title label", + font: UIFont = ThemeFont().header1, + color: UIColor = Color.baseDark, + accessibility: Accessibility = .unsupported + ) -> SecureConversations.ConfirmationStyle.SubtitleStyle { + return .init( + text: text, + font: font, + color: color, + accessibility: accessibility + ) + } +} + +extension SecureConversations.ConfirmationStyle.CheckMessagesButtonStyle { + static func mock( + title: String = "Title label", + font: UIFont = ThemeFont().header1, + textColor: UIColor = Color.baseDark, + backgroundColor: UIColor = Color.baseLight, + accessibility: Accessibility = .unsupported + ) -> SecureConversations.ConfirmationStyle.CheckMessagesButtonStyle { + return .init( + title: title, + font: font, + textColor: textColor, + backgroundColor: backgroundColor, + accessibility: accessibility + ) + } +} + diff --git a/GliaWidgetsTests/SecureConversations/Confirmation/SecureConversations.ConfirmationViewModelTests.swift b/GliaWidgetsTests/SecureConversations/Confirmation/SecureConversations.ConfirmationViewModelTests.swift index ddf345042..6287e95c6 100644 --- a/GliaWidgetsTests/SecureConversations/Confirmation/SecureConversations.ConfirmationViewModelTests.swift +++ b/GliaWidgetsTests/SecureConversations/Confirmation/SecureConversations.ConfirmationViewModelTests.swift @@ -3,37 +3,39 @@ import XCTest @testable import GliaWidgets final class SecureConversationsConfirmationViewModelTests: XCTestCase { - typealias ConfirmationViewModel = SecureConversations.ConfirmationViewModel - var viewModel: ConfirmationViewModel = .mock - - override func setUp() { - viewModel = .mock - } + typealias ConfirmationViewModel = SecureConversations.ConfirmationViewSwiftUI.Model + + var viewModel: ConfirmationViewModel = .init( + environment: .init( + uiApplication: .mock, + uiDevice: .mock, + notificationCenter: .mock + ), + style: Theme().defaultSecureConversationsConfirmationStyle, + delegate: nil + ) } // Props extension SecureConversationsConfirmationViewModelTests { func testPropsDoNotGenerateABackButton() { - let props = viewModel.props().confirmationViewProps.header + let backButton = viewModel.style.header.backButton - XCTAssertNil(props.backButton) + XCTAssertNil(backButton) } func testPropsGenerateCorrectTitle() { let title = "Test" - var style = Theme().secureConversationsConfirmation - style.headerTitle = title - - viewModel.environment = .init(confirmationStyle: style) - - let props = viewModel.props() - XCTAssertEqual(props.confirmationViewProps.header.title, title) - } - - func testPropsGenerateEndButtonWithAccessibilityIdentifier() { - let props = viewModel.props().confirmationViewProps.header.endButton - - XCTAssertEqual(props.accessibilityIdentifier, "header_end_button") + viewModel = .init( + environment: .init( + uiApplication: .mock, + uiDevice: .mock, + notificationCenter: .mock + ), + style: .mock(title: title), + delegate: nil + ) + XCTAssertEqual(viewModel.style.headerTitle, title) } } @@ -58,11 +60,18 @@ extension SecureConversationsConfirmationViewModelTests { func testPressingCloseButtonCallsDelegate() throws { var receivedEvent: ConfirmationViewModel.DelegateEvent? - viewModel.delegate = { event in - receivedEvent = event - } - - viewModel.props().confirmationViewProps.header.closeButton.tap() + viewModel = .init( + environment: .init( + uiApplication: .mock, + uiDevice: .mock, + notificationCenter: .mock + ), + style: .mock(), + delegate: { event in + receivedEvent = event + } + ) + viewModel.delegate?(.closeTapped) switch try XCTUnwrap(receivedEvent) { case .closeTapped: @@ -74,11 +83,18 @@ extension SecureConversationsConfirmationViewModelTests { func testPressingCheckMessagesButtonCallsDelegate() throws { var receivedEvent: ConfirmationViewModel.DelegateEvent? - viewModel.delegate = { event in - receivedEvent = event - } - - viewModel.props().confirmationViewProps.checkMessageButtonTap() + viewModel = .init( + environment: .init( + uiApplication: .mock, + uiDevice: .mock, + notificationCenter: .mock + ), + style: .mock(), + delegate: { event in + receivedEvent = event + } + ) + viewModel.delegate?(.chatTranscriptScreenRequested) switch try XCTUnwrap(receivedEvent) { case .chatTranscriptScreenRequested: @@ -86,20 +102,4 @@ extension SecureConversationsConfirmationViewModelTests { default: XCTFail() } } - - func testReportingAChangeRendersProps() throws { - var receivedEvent: ConfirmationViewModel.DelegateEvent? - - viewModel.delegate = { event in - receivedEvent = event - } - - viewModel.reportChange() - - switch try XCTUnwrap(receivedEvent) { - case .renderProps(_): - XCTAssertTrue(true) - default: XCTFail() - } - } } diff --git a/GliaWidgetsTests/UIKitBased.Failing.swift b/GliaWidgetsTests/UIKitBased.Failing.swift index baf3fa66a..6edfaf70b 100644 --- a/GliaWidgetsTests/UIKitBased.Failing.swift +++ b/GliaWidgetsTests/UIKitBased.Failing.swift @@ -65,6 +65,10 @@ extension UIKitBased.UIDevice { }, isProximityMonitoringEnabled: { _ in fail("\(Self.self).isProximityMonitoringEnabled") + }, + orientationDidChangeNotification: { + fail("\(Self.self).orientationDidChangeNotification") + return NSNotification.Name(rawValue: "") } ) } diff --git a/SnapshotTests/SecureConversationsConfirmationScreenDynamicTypeFontTests.swift b/SnapshotTests/SecureConversationsConfirmationScreenDynamicTypeFontTests.swift index 134c38231..977197ac2 100644 --- a/SnapshotTests/SecureConversationsConfirmationScreenDynamicTypeFontTests.swift +++ b/SnapshotTests/SecureConversationsConfirmationScreenDynamicTypeFontTests.swift @@ -7,41 +7,23 @@ final class SecureConversationsConfirmationScreenDynamicTypeFontTests: SnapshotT let theme = Theme.mock() func test_confirmationView_extra3Large() { - let props = Self.makeConfirmationProps(style: theme.secureConversationsConfirmation) - let viewController = SecureConversations.ConfirmationViewController( - viewModel: .init(environment: .init(confirmationStyle: theme.defaultSecureConversationsConfirmationStyle)), - viewFactory: .mock(theme: theme, messageRenderer: nil, environment: .mock), - props: props + let model: SecureConversations.ConfirmationViewSwiftUI.Model = .init( + environment: .init( + uiApplication: .mock, + uiDevice: .mock, + notificationCenter: .mock + ), + style: theme.defaultSecureConversationsConfirmationStyle, + delegate: nil ) + let viewController = SecureConversations.ConfirmationViewController(model: model) viewController.view.frame = UIScreen.main.bounds assertSnapshot( matching: viewController.view, as: .extra3LargeFontStrategy, - named: self.nameForDevice() - ) - } - - // MARK: - Helpers - - static func headerProps() -> Header.Props { - .mock( - title: "Secure Conversations", - backButton: .init(style: .mock(image: Asset.back.image)), - closeButton: .init(style: .mock(image: Asset.close.image)) - ) - } - - static func makeConfirmationProps( - headerProps: Header.Props = headerProps(), - style: SecureConversations.ConfirmationStyle - ) -> SecureConversations.ConfirmationViewController.Props { - .init( - confirmationViewProps: .init( - style: style, - header: headerProps, - checkMessageButtonTap: .nop - ) + named: self.nameForDevice(), + record: true ) } } diff --git a/SnapshotTests/SecureConversationsConfirmationScreenLayoutTests.swift b/SnapshotTests/SecureConversationsConfirmationScreenLayoutTests.swift index 41a2645fe..1805933ad 100644 --- a/SnapshotTests/SecureConversationsConfirmationScreenLayoutTests.swift +++ b/SnapshotTests/SecureConversationsConfirmationScreenLayoutTests.swift @@ -6,41 +6,23 @@ class SecureConversationsConfirmationScreenLayoutTests: SnapshotTestCase { let theme = Theme.mock() func test_confirmationView() { - let props = Self.makeConfirmationProps(style: theme.secureConversationsConfirmation) - let viewController = SecureConversations.ConfirmationViewController( - viewModel: .init(environment: .init(confirmationStyle: theme.defaultSecureConversationsConfirmationStyle)), - viewFactory: .mock(theme: theme, messageRenderer: nil, environment: .mock), - props: props + let model: SecureConversations.ConfirmationViewSwiftUI.Model = .init( + environment: .init( + uiApplication: .mock, + uiDevice: .mock, + notificationCenter: .mock + ), + style: theme.defaultSecureConversationsConfirmationStyle, + delegate: nil ) + let viewController = SecureConversations.ConfirmationViewController(model: model) viewController.view.frame = UIScreen.main.bounds assertSnapshot( matching: viewController.view, as: .image, - named: self.nameForDevice() - ) - } - - // MARK: - Helpers - - static func headerProps() -> Header.Props { - .mock( - title: "Secure Conversations", - backButton: .init(style: .mock(image: Asset.back.image)), - closeButton: .init(style: .mock(image: Asset.close.image)) - ) - } - - static func makeConfirmationProps( - headerProps: Header.Props = headerProps(), - style: SecureConversations.ConfirmationStyle - ) -> SecureConversations.ConfirmationViewController.Props { - .init( - confirmationViewProps: .init( - style: style, - header: headerProps, - checkMessageButtonTap: .nop - ) + named: self.nameForDevice(), + record: true ) } } diff --git a/SnapshotTests/SecureConversationsConfirmationScreenTests.swift b/SnapshotTests/SecureConversationsConfirmationScreenTests.swift index 587292207..8fbd75882 100644 --- a/SnapshotTests/SecureConversationsConfirmationScreenTests.swift +++ b/SnapshotTests/SecureConversationsConfirmationScreenTests.swift @@ -7,41 +7,23 @@ class SecureConversationsConfirmationScreenTests: SnapshotTestCase { let theme = Theme.mock() func test_confirmationView() { - let props = Self.makeConfirmationProps(style: theme.secureConversationsConfirmation) - let viewController = SecureConversations.ConfirmationViewController( - viewModel: .init(environment: .init(confirmationStyle: theme.defaultSecureConversationsConfirmationStyle)), - viewFactory: .mock(theme: theme, messageRenderer: nil, environment: .mock), - props: props + let model: SecureConversations.ConfirmationViewSwiftUI.Model = .init( + environment: .init( + uiApplication: .mock, + uiDevice: .mock, + notificationCenter: .mock + ), + style: theme.defaultSecureConversationsConfirmationStyle, + delegate: nil ) + let viewController = SecureConversations.ConfirmationViewController(model: model) viewController.view.frame = UIScreen.main.bounds assertSnapshot( matching: viewController.view, as: .accessibilityImage(precision: Self.possiblePrecision), - named: self.nameForDevice() - ) - } - - // MARK: - Helpers - - static func headerProps() -> Header.Props { - .mock( - title: "Secure Conversations", - backButton: .init(style: .mock(image: Asset.back.image)), - closeButton: .init(style: .mock(image: Asset.close.image)) - ) - } - - static func makeConfirmationProps( - headerProps: Header.Props = headerProps(), - style: SecureConversations.ConfirmationStyle - ) -> SecureConversations.ConfirmationViewController.Props { - .init( - confirmationViewProps: .init( - style: style, - header: headerProps, - checkMessageButtonTap: .nop - ) + named: self.nameForDevice(), + record: true ) } }