Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
6ecda6a
Merge branch 'feature/survey-core-feature' into feature/surveys-ui
mosliem Dec 22, 2025
5ce1ad6
wip: fix QuestionContent models access level and Add SurveyQuestionAn…
mosliem Dec 22, 2025
2fac5ae
Add Selection Question View for (Single, Multiple) choice options.
mosliem Dec 22, 2025
17913a7
Update: [SurveysCore] Update Survey Service error propagation.
mosliem Jan 20, 2026
a6c33a4
Add: Helper `ExternalSurveyURLBuilder` to handle the creation of exte…
mosliem Jan 20, 2026
b57f303
Add: [ToastMessage] Reusable view for success and failure messages.
mosliem Jan 20, 2026
dadae8a
Add: Survey view components.
mosliem Jan 20, 2026
a111b4f
Add: Survey ViewModel handle both state, actions, logic flow.
mosliem Jan 20, 2026
d8c9f6a
Add: Survey Hosting Protocol with the shared methods of survey observ…
mosliem Jan 20, 2026
31052e2
Integrate: Surveys feature into `StopViewController`.
mosliem Jan 20, 2026
20a9230
Integrate: Surveys feature into `MapViewController`.
mosliem Jan 20, 2026
43e7cbb
Update: [ExternalSurveyView] Refactor the presentation structure of a…
mosliem Jan 24, 2026
e6caa4f
Update: [MapViewController] Update the presentation of survey hero qu…
mosliem Jan 27, 2026
e0abe5b
Fix: [SurveyFullQuestions] Update question error state method.
mosliem Jan 27, 2026
536c8e4
Merge branch 'main' into feature/surveys-ui
mosliem Jan 27, 2026
d966838
Fix: Handle logging survey service unavailability.
mosliem Jan 31, 2026
f6bb459
Fix: [StopViewController-MapViewController] Update adding `HeroQuesti…
mosliem Jan 31, 2026
048c458
Enhance: [Survey] Update view model state properties access and actions.
mosliem Jan 31, 2026
361aeeb
Enhance: [ExternalSurveyURLBuilder] Log missing survey URL and remove…
mosliem Feb 2, 2026
1da83e0
Fix: [SurveyService] Handle and log missing survey update path.
mosliem Feb 2, 2026
6be1a3e
Enhance: [SurveyViewModel] Unify concurrency handling and improve ext…
mosliem Feb 2, 2026
d85c71a
fix: [MapViewController-StopViewController] Fix duplicated survey sta…
mosliem Feb 12, 2026
c86d84d
Enhance: [Surveys] Add accessibility label localization.
mosliem Feb 12, 2026
e2cc0f8
Fix: (ExternalSurveyURLBuilder - SurveySubmission) Handle empty value…
mosliem Feb 12, 2026
82bef36
Fix: [SurveyViewModel] Stabilize survey flow and improve state handling.
mosliem Feb 12, 2026
3b38634
Fix: (SurveysViewModel) Fix state blocking and race condition, with i…
mosliem Feb 19, 2026
a6e5a67
Enhance: (SurveyService) Remove redundant do catch blocks.
mosliem Feb 19, 2026
1cbb91f
Enhance: (ExternalSurveyURLBuilder) Refactor class dependancies to pr…
mosliem Feb 19, 2026
b872e01
Add: (SurveysViewModelTests) Add test cases with required mocks.
mosliem Feb 19, 2026
501dfe1
Add: (ExternalSurveyURLBuilderTests) Add test cases.
mosliem Feb 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions OBAKit/Extensions/UIKitExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import UIKit
import OBAKitCore
import SwiftUI

// MARK: - UIButton

Expand Down Expand Up @@ -75,3 +76,9 @@ extension UIApplication {
return windows ?? []
}
}

extension UIColor {
func toColor() -> Color {
Color(uiColor: self)
}
}
88 changes: 88 additions & 0 deletions OBAKit/Mapping/MapViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import FloatingPanel
import OBAKitCore
import SwiftUI
import OTPKit
import SafariServices

/// Displays a map, a set of stops rendered as annotation views, and the user's location if authorized.
///
Expand All @@ -28,6 +29,7 @@ class MapViewController: UIViewController,
MapPanelDelegate,
UIContextMenuInteractionDelegate,
UILargeContentViewerInteractionDelegate,
SurveyViewHostingProtocol,
UIGestureRecognizerDelegate {

// MARK: - Hoverbar
Expand Down Expand Up @@ -128,6 +130,8 @@ class MapViewController: UIViewController,
longPressGesture.minimumPressDuration = 0.5
longPressGesture.delegate = self
mapView.addGestureRecognizer(longPressGesture)

surveysVM.onAction(.onAppear)
}

public override func viewWillAppear(_ animated: Bool) {
Expand All @@ -144,6 +148,8 @@ class MapViewController: UIViewController,

updateVisibleMapRect()
layoutMapMargins()

observeSurveysState()
}

public override func viewDidAppear(_ animated: Bool) {
Expand All @@ -157,6 +163,7 @@ class MapViewController: UIViewController,
super.viewWillDisappear(animated)

navigationController?.setNavigationBarHidden(false, animated: false)
stopObserveSurveysState()
}

// MARK: - User Location
Expand Down Expand Up @@ -1013,6 +1020,87 @@ class MapViewController: UIViewController,
didTapMapStatus(interaction)
}
}

// MARK: - Survey

lazy var surveysVM: SurveysViewModel = SurveysViewModel(
stateManager: application.surveyStateManager,
service: application.surveyService,
prioritizer: application.surveyPrioritizer,
externalLinkBuilder: application.externalSurveyURLBuilder
)

var observationActive: Bool = false

private var surveyPopupController: UIViewController?

// MARK: - Show/Hide HeroQuestion
private func showSurveyHeroQuestionPopup() {
guard surveyPopupController == nil else { return }

let surveyQuestionView = MapHeroQuestionView(viewModel: surveysVM)
let hostingController = createPopupHostingController(content: surveyQuestionView)

addChild(hostingController)
view.insertSubview(hostingController.view, aboveSubview: mapRegionManager.mapView)

hostingController.view.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.activate([
hostingController.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -ThemeMetrics.controllerMargin),
hostingController.view.topAnchor.constraint(equalTo: toolbar.bottomAnchor, constant: 24),
hostingController.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: ThemeMetrics.controllerMargin),
])

hostingController.didMove(toParent: self)
surveyPopupController = hostingController
}

private func createPopupHostingController<Content: View>(content: Content) -> UIHostingController<Content> {
let controller = UIHostingController(rootView: content)
controller.view.backgroundColor = .clear
return controller
}

@objc private func dismissSurveyPopup() {
surveyPopupController?.willMove(toParent: nil)
surveyPopupController?.view.removeFromSuperview()
surveyPopupController?.removeFromParent()
surveyPopupController = nil
}

// MARK: - Surveys VM Observation

func observeSurveysState() {
observationActive = true
observeSurveyLoadingState()
observeSurveyHeroQuestion()
observeSurveyToastMessage()
observeSurveyFullQuestionsState(application.viewRouter)
observeSurveyDismissActionSheet()
observeOpenExternalSurvey(application.viewRouter)
}

func stopObserveSurveysState() {
observationActive = false
}

func observeSurveyHeroQuestion() {
withObservationTracking { [weak self] in
guard let self else { return }
if self.surveysVM.showHeroQuestion {
showSurveyHeroQuestionPopup()
} else {
self.dismissSurveyPopup()
}
} onChange: {
Task { @MainActor [weak self] in
guard let self, self.observationActive else { return }
self.observeSurveyHeroQuestion()
}
}
}

}

// swiftlint:enable file_length
78 changes: 76 additions & 2 deletions OBAKit/Stops/StopViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import UIKit
import OBAKitCore
import CoreLocation
import SwiftUI
import SafariServices

// swiftlint:disable file_length

Expand All @@ -32,12 +33,14 @@ public class StopViewController: UIViewController,
OBAListViewCollapsibleSectionsDelegate,
ModalDelegate,
Previewable,
StopPreferencesViewDelegate {
StopPreferencesViewDelegate,
SurveyViewHostingProtocol {

/// The available sections in this view controller.
enum ListSections {
case stopHeader
case donations
case surveys
case emptyData
case serviceAlerts
case arrivalDepartures(suffix: String)
Expand Down Expand Up @@ -84,12 +87,22 @@ public class StopViewController: UIViewController,
/// The amount of time that must elapse before `timerFired()` will update data.
private static let defaultTimerReloadInterval: TimeInterval = 30.0

lazy var surveysVM = SurveysViewModel(
stopContext: true,
stop: stop,
stateManager: application.surveyStateManager,
service: application.surveyService,
prioritizer: application.surveyPrioritizer,
externalLinkBuilder: application.externalSurveyURLBuilder
)

// MARK: - Data
/// The stop displayed by this controller.
var stop: Stop? {
didSet {
if stop != oldValue, let stop = stop {
stopUpdated(stop)
surveysVM.updateCurrentStop(stop)
}
}
}
Expand Down Expand Up @@ -189,6 +202,7 @@ public class StopViewController: UIViewController,
listView.register(listViewItem: MessageButtonItem.self)
listView.register(listViewItem: StopArrivalWalkItem.self)
listView.register(listViewItem: StopHeaderItem.self)
listView.register(listViewItem: HeroQuestionListItem.self)

view.addSubview(listView)
listView.pinToSuperview(.edges)
Expand All @@ -197,6 +211,8 @@ public class StopViewController: UIViewController,
if !stopViewShowsServiceAlerts {
collapsedSections = [ListSections.serviceAlerts.sectionID]
}

surveysVM.onAction(.onAppear)
}

public override func viewWillAppear(_ animated: Bool) {
Expand All @@ -211,6 +227,8 @@ public class StopViewController: UIViewController,
Task {
await updateData()
}

observeSurveysState()
}

public override func viewDidAppear(_ animated: Bool) {
Expand All @@ -231,8 +249,8 @@ public class StopViewController: UIViewController,

public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)

enableIdleTimer()
stopObserveSurveysState()
}

// MARK: - Tips
Expand Down Expand Up @@ -580,6 +598,10 @@ public class StopViewController: UIViewController,

sections.append(stopHeaderSection)

if let surveySection {
sections.append(surveySection)
}

if let donationsSection {
sections.append(donationsSection)
}
Expand Down Expand Up @@ -1280,4 +1302,56 @@ public class StopViewController: UIViewController,
return "User Distance: 03200-INFINITY"
}
}

// MARK: - Survey Section
private var surveySection: OBAListViewSection? {
guard let model = surveysVM.heroQuestion, surveysVM.showHeroQuestion else { return nil }

let heroQuestion = HeroQuestionListItem(
question: model,
answer: surveysVM.heroQuestionAnswer
) { [weak self] answer in
self?.surveysVM.onAction(.updateHeroAnswer(answer))
} onSubmitAction: { [weak self] in
guard let self else { return }
surveysVM.onAction(.onTapNextHeroQuestion)
} onCloseAction: { [weak self] in
self?.surveysVM.onAction(.onCloseSurveyHeroQuestion)
}

return listViewSection(for: .surveys, title: nil, items: [heroQuestion])
}

// MARK: - Survey Observation

var observationActive: Bool = false

func observeSurveysState() {
observationActive = true
observeSurveyLoadingState()
observeSurveyHeroQuestion()
observeSurveyToastMessage()
observeSurveyFullQuestionsState(application.viewRouter)
observeSurveyDismissActionSheet()
observeOpenExternalSurvey(application.viewRouter)
}

func observeSurveyHeroQuestion() {
withObservationTracking { [weak self] in
guard let self else { return }
_ = self.surveysVM.heroQuestion
self.listView.applyData()
} onChange: {
Task { @MainActor [weak self] in
guard let self, self.observationActive else { return }
self.observeSurveyHeroQuestion()
}
}
}

func stopObserveSurveysState() {
observationActive = false
}

}
// swiftlint:enable file_length
37 changes: 37 additions & 0 deletions OBAKit/Surveys/Model/SurveyQuestionAnswer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// SurveyAnswer.swift
// OBAKit
//
// Copyright © Open Transit Software Foundation
// This source code is licensed under the Apache 2.0 license found in the
// LICENSE file in the root directory of this source tree.
//

import Foundation

public enum SurveyQuestionAnswer: Hashable {

case text(String)

case radio(_ selectedOption: String)

case checkbox(_ selectedOptions: Set<String>)

}

extension SurveyQuestionAnswer {
var stringValue: String {
switch self {

case .text(let value):
return value

case .radio(let option):
return option

case .checkbox(let options):
return options.sorted().joined(separator: ", ")

}
}
}
Loading
Loading