diff --git a/apps/ios/GuideDogs.xcodeproj/project.pbxproj b/apps/ios/GuideDogs.xcodeproj/project.pbxproj index e344b2531..001d848eb 100644 --- a/apps/ios/GuideDogs.xcodeproj/project.pbxproj +++ b/apps/ios/GuideDogs.xcodeproj/project.pbxproj @@ -748,6 +748,7 @@ D30355D31B61401D00E1DDED /* GDAJSONObject.m in Sources */ = {isa = PBXBuildFile; fileRef = D30355D21B61401D00E1DDED /* GDAJSONObject.m */; }; D395B0F71B41DA15005A6407 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D395B0F61B41DA15005A6407 /* Images.xcassets */; }; D4A72B0131A1000100C0FFEE /* URLResourceIdentifierTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A72B0031A1000100C0FFEE /* URLResourceIdentifierTest.swift */; }; + F2A1000131B0000100C0FFEE /* MarkerTutorialViewStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2A1000031B0000100C0FFEE /* MarkerTutorialViewStateTests.swift */; }; DCAAD87F2BC1E95F0018D135 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = DCAAD87E2BC1E95F0018D135 /* PrivacyInfo.xcprivacy */; }; DCE4D2AD2D7CE40F00B5DA4B /* NewFeatures.json in Resources */ = {isa = PBXBuildFile; fileRef = DCE4D2AC2D7CE40F00B5DA4B /* NewFeatures.json */; }; /* End PBXBuildFile section */ @@ -1332,6 +1333,7 @@ 915FF9F42ADE3F91002B3690 /* AuthoredActivityContentTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthoredActivityContentTest.swift; sourceTree = ""; }; 91C82AAC2A5DCF040086D126 /* GeolocationManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = " GeolocationManagerTest.swift"; path = "UnitTests/Sensors/Geolocation/Geolocation Manager/ GeolocationManagerTest.swift"; sourceTree = SOURCE_ROOT; }; 91C82ABD2A6B08500086D126 /* RouteGuidanceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = RouteGuidanceTest.swift; path = "UnitTests/Behaviors/Route Guidance/RouteGuidanceTest.swift"; sourceTree = SOURCE_ROOT; }; + F2A1000031B0000100C0FFEE /* MarkerTutorialViewStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MarkerTutorialViewStateTests.swift; path = UnitTests/MarkerTutorialViewStateTests.swift; sourceTree = SOURCE_ROOT; }; 9A69906E2D5BDB8900D37B7D /* BoseFramesBLEDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BoseFramesBLEDevice.swift; path = Code/Devices/BLE/BoseFramesBLEDevice.swift; sourceTree = ""; }; 9A69906F2D5BDB8900D37B7D /* BoseSensorConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BoseSensorConfiguration.swift; path = Code/Devices/BLE/BoseSensorConfiguration.swift; sourceTree = ""; }; 9A6990702D5BDB8900D37B7D /* BoseSensorDataProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BoseSensorDataProcessor.swift; path = Code/Devices/BLE/BoseSensorDataProcessor.swift; sourceTree = ""; }; @@ -3509,6 +3511,7 @@ 914DEBCE2A3CE6B9007B161C /* UnitTests */ = { isa = PBXGroup; children = ( + F2A1000031B0000100C0FFEE /* MarkerTutorialViewStateTests.swift */, 914BAAFB2AD747C200CB2171 /* Audio */, 91C82ABB2A6B03790086D126 /* Behaviors */, 91C82AB52A67182E0086D126 /* Data */, @@ -4744,6 +4747,7 @@ 914BAB012AD7490100CB2171 /* NavigationControllerTest.swift in Sources */, D4A72B0131A1000100C0FFEE /* URLResourceIdentifierTest.swift in Sources */, D4A72B1131A2000100C0FFEE /* LocalizationContextTest.swift in Sources */, + F2A1000131B0000100C0FFEE /* MarkerTutorialViewStateTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Help/HelpViewController.swift b/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Help/HelpViewController.swift index 7ef273f6a..bc8f07ed0 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Help/HelpViewController.swift +++ b/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Help/HelpViewController.swift @@ -3,6 +3,7 @@ // Soundscape // // Copyright (c) Microsoft Corporation. +// Copyright (c) Soundscape Community Contributors. // Licensed under the MIT License. // @@ -272,7 +273,6 @@ class HelpViewController: BaseTableViewController { static let OpenHelpPage = "OpenHelpPage" static let OpenGenericHelpPage = "OpenGenericHelpPage" static let OpenDestinationTutorial = "destinationTutorial" - static let OpenMarkerTutorial = "markerTutorial" static let OpenOfflinePage = "showOfflineInfo" } @@ -414,7 +414,8 @@ class HelpViewController: BaseTableViewController { if indexPath.row == Row.destinations { performSegue(withIdentifier: Segues.OpenDestinationTutorial, sender: self) } else if indexPath.row == Row.markers { - performSegue(withIdentifier: Segues.OpenMarkerTutorial, sender: self) + let viewController = MarkerTutorialViewController(logContext: "help_screen") + navigationController?.pushViewController(viewController, animated: true) } default: @@ -488,9 +489,6 @@ class HelpViewController: BaseTableViewController { case let destinationVC as DestinationTutorialIntroViewController: destinationVC.logContext = "help_screen" - case let markerVC as MarkerTutorialViewController: - markerVC.logContext = "help_screen" - default: return } diff --git a/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Home/HomeViewController.swift b/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Home/HomeViewController.swift index b4383e4c3..765460d7d 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Home/HomeViewController.swift +++ b/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Home/HomeViewController.swift @@ -428,8 +428,6 @@ class HomeViewController: UIViewController { } else if let vc = segue.destination as? DestinationTutorialIntroViewController { vc.source = self vc.logContext = telemetryContext - } else if let vc = segue.destination as? MarkerTutorialViewController { - vc.logContext = telemetryContext } else if let vc = segue.destination as? StandbyViewController { vc.delegate = self } else if let vc = segue.destination as? LocationDetailViewController { diff --git a/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Tutorials/Markers/MarkerTutorialViewController.swift b/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Tutorials/Markers/MarkerTutorialViewController.swift index 03ecb9721..fc083d1a3 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Tutorials/Markers/MarkerTutorialViewController.swift +++ b/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Tutorials/Markers/MarkerTutorialViewController.swift @@ -3,6 +3,7 @@ // Soundscape // // Copyright (c) Microsoft Corporation. +// Copyright (c) Soundscape Community Contributors. // Licensed under the MIT License. // @@ -16,9 +17,12 @@ class MarkerTutorialViewController: BaseTutorialViewController { // MARK: Types - private struct Segues { - static let showAddMarker = "showAddMarker" - static let showSetAudioBeacon = "showSetAudioBeacon" + private struct MarkerTutorialPage { + let title: String + let image: UIImage + let text: String + let buttonTitle: String? + let action: MarkerTutorialAction? } private struct PageIndexes { @@ -31,48 +35,47 @@ class MarkerTutorialViewController: BaseTutorialViewController { // MARK: Properties - private let tutorial = Tutorial(pages: [ + private let tutorial = [ // Intro - Page(title: GDLocalizedString("tutorial.markers.getting_started"), - image: UIImage(named: "Markers tutorial - 01")!, - text: GDLocalizedString("tutorial.markers.text.Intro"), - buttonTitle: GDLocalizedString("tutorial.markers.add_marker"), - buttonAction: #selector(addMarkerAction)), + MarkerTutorialPage(title: GDLocalizedString("tutorial.markers.getting_started"), + image: UIImage(named: "Markers tutorial - 01")!, + text: GDLocalizedString("tutorial.markers.text.Intro"), + buttonTitle: GDLocalizedString("tutorial.markers.add_marker"), + action: .addMarker), // Edit Marker - Page(title: GDLocalizedString("tutorial.markers.mark_your_world"), - image: UIImage(named: "Markers tutorial - 02")!, - text: GDLocalizedString("tutorial.markers.text.EditMarker"), - buttonTitle: nil, - buttonAction: nil), - Page(title: GDLocalizedString("tutorial.markers.mark_your_world"), - image: UIImage(named: "Markers tutorial - 02")!, - text: GDLocalizationUnnecessary("placeholder_page_for_select_marker_screen"), - buttonTitle: nil, - buttonAction: nil), + MarkerTutorialPage(title: GDLocalizedString("tutorial.markers.mark_your_world"), + image: UIImage(named: "Markers tutorial - 02")!, + text: GDLocalizedString("tutorial.markers.text.EditMarker"), + buttonTitle: nil, + action: nil), + MarkerTutorialPage(title: GDLocalizedString("tutorial.markers.mark_your_world"), + image: UIImage(named: "Markers tutorial - 02")!, + text: GDLocalizationUnnecessary("placeholder_page_for_select_marker_screen"), + buttonTitle: nil, + action: nil), // Nearby Markers - Page(title: GDLocalizedString("tutorial.markers.experience_your_world"), - image: UIImage(named: "Markers tutorial - 04")!, - text: GDLocalizedString("tutorial.markers.text.NearbyMarkers"), - buttonTitle: GDLocalizedString("callouts.nearby_markers"), - buttonAction: #selector(nearbyMarkersAction)), + MarkerTutorialPage(title: GDLocalizedString("tutorial.markers.experience_your_world"), + image: UIImage(named: "Markers tutorial - 04")!, + text: GDLocalizedString("tutorial.markers.text.NearbyMarkers"), + buttonTitle: GDLocalizedString("callouts.nearby_markers"), + action: .nearbyMarkers), // Audio Beacon - Page(title: GDLocalizedString("tutorial.markers.experience_your_world"), - image: UIImage(named: "Markers tutorial - 04")!, - text: GDLocalizedString("tutorial.markers.text.AudioBeacon"), - buttonTitle: nil, - buttonAction: nil), + MarkerTutorialPage(title: GDLocalizedString("tutorial.markers.experience_your_world"), + image: UIImage(named: "Markers tutorial - 04")!, + text: GDLocalizedString("tutorial.markers.text.AudioBeacon"), + buttonTitle: nil, + action: nil), // Wrap Up - Page(title: GDLocalizedString("tutorial.markers.experience_your_world"), - image: UIImage(named: "Markers tutorial - 05")!, - text: GDLocalizedString("tutorial.markers.text.WrapUp"), - buttonTitle: nil, - buttonAction: nil) - ] - ) + MarkerTutorialPage(title: GDLocalizedString("tutorial.markers.experience_your_world"), + image: UIImage(named: "Markers tutorial - 05")!, + text: GDLocalizedString("tutorial.markers.text.WrapUp"), + buttonTitle: nil, + action: nil) + ] // These page indexes represents pages that text should be delayed to accommodate VoiceOver callouts private var pageIndexesForVoiceOverDelayedPlay: [Int] { @@ -89,16 +92,8 @@ class MarkerTutorialViewController: BaseTutorialViewController { var player: FadeableAudioPlayer? private var presentedAlertController: UIAlertController? - - // UI Outlets - - @IBOutlet weak var scrollView: UIScrollView! - @IBOutlet weak var headTitle: UILabel! - @IBOutlet weak var imageView: UIImageView! - @IBOutlet weak var actionButton: UIButton! - @IBOutlet weak var exitButton: UIButton! - @IBOutlet weak var actionButtonLabel: UILabel! - var scrollViewBottomConstraint: NSLayoutConstraint? + private let viewState = MarkerTutorialViewState(exitTitle: GDLocalizedString("tutorial.skip")) + private var hostingController: UIHostingController? // Tutorial state properties @@ -111,10 +106,51 @@ class MarkerTutorialViewController: BaseTutorialViewController { private var didSetAudioBeacon = false /// Used for telemetry to identify the context (source) of the viewing this screen - var logContext: String? + private(set) var logContext: String? + + // MARK: Initialization + + init(logContext: String? = nil) { + self.logContext = logContext + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } // MARK: Lifecycle + override func loadView() { + let rootView = MarkerTutorialView( + state: viewState, + onAction: { [weak self] action in + self?.perform(action) + }, + onExit: { [weak self] in + self?.tryExitTutorial() + } + ) + let hostingController = UIHostingController(rootView: rootView) + + addChild(hostingController) + view = UIView() + view.backgroundColor = UIColor(red: 36.0 / 255.0, + green: 58.0 / 255.0, + blue: 102.0 / 255.0, + alpha: 1) + view.addSubview(hostingController.view) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + hostingController.didMove(toParent: self) + self.hostingController = hostingController + } + override func viewDidLoad() { super.viewDidLoad() @@ -127,12 +163,6 @@ class MarkerTutorialViewController: BaseTutorialViewController { toggleAppCalloutsOff() clearView() started = true - headTitle.isAccessibilityElement = true - pageTextLabel.isAccessibilityElement = true - - exitButton.setTitle(GDLocalizedString("tutorial.skip"), for: .normal) - - configureScrollViewBottomConstraint() GDATelemetry.trackScreenView("tutorial.markers", with: logContext == nil ? nil : ["context": logContext!]) @@ -209,6 +239,16 @@ class MarkerTutorialViewController: BaseTutorialViewController { super.stop() } + + override internal func updatePageText(_ text: String) { + DispatchQueue.main.async { [weak self] in + guard self?.viewState.text != text else { + return + } + + self?.viewState.updateText(text) + } + } // MARK: Tutorial Flow @@ -227,29 +267,31 @@ class MarkerTutorialViewController: BaseTutorialViewController { } private func showPage(with index: UInt) { - guard index <= tutorial.pages.count else { + guard index < tutorial.count else { return } currentPageIndex = index - show(page: tutorial.pages[Int(index)]) + show(page: tutorial[Int(index)]) } - private func show(page: Page) { + private func show(page: MarkerTutorialPage) { if currentPageIndex! == PageIndexes.editMarker { editMarkerAction() return } DispatchQueue.main.async { - self.headTitle.text = page.title - self.imageView.image = page.image + self.viewState.show(title: page.title, + image: page.image, + text: nil, + actionTitle: page.buttonTitle, + action: page.action, + hidesContentFromAccessibility: self.currentPageIndex != UInt(PageIndexes.intro)) } - changeActionButton(with: page.buttonTitle, action: page.buttonAction) if page.buttonTitle == nil { - hideActionButton(true) UIAccessibility.post(notification: UIAccessibility.Notification.layoutChanged, argument: nil) } else if currentPageIndex! == PageIndexes.intro { // Clear destination if needed @@ -258,12 +300,10 @@ class MarkerTutorialViewController: BaseTutorialViewController { // Hush speech or beacon if playing AppContext.shared.eventProcessor.hush(playSound: false) - hideActionButton(false) - actionButton.showAnimated { [weak self] (_) in + DispatchQueue.main.async { [weak self] in + self?.viewState.revealAction() UIAccessibility.post(notification: UIAccessibility.Notification.layoutChanged, argument: nil) - self?.configureScrollViewBottomConstraint() } - actionButtonLabel.showAnimated() } guard let currentPageIndex = currentPageIndex, currentPageIndex > 0 else { @@ -271,9 +311,6 @@ class MarkerTutorialViewController: BaseTutorialViewController { return } - headTitle.isAccessibilityElement = false - pageTextLabel.isAccessibilityElement = false - playMusic() let pageIndexBeforeTextPlay = self.currentPageIndex @@ -282,7 +319,7 @@ class MarkerTutorialViewController: BaseTutorialViewController { let shouldDelayTextPlay = UIAccessibility.isVoiceOverRunning && pageIndexesForVoiceOverDelayedPlay.contains(Int(currentPageIndex)) if shouldDelayTextPlay { // Clear current text before delay - self.pageTextLabel.text = nil + self.viewState.updateText(nil) } play(delay: shouldDelayTextPlay ? 1.5 : 0.0, text: page.text) { [weak self] (_) in @@ -303,11 +340,10 @@ class MarkerTutorialViewController: BaseTutorialViewController { if page.buttonTitle == nil { self.showNextPage() } else { - self.actionButton.showAnimated { [weak self] (_) in - UIAccessibility.post(notification: UIAccessibility.Notification.layoutChanged, argument: self?.actionButton) - self?.configureScrollViewBottomConstraint() + DispatchQueue.main.async { [weak self] in + self?.viewState.revealAction() + UIAccessibility.post(notification: UIAccessibility.Notification.layoutChanged, argument: nil) } - self.actionButtonLabel.showAnimated() } } } @@ -336,18 +372,34 @@ class MarkerTutorialViewController: BaseTutorialViewController { private func hasFinishedTutorial() -> Bool { guard let currentPageIndex = currentPageIndex else { return false } - return currentPageIndex == tutorial.pages.count - 1 + return currentPageIndex == tutorial.count - 1 } // MARK: UI Actions - - @objc func addMarkerAction() { + + private func perform(_ action: MarkerTutorialAction) { + switch action { + case .addMarker: + addMarkerAction() + case .nearbyMarkers: + nearbyMarkersAction() + } + } + + private func addMarkerAction() { GDATelemetry.track("tutorial.markers.add_marker") - self.performSegue(withIdentifier: Segues.showAddMarker, sender: nil) + let storyboard = UIStoryboard(name: "POITable", bundle: .main) + guard let viewController = storyboard.instantiateInitialViewController() as? SearchTableViewController else { + GDLogAppError("Marker tutorial: unable to load POI table.") + return + } + + viewController.delegate = self + navigationController?.pushViewController(viewController, animated: true) } - @objc func editMarkerAction() { + private func editMarkerAction() { let detail: LocationDetail if let referenceEntity = referenceEntity { @@ -368,7 +420,7 @@ class MarkerTutorialViewController: BaseTutorialViewController { } } - @objc func nearbyMarkersAction() { + private func nearbyMarkersAction() { guard !pressedNearbyMarkers else { return } @@ -387,10 +439,6 @@ class MarkerTutorialViewController: BaseTutorialViewController { AppContext.process(event) } - @objc func audioBeaconAction() { - self.performSegue(withIdentifier: Segues.showSetAudioBeacon, sender: nil) - } - private func presentExitAlert() { let alert = UIAlertController(title: GDLocalizedString("tutorial.exit.alert_title"), message: nil, @@ -407,8 +455,8 @@ class MarkerTutorialViewController: BaseTutorialViewController { presentedAlertController = alert } - @IBAction func tryExitTutorial() { - if currentPageIndex! == PageIndexes.intro { + private func tryExitTutorial() { + if currentPageIndex == UInt(PageIndexes.intro) { // Do not present alert if user is on the intro page exitTutorial() } else { @@ -450,43 +498,8 @@ class MarkerTutorialViewController: BaseTutorialViewController { } } - // MARK: UI Changes - - private func changeActionButton(with title: String?, action: Selector?) { - actionButtonLabel.text = title - actionButton.accessibilityLabel = title - - // Remove all assosiated actions - self.actionButton.removeTarget(nil, action: nil, for: .allEvents) - - // Add new action - if let action = action { - self.actionButton.addTarget(self, action: action, for: .touchUpInside) - } - } - - private func hideActionButton(_ hide: Bool) { - actionButton.isHidden = hide - actionButtonLabel.isHidden = hide - - configureScrollViewBottomConstraint() - } - - private func configureScrollViewBottomConstraint() { - if actionButton.isHidden, let constraint = scrollViewBottomConstraint { - constraint.isActive = false - } else { - scrollViewBottomConstraint = scrollView.bottomAnchor.constraint(equalTo: actionButton.topAnchor, constant: -12) - scrollViewBottomConstraint!.isActive = true - } - } - private func clearView() { - headTitle.text = nil - imageView.image = nil - pageTextLabel.text = nil - hideActionButton(true) - changeActionButton(with: nil, action: nil) + viewState.clear() } // MARK: Audio Beacon @@ -528,23 +541,6 @@ class MarkerTutorialViewController: BaseTutorialViewController { } } - // MARK: Navigation - - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - super.prepare(for: segue, sender: sender) - - if let vc = segue.destination as? SearchTableViewController { - vc.delegate = self - } - } - - @IBAction func unwindToMarkerTutorial(segue: UIStoryboardSegue) { - // TODO: How should this be handled without the unwind segue occurring anymore -// if segue.source is EditMarkerViewHostViewController { -// markerEdited = referenceEntity != nil -// } - } - } // MARK: POITableViewDelegate diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/Help.storyboard b/apps/ios/GuideDogs/Code/Visual UI/Views/Help.storyboard index d37e891e3..fd3bf851c 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/Views/Help.storyboard +++ b/apps/ios/GuideDogs/Code/Visual UI/Views/Help.storyboard @@ -59,7 +59,6 @@ - @@ -68,16 +67,6 @@ - - - - - - - - - - diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/MarkerTutorial.storyboard b/apps/ios/GuideDogs/Code/Visual UI/Views/MarkerTutorial.storyboard deleted file mode 100644 index 459eafe24..000000000 --- a/apps/ios/GuideDogs/Code/Visual UI/Views/MarkerTutorial.storyboard +++ /dev/null @@ -1,182 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/Tutorials/MarkerTutorialView.swift b/apps/ios/GuideDogs/Code/Visual UI/Views/Tutorials/MarkerTutorialView.swift new file mode 100644 index 000000000..189a5e20f --- /dev/null +++ b/apps/ios/GuideDogs/Code/Visual UI/Views/Tutorials/MarkerTutorialView.swift @@ -0,0 +1,164 @@ +// +// MarkerTutorialView.swift +// Soundscape +// +// Copyright (c) Soundscape Community Contributors. +// Licensed under the MIT License. +// + +import SwiftUI +import UIKit + +enum MarkerTutorialAction: Equatable { + case addMarker + case nearbyMarkers +} + +final class MarkerTutorialViewState: ObservableObject { + @Published private(set) var title: String? + @Published private(set) var image: UIImage? + @Published private(set) var text: String? + @Published private(set) var actionTitle: String? + @Published private(set) var action: MarkerTutorialAction? + @Published private(set) var isActionVisible = false + @Published private(set) var exitTitle: String + @Published private(set) var hidesContentFromAccessibility = false + @Published private(set) var actionFocusRequest = 0 + + init(exitTitle: String) { + self.exitTitle = exitTitle + } + + func clear() { + title = nil + image = nil + text = nil + actionTitle = nil + action = nil + isActionVisible = false + hidesContentFromAccessibility = false + } + + func show(title: String, + image: UIImage, + text: String?, + actionTitle: String?, + action: MarkerTutorialAction?, + hidesContentFromAccessibility: Bool) { + self.title = title + self.image = image + self.text = text + self.actionTitle = actionTitle + self.action = action + self.hidesContentFromAccessibility = hidesContentFromAccessibility + isActionVisible = false + } + + func updateText(_ text: String?) { + self.text = text + } + + func revealAction(requestAccessibilityFocus: Bool = true) { + guard actionTitle != nil, action != nil else { + isActionVisible = false + return + } + + isActionVisible = true + if requestAccessibilityFocus { + actionFocusRequest += 1 + } + } +} + +struct MarkerTutorialView: View { + @ObservedObject var state: MarkerTutorialViewState + + let onAction: (MarkerTutorialAction) -> Void + let onExit: () -> Void + + @AccessibilityFocusState private var isActionFocused: Bool + + private let backgroundColor = Color(red: 36.0 / 255.0, + green: 58.0 / 255.0, + blue: 102.0 / 255.0) + + var body: some View { + ZStack { + backgroundColor + .ignoresSafeArea() + + VStack(spacing: 20) { + ScrollView { + VStack(spacing: 20) { + if let title = state.title { + Text(title) + .font(.title) + .multilineTextAlignment(.center) + .foregroundColor(.white) + .accessibilityAddTraits(.isHeader) + .accessibilityHidden(state.hidesContentFromAccessibility) + .padding(.horizontal, 20) + .padding(.top, 10) + } + + if let image = state.image { + Image(uiImage: image) + .resizable() + .aspectRatio(125.0 / 53.0, contentMode: .fill) + .frame(maxWidth: .infinity) + .clipped() + .accessibilityHidden(true) + } + + if let text = state.text { + Text(text) + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(.white) + .accessibilityHidden(state.hidesContentFromAccessibility) + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } + .frame(maxWidth: .infinity) + } + + if state.isActionVisible, + let actionTitle = state.actionTitle, + let action = state.action { + Button { + onAction(action) + } label: { + Text(actionTitle) + .font(.body) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, minHeight: 45) + .padding(.horizontal, 12) + .background(Color.white) + .foregroundColor(backgroundColor) + .cornerRadius(5) + } + .accessibilityFocused($isActionFocused) + .padding(.horizontal, 32) + .transition(.opacity) + } + + Button(action: onExit) { + Text(state.exitTitle) + .font(.callout) + .frame(maxWidth: .infinity, minHeight: 45) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(5) + } + .padding(.horizontal, 32) + .padding(.bottom, 8) + } + } + .animation(.easeInOut(duration: 0.3), value: state.isActionVisible) + .onChange(of: state.actionFocusRequest) { _ in + isActionFocused = true + } + } +} diff --git a/apps/ios/GuideDogs/Code/Visual UI/Views/main.storyboard b/apps/ios/GuideDogs/Code/Visual UI/Views/main.storyboard index 9a6746850..1289b0537 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/Views/main.storyboard +++ b/apps/ios/GuideDogs/Code/Visual UI/Views/main.storyboard @@ -146,14 +146,6 @@ - - - - - - - - @@ -339,7 +331,6 @@ - diff --git a/apps/ios/UnitTests/MarkerTutorialViewStateTests.swift b/apps/ios/UnitTests/MarkerTutorialViewStateTests.swift new file mode 100644 index 000000000..39ceeb122 --- /dev/null +++ b/apps/ios/UnitTests/MarkerTutorialViewStateTests.swift @@ -0,0 +1,165 @@ +// +// MarkerTutorialViewStateTests.swift +// UnitTests +// +// Copyright (c) Soundscape Community Contributors. +// Licensed under the MIT License. +// + +import XCTest +import UIKit +import SwiftUI +@testable import Soundscape + +final class MarkerTutorialViewStateTests: XCTestCase { + func testIntroStateAndAddMarkerAction() { + let state = MarkerTutorialViewState(exitTitle: "Skip") + + state.show(title: "Getting started", + image: UIImage(), + text: "Intro", + actionTitle: "Add marker", + action: .addMarker, + hidesContentFromAccessibility: false) + + XCTAssertEqual(state.title, "Getting started") + XCTAssertEqual(state.text, "Intro") + XCTAssertEqual(state.actionTitle, "Add marker") + XCTAssertEqual(state.action, .addMarker) + XCTAssertFalse(state.isActionVisible) + XCTAssertFalse(state.hidesContentFromAccessibility) + XCTAssertEqual(state.exitTitle, "Skip") + } + + func testActionRemainsHiddenWhenPageHasNoAction() { + let state = MarkerTutorialViewState(exitTitle: "Skip") + + state.show(title: "Mark your world", + image: UIImage(), + text: nil, + actionTitle: nil, + action: nil, + hidesContentFromAccessibility: true) + state.revealAction() + + XCTAssertFalse(state.isActionVisible) + XCTAssertEqual(state.actionFocusRequest, 0) + XCTAssertTrue(state.hidesContentFromAccessibility) + } + + func testNearbyMarkersActionBecomesVisibleAndRequestsFocus() { + let state = MarkerTutorialViewState(exitTitle: "Skip") + + state.show(title: "Experience your world", + image: UIImage(), + text: nil, + actionTitle: "Nearby markers", + action: .nearbyMarkers, + hidesContentFromAccessibility: true) + state.revealAction() + + XCTAssertEqual(state.action, .nearbyMarkers) + XCTAssertTrue(state.isActionVisible) + XCTAssertEqual(state.actionFocusRequest, 1) + } + + func testSpokenTextUpdateAndClearResetPresentation() { + let state = MarkerTutorialViewState(exitTitle: "Skip") + state.show(title: "Title", + image: UIImage(), + text: nil, + actionTitle: "Continue", + action: .addMarker, + hidesContentFromAccessibility: true) + + state.updateText("Spoken tutorial text") + + XCTAssertEqual(state.text, "Spoken tutorial text") + + state.clear() + + XCTAssertNil(state.title) + XCTAssertNil(state.image) + XCTAssertNil(state.text) + XCTAssertNil(state.actionTitle) + XCTAssertNil(state.action) + XCTAssertFalse(state.isActionVisible) + XCTAssertFalse(state.hidesContentFromAccessibility) + } + + func testControllerRendersSwiftUISurfaceAndRestoresNavigationBar() { + let controller = MarkerTutorialViewController(logContext: "simulator_smoke_test") + let navigationController = UINavigationController(rootViewController: controller) + + defer { + controller.stop() + AppContext.shared.isInTutorialMode = false + } + + controller.loadViewIfNeeded() + navigationController.view.frame = UIScreen.main.bounds + controller.view.frame = navigationController.view.bounds + navigationController.beginAppearanceTransition(true, animated: false) + navigationController.endAppearanceTransition() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.5)) + navigationController.view.layoutIfNeeded() + controller.view.layoutIfNeeded() + + XCTAssertEqual(controller.children.count, 1) + XCTAssertTrue(String(describing: type(of: controller.children[0])).contains("UIHostingController")) + XCTAssertTrue(navigationController.isNavigationBarHidden) + XCTAssertEqual(controller.view.backgroundColor, + UIColor(red: 36.0 / 255.0, + green: 58.0 / 255.0, + blue: 102.0 / 255.0, + alpha: 1)) + + navigationController.beginAppearanceTransition(false, animated: false) + navigationController.endAppearanceTransition() + + XCTAssertFalse(navigationController.isNavigationBarHidden) + } + + func testSwiftUISurfaceRendersIntroOnSimulator() throws { + let tutorialImage = try XCTUnwrap(UIImage(named: "Markers tutorial - 01")) + let state = MarkerTutorialViewState(exitTitle: GDLocalizedString("tutorial.skip")) + state.show(title: GDLocalizedString("tutorial.markers.getting_started"), + image: tutorialImage, + text: GDLocalizedString("tutorial.markers.text.Intro"), + actionTitle: GDLocalizedString("tutorial.markers.add_marker"), + action: .addMarker, + hidesContentFromAccessibility: false) + state.revealAction(requestAccessibilityFocus: false) + + let hostingController = UIHostingController( + rootView: MarkerTutorialView(state: state, onAction: { _ in }, onExit: {}) + ) + let windowScene = try XCTUnwrap( + UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + ) + let window = UIWindow(windowScene: windowScene) + window.frame = UIScreen.main.bounds + window.windowLevel = .alert + 1 + window.rootViewController = hostingController + + defer { + window.isHidden = true + } + + window.makeKeyAndVisible() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.5)) + hostingController.view.frame = window.bounds + hostingController.view.layoutIfNeeded() + + let renderer = UIGraphicsImageRenderer(bounds: hostingController.view.bounds) + let image = renderer.image { _ in + hostingController.view.drawHierarchy(in: hostingController.view.bounds, afterScreenUpdates: true) + } + XCTAssertEqual(image.size, hostingController.view.bounds.size) + + let attachment = XCTAttachment(image: image) + attachment.name = "Marker tutorial intro - simulator" + attachment.lifetime = .keepAlways + add(attachment) + } +}