diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c34f5a3..6b53d13 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -39,7 +39,11 @@ jobs: ${{ runner.os }}-spm- - name: Switch Xcode - run: sudo xcode-select -switch /Applications/Xcode_26.0.app + run: sudo xcode-select -switch /Applications/Xcode_26.2.app + + - name: List Available Simulators + run: | + xcrun simctl list devices # Build - name: Build OTPKit @@ -54,7 +58,6 @@ jobs: -scheme OTPKit -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -resultBundlePath OTPKitTests.xcresult - -quiet # Upload results - uses: kishikawakatsumi/xcresulttool@v1.7.0 @@ -62,5 +65,5 @@ jobs: with: show-passed-tests: false # Avoid truncation of annotations by GitHub by omitting succeeding tests. path: | - OTPKitDemoTests.xcresult + OTPKitTests.xcresult if: success() || failure() diff --git a/CLAUDE.md b/CLAUDE.md index dde7949..7de3f47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,22 +156,31 @@ func testFetchPlan() async throws { ### Basic Setup ```swift import OTPKit +import MapKit // 1. Create your map view (OTPKit doesn't provide one) let mapView = MKMapView() let mapProvider = MKMapViewAdapter(mapView: mapView) -// 2. Create configuration +// 2. Define the search region for location suggestions +let searchRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 47.6062, longitude: -122.3321), + latitudinalMeters: 50000, + longitudinalMeters: 50000 +) + +// 3. Create configuration with required search region let config = OTPConfiguration( otpServerURL: URL(string: "https://otp.example.com")!, enabledTransportModes: [.transit, .walk, .bike], - themeConfiguration: OTPThemeConfiguration(primaryColor: .blue) + themeConfiguration: OTPThemeConfiguration(primaryColor: .blue), + searchRegion: searchRegion ) -// 3. Initialize API service +// 4. Initialize API service let apiService = RestAPIService(baseURL: config.otpServerURL) -// 4. Create and present OTP view +// 5. Create and present OTP view let otpView = OTPView( otpConfig: config, apiService: apiService, @@ -186,9 +195,15 @@ let themeConfig = OTPThemeConfiguration( secondaryColor: .green, backgroundColor: .systemBackground ) +let searchRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 47.6062, longitude: -122.3321), + latitudinalMeters: 50000, + longitudinalMeters: 50000 +) let config = OTPConfiguration( otpServerURL: url, - themeConfiguration: themeConfig + themeConfiguration: themeConfig, + searchRegion: searchRegion ) ``` diff --git a/Demo/OTPKitDemo/OTPDemoViewController.swift b/Demo/OTPKitDemo/OTPDemoViewController.swift index ced9eb0..4d23c3f 100644 --- a/Demo/OTPKitDemo/OTPDemoViewController.swift +++ b/Demo/OTPKitDemo/OTPDemoViewController.swift @@ -141,9 +141,19 @@ class OTPDemoViewController: UIViewController { return } + // Create search region from the selected region info + let searchRegion = MKCoordinateRegion( + center: regionInfo.center, + latitudinalMeters: 50000, + longitudinalMeters: 50000 + ) + // Create bottom sheet and present it let tripPlanner = TripPlanner( - otpConfig: OTPConfiguration(otpServerURL: serverURL), + otpConfig: OTPConfiguration( + otpServerURL: serverURL, + searchRegion: searchRegion + ), apiService: apiService, mapProvider: provider, notificationCenter: NotificationCenter.default diff --git a/Demo/OTPKitDemo/OnboardingViewController.swift b/Demo/OTPKitDemo/OnboardingViewController.swift index 297a6f6..e8c589b 100644 --- a/Demo/OTPKitDemo/OnboardingViewController.swift +++ b/Demo/OTPKitDemo/OnboardingViewController.swift @@ -17,6 +17,13 @@ class OnboardingViewController: UIViewController { private var continueButton: UIButton! private let regions = [ + OTPRegionInfo( + name: "San Diego", + description: "San Diego", + icon: "building.2.fill", + url: URL(string: "https://realtime.sdmts.com:9091/otp/")!, + center: CLLocationCoordinate2D(latitude: 32.8850078, longitude: -117.2393175) + ), OTPRegionInfo( name: "Seattle", description: "Puget Sound region", diff --git a/OTPKit/Sources/OTPKit/Core/Helper/Location/SearchManager.swift b/OTPKit/Sources/OTPKit/Core/Helper/Location/SearchManager.swift index 05f46c2..0975282 100644 --- a/OTPKit/Sources/OTPKit/Core/Helper/Location/SearchManager.swift +++ b/OTPKit/Sources/OTPKit/Core/Helper/Location/SearchManager.swift @@ -19,11 +19,12 @@ class SearchManager: NSObject, MKLocalSearchCompleterDelegate { private let searchCompleter = MKLocalSearchCompleter() - override init() { + init(region: MKCoordinateRegion) { super.init() searchCompleter.delegate = self searchCompleter.resultTypes = [.address, .pointOfInterest] - searchCompleter.region = MKCoordinateRegion(.world) + searchCompleter.region = region + searchCompleter.regionPriority = .required // iOS 18+ - strictly enforce region } func search(query: String) { diff --git a/OTPKit/Sources/OTPKit/Core/OTPConfiguration.swift b/OTPKit/Sources/OTPKit/Core/OTPConfiguration.swift index a6f452f..5d6b1d0 100644 --- a/OTPKit/Sources/OTPKit/Core/OTPConfiguration.swift +++ b/OTPKit/Sources/OTPKit/Core/OTPConfiguration.swift @@ -20,13 +20,37 @@ public struct OTPConfiguration { /// UI theme configuration public let themeConfiguration: OTPThemeConfiguration + /// Geographic region for location search suggestions. + /// Search results will be strictly limited to this region. + public let searchRegion: MKCoordinateRegion + public init( otpServerURL: URL, enabledTransportModes: [TransportMode] = TransportMode.allCases, - themeConfiguration: OTPThemeConfiguration = OTPThemeConfiguration() + themeConfiguration: OTPThemeConfiguration = OTPThemeConfiguration(), + searchRegion: MKCoordinateRegion ) { self.otpServerURL = otpServerURL self.enabledTransportModes = enabledTransportModes self.themeConfiguration = themeConfiguration + self.searchRegion = searchRegion + } +} + +// MARK: - Environment Key for Search Region + +private struct OTPSearchRegionKey: EnvironmentKey { + // Default to Seattle area for previews (matches existing PreviewHelpers coordinates) + static let defaultValue = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 47.6062, longitude: -122.3321), + latitudinalMeters: 50000, + longitudinalMeters: 50000 + ) +} + +extension EnvironmentValues { + public var otpSearchRegion: MKCoordinateRegion { + get { self[OTPSearchRegionKey.self] } + set { self[OTPSearchRegionKey.self] = newValue } } } diff --git a/OTPKit/Sources/OTPKit/Core/Previews/PreviewHelpers.swift b/OTPKit/Sources/OTPKit/Core/Previews/PreviewHelpers.swift index d33bba2..c9c0ef7 100644 --- a/OTPKit/Sources/OTPKit/Core/Previews/PreviewHelpers.swift +++ b/OTPKit/Sources/OTPKit/Core/Previews/PreviewHelpers.swift @@ -11,16 +11,25 @@ import SwiftUI class PreviewHelpers { + /// Seattle region for previews (matches common test coordinates) + static let seattleRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 47.6062, longitude: -122.3321), + latitudinalMeters: 50000, + longitudinalMeters: 50000 + ) + static func mockOTPConfiguration() -> OTPConfiguration { return OTPConfiguration( - otpServerURL: URL(string: "https://example.com")! + otpServerURL: URL(string: "https://example.com")!, + searchRegion: seattleRegion ) } @MainActor static func mockTripPlannerViewModel(canPlanTrip: Bool = true) -> TripPlannerViewModel { let config = OTPConfiguration( - otpServerURL: URL(string: "https://example.com")! + otpServerURL: URL(string: "https://example.com")!, + searchRegion: seattleRegion ) let mapView = MKMapView() let mapProvider = MKMapViewAdapter(mapView: mapView) diff --git a/OTPKit/Sources/OTPKit/Presentation/Sheets/Search/SearchSheetView.swift b/OTPKit/Sources/OTPKit/Presentation/Sheets/Search/SearchSheetView.swift index 617f50f..bf4b7ce 100644 --- a/OTPKit/Sources/OTPKit/Presentation/Sheets/Search/SearchSheetView.swift +++ b/OTPKit/Sources/OTPKit/Presentation/Sheets/Search/SearchSheetView.swift @@ -11,6 +11,7 @@ import MapKit /// Integrates with MapKit to fetch and display search suggestions and details. struct SearchSheetView: View { @Environment(\.otpTheme) private var theme + @Environment(\.otpSearchRegion) private var searchRegion let selectedMode: LocationMode let onLocationSelected: OnLocationSelectedHandler @@ -18,29 +19,34 @@ struct SearchSheetView: View { @State private var searchText = "" @State private var searchResults: [Location] = [] - // MapKit search - @State private var searchManager = SearchManager() + // MapKit search - initialized lazily with region from environment + @State private var searchManager: SearchManager? var body: some View { NavigationView { VStack(spacing: 0) { SearchBar(searchText: $searchText) { newValue in + guard let manager = searchManager else { return } if newValue.isEmpty { - searchManager.clear() + manager.clear() } else { - searchManager.search(query: newValue) + manager.search(query: newValue) } } .padding(.horizontal, 16) .padding(.top, 16) // Search Results - if searchManager.isSearching { - buildSearchingView() - } else if searchManager.searchCompletions.isEmpty && !searchText.isEmpty { - buildNoSearchResultsView() - } else if !searchManager.searchCompletions.isEmpty { - buildSearchResultsView() + if let manager = searchManager { + if manager.isSearching { + buildSearchingView() + } else if manager.searchCompletions.isEmpty && !searchText.isEmpty { + buildNoSearchResultsView() + } else if !manager.searchCompletions.isEmpty { + buildSearchResultsView(manager: manager) + } else { + buildDefaultView() + } } else { buildDefaultView() } @@ -48,6 +54,11 @@ struct SearchSheetView: View { .navigationTitle(buildNavigationTitle()) .navigationBarTitleDisplayMode(.inline) } + .onAppear { + if searchManager == nil { + searchManager = SearchManager(region: searchRegion) + } + } } @ViewBuilder @@ -110,10 +121,10 @@ struct SearchSheetView: View { } @ViewBuilder - private func buildSearchResultsView() -> some View { - List(searchManager.searchCompletions, id: \.self) { completion in + private func buildSearchResultsView(manager: SearchManager) -> some View { + List(manager.searchCompletions, id: \.self) { completion in SearchCompletionRow(completion: completion) { - searchManager.performDetailedSearch(for: completion) { location in + manager.performDetailedSearch(for: completion) { location in onLocationSelected(location, selectedMode) } } diff --git a/OTPKit/Sources/OTPKit/Presentation/TripPlanner.swift b/OTPKit/Sources/OTPKit/Presentation/TripPlanner.swift index 8de5ae7..6570497 100644 --- a/OTPKit/Sources/OTPKit/Presentation/TripPlanner.swift +++ b/OTPKit/Sources/OTPKit/Presentation/TripPlanner.swift @@ -72,6 +72,7 @@ public class TripPlanner { return view .environment(\.otpTheme, viewModel.config.themeConfiguration) + .environment(\.otpSearchRegion, viewModel.config.searchRegion) .environmentObject(mapCoordinator) .environmentObject(viewModel) } diff --git a/OTPKit/Sources/OTPKit/Presentation/TripPlanner/TripPlannerView.swift b/OTPKit/Sources/OTPKit/Presentation/TripPlanner/TripPlannerView.swift index 0401671..a5c1275 100644 --- a/OTPKit/Sources/OTPKit/Presentation/TripPlanner/TripPlannerView.swift +++ b/OTPKit/Sources/OTPKit/Presentation/TripPlanner/TripPlannerView.swift @@ -169,11 +169,15 @@ private extension TripPlannerView { } #Preview { - let otpConfig = OTPConfiguration(otpServerURL: URL(string: "https://example.com")!) + let otpConfig = PreviewHelpers.mockOTPConfiguration() let mapView = MKMapView() let mapProvider = MKMapViewAdapter(mapView: mapView) let mapCoordinator = MapCoordinator(mapProvider: mapProvider) - let viewModel = TripPlannerViewModel(config: otpConfig, apiService: PreviewHelpers.MockAPIService(), mapCoordinator: mapCoordinator) + let viewModel = TripPlannerViewModel( + config: otpConfig, + apiService: PreviewHelpers.MockAPIService(), + mapCoordinator: mapCoordinator + ) TripPlannerView( viewModel: viewModel, diff --git a/OTPKit/Tests/Helpers/TestFixtures.swift b/OTPKit/Tests/Helpers/TestFixtures.swift index 50c9192..943ee57 100644 --- a/OTPKit/Tests/Helpers/TestFixtures.swift +++ b/OTPKit/Tests/Helpers/TestFixtures.swift @@ -7,6 +7,7 @@ import Foundation import CoreLocation +import MapKit @testable import OTPKit /// Provides test fixtures for OTPKit tests @@ -68,15 +69,24 @@ enum TestFixtures { return Place(name: name, lon: -122, lat: 47, vertexType: "TRANSIT") } + /// Seattle region for tests (matches common test coordinates) + static let seattleRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 47.6062, longitude: -122.3321), + latitudinalMeters: 50000, + longitudinalMeters: 50000 + ) + static func makeOTPConfiguration( serverURL: URL = URL(string: "https://otp.example.com")!, enabledModes: [TransportMode] = [.transit, .walk], - theme: OTPThemeConfiguration? = nil + theme: OTPThemeConfiguration? = nil, + searchRegion: MKCoordinateRegion? = nil ) -> OTPConfiguration { OTPConfiguration( otpServerURL: serverURL, enabledTransportModes: enabledModes, - themeConfiguration: theme ?? OTPThemeConfiguration() + themeConfiguration: theme ?? OTPThemeConfiguration(), + searchRegion: searchRegion ?? seattleRegion ) } diff --git a/OTPKit/Tests/TripPlannerViewModelTests.swift b/OTPKit/Tests/TripPlannerViewModelTests.swift index 882241e..b466d3f 100644 --- a/OTPKit/Tests/TripPlannerViewModelTests.swift +++ b/OTPKit/Tests/TripPlannerViewModelTests.swift @@ -35,10 +35,7 @@ struct TripPlannerViewModelTests { defaults.removeObject(forKey: "OTPKit.TripOptions.maxWalkingDistance") defaults.removeObject(forKey: "OTPKit.TripOptions.routePreference") - let config = OTPConfiguration( - otpServerURL: URL(string: "https://otp.example.com")!, - enabledTransportModes: enabledModes - ) + let config = TestFixtures.makeOTPConfiguration(enabledModes: enabledModes) let apiService = mockAPIService ?? TestFixtures.MockAPIService() let mapProvider = MockMapProvider() diff --git a/README.markdown b/README.markdown index ed2316c..03b3901 100644 --- a/README.markdown +++ b/README.markdown @@ -31,21 +31,29 @@ dependencies: [ ### SwiftUI Usage ```swift import OTPKit +import MapKit struct ContentView: View { var body: some View { - // 1. Configure OTPKit with server URL and theme + // 1. Define the search region for location suggestions + let searchRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 47.6062, longitude: -122.3321), + latitudinalMeters: 50000, + longitudinalMeters: 50000 + ) + + // 2. Configure OTPKit with server URL, theme, and search region let config = OTPConfiguration( otpServerURL: URL(string: "https://your-otp-server.com")!, - region: .userLocation(fallback: .automatic), - themeConfiguration: OTPThemeConfiguration(primaryColor: .blue, secondaryColor: .gray) + themeConfiguration: OTPThemeConfiguration(primaryColor: .blue, secondaryColor: .gray), + searchRegion: searchRegion ) - // 2. Create API service for OpenTripPlanner + // 3. Create API service for OpenTripPlanner let apiService = RestAPIService(baseURL: config.otpServerURL) VStack { - // 3. Add complete trip planner to your app + // 4. Add complete trip planner to your app OTPView(otpConfig: config, apiService: apiService) } } @@ -56,10 +64,18 @@ struct ContentView: View { ```swift import OTPKit +import MapKit + +// Define the search region for location suggestions +let searchRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 47.6062, longitude: -122.3321), + latitudinalMeters: 50000, + longitudinalMeters: 50000 +) let config = OTPConfiguration( otpServerURL: URL(string: "https://your-otp-server.com")!, - region: .userLocation(fallback: .automatic) + searchRegion: searchRegion ) let apiService = RestAPIService(baseURL: config.otpServerURL) @@ -69,7 +85,6 @@ let tripPlannerView = OTPView(otpConfig: config, apiService: apiService) let hostingController = UIHostingController(rootView: tripPlannerView) addChild(hostingController) view.addSubview(hostingController.view) - ``` ## Development