diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c7155fe8..5e4d856a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,23 @@ Critical Maps iOS +## [4.10.0] - 2026-06-08 + +### Added + +- Highlight groups feature - Participants that are close to one another are highlighted as groups, while riders outside of a group remain visible but less prominent. + +### Updated + +- Updated dependencies + ## [4.9.0] - 2026-01-03 ### Updated - Migrated to `swift-tools-version:6.2` -- Refactored SwiftUI views following best practices for improved performance: -- Performance optimization for RequestTimer and InfoOverlayView +- Refactored SwiftUI views following best practices for improved performance +- Performance optimisation for RequestTimer and InfoOverlayView - Updated dependencies ### Fixed diff --git a/Config/Debug-Local.xcconfig b/Config/Debug-Local.xcconfig new file mode 100644 index 000000000..d8ed8d784 --- /dev/null +++ b/Config/Debug-Local.xcconfig @@ -0,0 +1,10 @@ +// Build configuration for running against the local mock server, which lives in +// its own repository (criticalmaps-mock-server). +// +// Used by the "Debug Local" build configuration / "Critical Maps (Local)" scheme. +// Sets the DEBUG_LOCAL compile flag so the app target injects a ServerConfiguration +// pointing at http://localhost:8080 (see iOS/AppConfiguration.swift). + +#include "Shared.xcconfig" + +SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEBUG_LOCAL diff --git a/Config/Shared.xcconfig b/Config/Shared.xcconfig new file mode 100644 index 000000000..2de793417 --- /dev/null +++ b/Config/Shared.xcconfig @@ -0,0 +1,5 @@ +// Shared base settings for app build configurations. +// +// Intentionally minimal today — a place to put settings common to all +// configurations (e.g. an externalized API host) without touching the project +// file. The "Debug Local" configuration includes this via Debug-Local.xcconfig. diff --git a/CriticalMaps.xcodeproj/project.pbxproj b/CriticalMaps.xcodeproj/project.pbxproj index 06287ccc8..22ae843a7 100644 --- a/CriticalMaps.xcodeproj/project.pbxproj +++ b/CriticalMaps.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 5ECACE0EB79FB71190DA0CCC /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D32ED4D6F091FA6990994EBE /* AppConfiguration.swift */; }; 730741DD2F00737D0080985D /* AppIcon-Sun.icon in Resources */ = {isa = PBXBuildFile; fileRef = 730741DC2F00737D0080985D /* AppIcon-Sun.icon */; }; 730741DE2F00737D0080985D /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 730741D82F00737D0080985D /* AppIcon.icon */; }; 730741DF2F00737D0080985D /* AppIcon-Dark.icon in Resources */ = {isa = PBXBuildFile; fileRef = 730741D92F00737D0080985D /* AppIcon-Dark.icon */; }; @@ -26,6 +27,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 20A6EB76B0234461A4A91977 /* Debug-Local.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Debug-Local.xcconfig"; sourceTree = ""; }; 730741D82F00737D0080985D /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; 730741D92F00737D0080985D /* AppIcon-Dark.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = "AppIcon-Dark.icon"; sourceTree = ""; }; 730741DA2F00737D0080985D /* AppIcon-Neon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = "AppIcon-Neon.icon"; sourceTree = ""; }; @@ -57,6 +59,8 @@ 73EA92432C59585D008DEEFE /* SocialFeature.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = SocialFeature.xctestplan; sourceTree = ""; }; 73EB0310263F23A100941D57 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; 73F010D82B94A19700C2B613 /* LisbonPortugal.gpx */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = LisbonPortugal.gpx; sourceTree = ""; }; + A2505A49BB8F102AF4496DBD /* Shared.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = ""; }; + D32ED4D6F091FA6990994EBE /* AppConfiguration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppConfiguration.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -169,6 +173,7 @@ 739A58322D4156F100B80B41 /* SettingsFeaturePreview.app */, 739A58332D4156F100B80B41 /* GuideFeaturePreview.app */, 73CA22F82D4158650040C7CF /* Frameworks */, + 895C7647794A2614C2AA6100 /* Config */, ); sourceTree = ""; }; @@ -186,10 +191,21 @@ 73654C6B26C85FCF004BE38B /* Launch Screen.storyboard */, 739C4980276733E20001466A /* Berlin:Germany.gpx */, 73F010D82B94A19700C2B613 /* LisbonPortugal.gpx */, + D32ED4D6F091FA6990994EBE /* AppConfiguration.swift */, ); path = iOS; sourceTree = ""; }; + 895C7647794A2614C2AA6100 /* Config */ = { + isa = PBXGroup; + children = ( + 20A6EB76B0234461A4A91977 /* Debug-Local.xcconfig */, + A2505A49BB8F102AF4496DBD /* Shared.xcconfig */, + ); + name = Config; + path = Config; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -385,6 +401,7 @@ buildActionMask = 2147483647; files = ( 73EB0311263F23A100941D57 /* App.swift in Sources */, + 5ECACE0EB79FB71190DA0CCC /* AppConfiguration.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -399,6 +416,71 @@ /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ + 1479D1E4E7FFDDA1E9B95334 /* Debug Local */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + }; + name = "Debug Local"; + }; 73238EC527728043003DE01F /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -774,6 +856,39 @@ }; name = Release; }; + F588BCFCE13D3727E800503D /* Debug Local */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 20A6EB76B0234461A4A91977 /* Debug-Local.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon-Sun AppIcon-Rainbow AppIcon-Neon AppIcon-Dark"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 250902; + DEVELOPMENT_TEAM = 5YLLXUZBZ2; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 5YLLXUZBZ2; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = "$(SRCROOT)/iOS/Info.plist"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.navigation"; + IPHONEOS_DEPLOYMENT_TARGET = 17; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 4.7.0; + PRODUCT_BUNDLE_IDENTIFIER = de.pokuslabs.criticalmassberlin; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "Critical Maps Prov Profile"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Criticalmaps dist"; + RUN_DOCUMENTATION_COMPILER = NO; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Debug Local"; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -800,6 +915,7 @@ buildConfigurations = ( 73CF5FE5263EAF5C001925A3 /* Debug */, 73CF5FE6263EAF5C001925A3 /* Release */, + 1479D1E4E7FFDDA1E9B95334 /* Debug Local */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -809,6 +925,7 @@ buildConfigurations = ( 73CF5FE8263EAF5C001925A3 /* Debug */, 73CF5FE9263EAF5C001925A3 /* Release */, + F588BCFCE13D3727E800503D /* Debug Local */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/CriticalMaps.xcodeproj/xcshareddata/xcschemes/Critical Maps (Local).xcscheme b/CriticalMaps.xcodeproj/xcshareddata/xcschemes/Critical Maps (Local).xcscheme new file mode 100644 index 000000000..bdae59d6e --- /dev/null +++ b/CriticalMaps.xcodeproj/xcshareddata/xcschemes/Critical Maps (Local).xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CriticalMapsKit/Sources/ApiClient/APIService.swift b/CriticalMapsKit/Sources/ApiClient/APIService.swift index 93110a172..c58245152 100644 --- a/CriticalMapsKit/Sources/ApiClient/APIService.swift +++ b/CriticalMapsKit/Sources/ApiClient/APIService.swift @@ -18,15 +18,23 @@ public struct APIService: Sendable { extension APIService: DependencyKey { public static var liveValue: APIService { @Dependency(\.apiClient) var apiClient + @Dependency(\.serverConfiguration) var serverConfiguration + + let locationsEndpoint = Endpoint( + baseUrl: serverConfiguration.locationsHost, + pathComponents: ["locations"], + scheme: serverConfiguration.scheme, + port: serverConfiguration.locationsPort + ) return Self( getRiders: { - let request: Request = .get(.locations) + let request: Request = .get(locationsEndpoint) let (data, _) = try await apiClient.send(request) return try data.decoded() - }, + }, postRiderLocation: { body in - let request: Request = .put(.locations, body: try? body.encoded()) + let request: Request = .put(locationsEndpoint, body: try? body.encoded()) let (data, _) = try await apiClient.send(request) return try data.decoded() }, diff --git a/CriticalMapsKit/Sources/ApiClient/Endpoint.swift b/CriticalMapsKit/Sources/ApiClient/Endpoint.swift index c54c2be47..119eeb183 100644 --- a/CriticalMapsKit/Sources/ApiClient/Endpoint.swift +++ b/CriticalMapsKit/Sources/ApiClient/Endpoint.swift @@ -2,12 +2,21 @@ import Foundation /// A structure to define an endpoint on the Critical Maps API public struct Endpoint: Sendable { + public let scheme: String public let baseUrl: String + public let port: Int? public let pathComponents: [String] - public init(baseUrl: String, pathComponents: [String] = []) { + public init( + baseUrl: String, + pathComponents: [String] = [], + scheme: String = "https", + port: Int? = nil + ) { self.baseUrl = baseUrl self.pathComponents = pathComponents + self.scheme = scheme + self.port = port } var url: String { diff --git a/CriticalMapsKit/Sources/ApiClient/Requests/Request.swift b/CriticalMapsKit/Sources/ApiClient/Requests/Request.swift index 4d8932347..96f061be0 100644 --- a/CriticalMapsKit/Sources/ApiClient/Requests/Request.swift +++ b/CriticalMapsKit/Sources/ApiClient/Requests/Request.swift @@ -26,10 +26,15 @@ public struct Request: Sendable { } public func makeRequest() throws -> URLRequest { - guard var components = URLComponents(string: endpoint.url) else { - throw APIRequestBuildError.invalidURL + // Build from discrete components so hosts with an explicit port (e.g. a local + // mock server at `localhost:8080`) are not mis-parsed as a scheme. + var components = URLComponents() + components.scheme = endpoint.scheme + components.host = endpoint.baseUrl + components.port = endpoint.port + if !endpoint.pathComponents.isEmpty { + components.path = "/" + endpoint.pathComponents.joined(separator: "/") } - components.scheme = "https" if !queryItems.isEmpty { components.queryItems = queryItems } diff --git a/CriticalMapsKit/Sources/ApiClient/ServerConfiguration.swift b/CriticalMapsKit/Sources/ApiClient/ServerConfiguration.swift new file mode 100644 index 000000000..dc004d2fb --- /dev/null +++ b/CriticalMapsKit/Sources/ApiClient/ServerConfiguration.swift @@ -0,0 +1,45 @@ +import ComposableArchitecture +import Foundation + +/// Configures which server the locations endpoint talks to and how often the app +/// polls. The default is production; a development build (e.g. the +/// `Critical Maps (Local)` scheme) overrides this dependency at the app's +/// composition root to point at a local mock server with a fast poll interval. +public struct ServerConfiguration: Sendable, Equatable { + /// URL scheme for the locations endpoint, e.g. `"https"` or `"http"`. + public var scheme: String + /// Host for the locations endpoint, e.g. `"api-cdn.criticalmaps.net"` or `"localhost"`. + public var locationsHost: String + /// Optional port for the locations endpoint (e.g. `8080` for a local server). + public var locationsPort: Int? + /// Full poll-cycle length in seconds. The app posts its location at the halfway + /// point and fetches riders at the full cycle, so production is `60` (30s + 30s). + public var pollIntervalSeconds: Int + + public init( + scheme: String = "https", + locationsHost: String = "api-cdn.criticalmaps.net", + locationsPort: Int? = nil, + pollIntervalSeconds: Int = 60 + ) { + self.scheme = scheme + self.locationsHost = locationsHost + self.locationsPort = locationsPort + self.pollIntervalSeconds = pollIntervalSeconds + } + + /// The production configuration (Critical Maps CDN over HTTPS, 60s poll cycle). + public static let production = ServerConfiguration() +} + +extension ServerConfiguration: DependencyKey { + public static let liveValue = ServerConfiguration.production + public static let testValue = ServerConfiguration.production +} + +public extension DependencyValues { + var serverConfiguration: ServerConfiguration { + get { self[ServerConfiguration.self] } + set { self[ServerConfiguration.self] = newValue } + } +} diff --git a/CriticalMapsKit/Sources/AppFeature/AppFeature.swift b/CriticalMapsKit/Sources/AppFeature/AppFeature.swift index 2bd4b5895..1b8d227ff 100644 --- a/CriticalMapsKit/Sources/AppFeature/AppFeature.swift +++ b/CriticalMapsKit/Sources/AppFeature/AppFeature.swift @@ -25,12 +25,6 @@ public struct AppFeature: Sendable { // swiftlint:disable:this type_body_length public enum Destination: Sendable { case social(SocialFeature) case settings(SettingsFeature) - case alert(AlertState) - - @CasePathable - public enum Alert: Equatable, Sendable { - case setObservationMode(enabled: Bool) - } } // MARK: State @@ -49,6 +43,7 @@ public struct AppFeature: Sendable { // swiftlint:disable:this type_body_length @Presents var destination: Destination.State? public var eventListPresentation: PresentationDetent = .fraction(0.3) public var isEventListPresented = false + public var isWhatsNewPresented = false public var chatMessageBadgeCount: UInt = 0 public var isCurrentLocationInPrivacyZone = false @@ -58,6 +53,7 @@ public struct AppFeature: Sendable { // swiftlint:disable:this type_body_length @Shared(.appearanceSettings) var appearanceSettings @Shared(.hasConnectionError) var hasConnectionError @Shared(.privacyZoneSettings) var privacyZoneSettings + @Shared(.lastSeenWhatsNewVersion) var lastSeenWhatsNewVersion public init( locationsAndChatMessages: [Rider] = [], @@ -108,13 +104,14 @@ public struct AppFeature: Sendable { // swiftlint:disable:this type_body_length case fetchChatMessages case fetchChatMessagesResponse(Result<[ChatMessage], any Error>) case onRideSelectedFromBottomSheet(SharedModels.Ride) - case presentObservationModeAlert - + case socialButtonTapped case settingsButtonTapped case didTapNextRideOverlayButton case dismissEventList case dismissDestination + case whatsNewContinueTapped + case whatsNewDismissed case map(MapFeatureAction) case nextRide(NextRideFeature.Action) @@ -123,7 +120,14 @@ public struct AppFeature: Sendable { // swiftlint:disable:this type_body_length // MARK: Reducer + /// The app version whose release the "What's New" sheet announces. The sheet is + /// shown once to users running this exact version (both fresh installs and + /// updates), and never on other versions. Bump this together with the sheet copy + /// when a future release should announce something new. + static let whatsNewVersion = "4.10.0" + @Dependency(\.apiService) var apiService + @Dependency(\.appVersion) var appVersion @Dependency(\.continuousClock) var clock @Dependency(\.date) var date @Dependency(\.feedbackGenerator) var feedbackGenerator @@ -166,7 +170,18 @@ public struct AppFeature: Sendable { // swiftlint:disable:this type_body_length $sessionID.withLock { $0 = uuid().uuidString } StorageMigration.migratePrivacyZones() - + + // Show the "What's New" sheet once: to every fresh install (onboarding, + // any version) and to users updating to the release it announces + // (`whatsNewVersion`). The sheet carries the feature toggles, so it also + // replaces the old observation-mode prompt. + let isFreshInstall = state.lastSeenWhatsNewVersion.isEmpty + let isAnnouncedUpdate = appVersion == Self.whatsNewVersion + && state.lastSeenWhatsNewVersion != Self.whatsNewVersion + if isFreshInstall || isAnnouncedUpdate { + state.isWhatsNewPresented = true + } + return .merge( [ .send(.map(.onAppear)), @@ -186,13 +201,6 @@ public struct AppFeature: Sendable { // swiftlint:disable:this type_body_length }, .run { _ in await feedbackGenerator.prepare() - }, - .run { send in - @Shared(.didShowObservationModePrompt) var didShowObservationModePrompt - if !didShowObservationModePrompt { - try? await clock.sleep(for: .seconds(3)) - await send(.presentObservationModeAlert) - } } ] ) @@ -377,37 +385,19 @@ public struct AppFeature: Sendable { // swiftlint:disable:this type_body_length case .dismissDestination: state.destination = nil return .none - - case .presentObservationModeAlert: - state.destination = .alert( - AlertState( - title: { - TextState(verbatim: L10n.Settings.Observationmode.title) - }, - actions: { - ButtonState( - action: .setObservationMode(enabled: false), - label: { TextState(L10n.AppCore.ViewingModeAlert.riding) } - ) - ButtonState( - action: .setObservationMode(enabled: true), - label: { TextState(L10n.AppCore.ViewingModeAlert.watching) } - ) - }, - message: { TextState(L10n.AppCore.ViewingModeAlert.message) } - ) - ) - @Shared(.didShowObservationModePrompt) var didShowObservationModePrompt - $didShowObservationModePrompt.withLock { $0 = true } + + case .whatsNewContinueTapped: + // Decision: Continue simply dismisses; discovery happens in Settings. + state.isWhatsNewPresented = false return .none - - case let .destination(.presented(.alert(alertAction))): - switch alertAction { - case let .setObservationMode(enabled: mode): - state.$userSettings.withLock { $0.isObservationModeEnabled = mode } - return .none - } - + + case .whatsNewDismissed: + // Mark the sheet as seen for this version so it isn't shown again. + // Fires for both Continue and swipe-to-dismiss. + state.isWhatsNewPresented = false + state.$lastSeenWhatsNewVersion.withLock { $0 = appVersion } + return .none + case let .destination(.presented(.settings(settingsAction))): switch settingsAction { case .destination(.presented(.rideEventSettings)): diff --git a/CriticalMapsKit/Sources/AppFeature/AppVersion.swift b/CriticalMapsKit/Sources/AppFeature/AppVersion.swift new file mode 100644 index 000000000..6993a8dc5 --- /dev/null +++ b/CriticalMapsKit/Sources/AppFeature/AppVersion.swift @@ -0,0 +1,19 @@ +import Dependencies +import Foundation +import Helpers + +/// The app's marketing version (e.g. "4.7.0"), injectable for testability. +/// +/// `liveValue` reads `CFBundleShortVersionString`; tests override it to exercise +/// the "What's New" version gating deterministically. +public enum AppVersionKey: DependencyKey { + public static let liveValue: String = Bundle.main.versionNumber + public static let testValue = "" +} + +public extension DependencyValues { + var appVersion: String { + get { self[AppVersionKey.self] } + set { self[AppVersionKey.self] = newValue } + } +} diff --git a/CriticalMapsKit/Sources/AppFeature/AppView.swift b/CriticalMapsKit/Sources/AppFeature/AppView.swift index dea6cc904..09a12c386 100644 --- a/CriticalMapsKit/Sources/AppFeature/AppView.swift +++ b/CriticalMapsKit/Sources/AppFeature/AppView.swift @@ -63,11 +63,12 @@ public struct AppView: View { } } ) - .alert( - $store.scope( - state: \.destination?.alert, - action: \.destination.alert - ) + .sheet( + isPresented: $store.isWhatsNewPresented, + onDismiss: { store.send(.whatsNewDismissed) }, + content: { + WhatsNewSheet(onContinue: { store.send(.whatsNewContinueTapped) }) + } ) .onAppear { store.send(.onAppear) } .onDisappear { store.send(.onDisappear) } diff --git a/CriticalMapsKit/Sources/AppFeature/RequestTimerFeature.swift b/CriticalMapsKit/Sources/AppFeature/RequestTimerFeature.swift index fcbfee07d..13c2ab5a8 100644 --- a/CriticalMapsKit/Sources/AppFeature/RequestTimerFeature.swift +++ b/CriticalMapsKit/Sources/AppFeature/RequestTimerFeature.swift @@ -1,3 +1,4 @@ +import ApiClient import ComposableArchitecture import Foundation @@ -28,6 +29,7 @@ public struct RequestTimer: Sendable { @Dependency(\.continuousClock) var clock @Dependency(\.date.now) var now + @Dependency(\.serverConfiguration) var serverConfiguration enum CancelID { case timer } @@ -48,14 +50,19 @@ public struct RequestTimer: Sendable { state.isTimerActive = true state.cycleStartTime = now + // Split the poll cycle into two halves: post location at the halfway + // point, fetch riders at the full cycle. Production = 60s (30s + 30s); + // a dev build can shorten this via `serverConfiguration`. + let fullCycle = serverConfiguration.pollIntervalSeconds + let firstHalf = fullCycle / 2 + let secondHalf = fullCycle - firstHalf + return .run { send in while true { - // Sleep for 30 seconds - try await clock.sleep(for: .seconds(30)) + try await clock.sleep(for: .seconds(firstHalf)) await send(.halfwayPoint) - // Sleep for another 30 seconds - try await clock.sleep(for: .seconds(30)) + try await clock.sleep(for: .seconds(secondHalf)) await send(.fullCycle) } } diff --git a/CriticalMapsKit/Sources/AppFeature/WhatsNewSheet.swift b/CriticalMapsKit/Sources/AppFeature/WhatsNewSheet.swift new file mode 100644 index 000000000..3b3e12f77 --- /dev/null +++ b/CriticalMapsKit/Sources/AppFeature/WhatsNewSheet.swift @@ -0,0 +1,93 @@ +import L10n +import SharedKeys +import SharedModels +import Styleguide +import SwiftUI + +/// "What's New" onboarding sheet shown once after updating to a version with new +/// features. Presentational — the gating/dismissal live in `AppFeature` +struct WhatsNewSheet: View { + @Shared(.userSettings) private var userSettings: UserSettings + var onContinue: () -> Void + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: .grid(6)) { + heroImage + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: .grid(5))) + .accessibilityHidden(true) + .animation(.snappy, value: userSettings.highlightActiveRiders) + + Text(L10n.WhatsNew.header) + .font(.headline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(spacing: .grid(4)) { + FeatureCard( + title: L10n.Settings.HighlightActiveRiders.label, + description: L10n.WhatsNew.HighlightActiveRiders.description, + isOn: Binding($userSettings.highlightActiveRiders) + ) + + FeatureCard( + title: L10n.Settings.Observationmode.title, + description: L10n.WhatsNew.ObservationMode.description, + isOn: Binding($userSettings.isObservationModeEnabled) + ) + } + } + .padding() + } + .safeAreaInset(edge: .bottom) { + Button(action: onContinue) { + Text(L10n.WhatsNew.continue) + .frame(maxWidth: .infinity) + } + .buttonStyle(.criticalMaps) + .padding() + } + } + .presentationDetents([.large]) + } + + private var heroImage: Image { + (userSettings.highlightActiveRiders ? Asset.highlightActiveOn : Asset.highlightActiveOff) + .swiftUIImage + } +} + +private struct FeatureCard: View { + let title: String + let description: String + let isOn: Binding + + var body: some View { + HStack(alignment: .top, spacing: .grid(4)) { + VStack(alignment: .leading, spacing: .grid(1)) { + Text(title) + .font(.headline) + Text(description) + .foregroundStyle(.secondary) + } + + Spacer(minLength: .grid(2)) + + Toggle(title, isOn: isOn) + .labelsHidden() + .tint(.brand500) + } + .padding() + .background(.regularMaterial) + .clipShape(.rect(cornerRadius: .grid(5))) + } +} + +#Preview { + WhatsNewSheet { + print("continue") + } +} diff --git a/CriticalMapsKit/Sources/L10n/Generated/L10n.swift b/CriticalMapsKit/Sources/L10n/Generated/L10n.swift index 144eb6c14..6c806cc3a 100644 --- a/CriticalMapsKit/Sources/L10n/Generated/L10n.swift +++ b/CriticalMapsKit/Sources/L10n/Generated/L10n.swift @@ -35,6 +35,12 @@ public enum L10n { public static let label = L10n.tr("Localizable", "a11y.mapfeatureview.nextridebanner.label", fallback: "Next critical mass banner") } } + public enum Settings { + public enum HighlightActiveRiders { + /// When enabled, riders in a group are highlighted and others appear dimmed on the map + public static let hint = L10n.tr("Localizable", "a11y.settings.highlightActiveRiders.hint", fallback: "When enabled, riders in a group are highlighted and others appear dimmed on the map") + } + } public enum Usertrackingbutton { /// Don't follow public static let dontFollow = L10n.tr("Localizable", "a11y.usertrackingbutton.dontFollow", fallback: "Don't follow") @@ -46,18 +52,6 @@ public enum L10n { public static let hint = L10n.tr("Localizable", "a11y.usertrackingbutton.hint", fallback: "Toggle tracking mode") } } - public enum AppCore { - public enum ViewingModeAlert { - /// Are you participating in the Critical Mass or are you only watching? - public static let message = L10n.tr("Localizable", "appCore.viewingModeAlert.message", fallback: "Are you participating in the Critical Mass or are you only watching?") - /// Riding - public static let riding = L10n.tr("Localizable", "appCore.viewingModeAlert.riding", fallback: "Riding") - /// Viewing Mode - public static let title = L10n.tr("Localizable", "appCore.viewingModeAlert.title", fallback: "Viewing Mode") - /// Watching - public static let watching = L10n.tr("Localizable", "appCore.viewingModeAlert.watching", fallback: "Watching") - } - } public enum AppView { public enum Overlay { /// Next update @@ -401,6 +395,12 @@ public enum L10n { /// Show ID public static let showID = L10n.tr("Localizable", "settings.friends.showID", fallback: "Show ID") } + public enum HighlightActiveRiders { + /// Highlights cyclists riding in groups + public static let description = L10n.tr("Localizable", "settings.highlightActiveRiders.description", fallback: "Highlights cyclists riding in groups") + /// Highlight active riders + public static let label = L10n.tr("Localizable", "settings.highlightActiveRiders.label", fallback: "Highlight active riders") + } public enum Info { public enum Toggle { /// Show info toogle over the map @@ -474,6 +474,22 @@ public enum L10n { public static let message = L10n.tr("Localizable", "twitter.empty.message", fallback: "Here you’ll find tweets tagged with @criticalmaps and #criticalmass") } } + public enum WhatsNew { + /// Continue + public static let `continue` = L10n.tr("Localizable", "whatsNew.continue", fallback: "Continue") + /// Discover + public static let header = L10n.tr("Localizable", "whatsNew.header", fallback: "Discover") + public enum HighlightActiveRiders { + /// Get a clearer view of what's happening on the map. + /// + /// Participants that are close to one another are highlighted as groups, while riders outside of a group remain visible but less prominent. + public static let description = L10n.tr("Localizable", "whatsNew.highlightActiveRiders.description", fallback: "Get a clearer view of what's happening on the map.\n\nParticipants that are close to one another are highlighted as groups, while riders outside of a group remain visible but less prominent.") + } + public enum ObservationMode { + /// Following the ride without taking part — as a passenger, driver or onlooker? Turn this on so your location isn't shared as a rider. + public static let description = L10n.tr("Localizable", "whatsNew.observationMode.description", fallback: "Following the ride without taking part — as a passenger, driver or onlooker? Turn this on so your location isn't shared as a rider.") + } + } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces diff --git a/CriticalMapsKit/Sources/L10n/Resources/de.lproj/Localizable.strings b/CriticalMapsKit/Sources/L10n/Resources/de.lproj/Localizable.strings index 308b749b4..aadb9fe89 100644 --- a/CriticalMapsKit/Sources/L10n/Resources/de.lproj/Localizable.strings +++ b/CriticalMapsKit/Sources/L10n/Resources/de.lproj/Localizable.strings @@ -12,10 +12,6 @@ "a11y.general.off" = "Off"; "a11y.general.selected" = "ausgewählt"; -"appCore.viewingModeAlert.title" = "Anzeigemodus"; -"appCore.viewingModeAlert.message" = "Nimmst du teil oder schaust du zu?"; -"appCore.viewingModeAlert.riding" = "Fahre mit"; -"appCore.viewingModeAlert.watching" = "Schaue zu"; "appView.overlay.nextUpdate" = "Nächstes Update"; "appView.overlay.riders" = "Riders"; @@ -335,3 +331,12 @@ "rideEvent.contextMenu.copyCoordinates" = "Koordinaten kopieren"; "rideEvent.contextMenu.copyLocationName" = "Ortsname kopieren"; + +"settings.highlightActiveRiders.label" = "Aktive Teilnehmer hervorheben"; +"settings.highlightActiveRiders.description" = "Hebt Radfahrer hervor, die in Gruppen fahren"; +"a11y.settings.highlightActiveRiders.hint" = "Wenn aktiviert, werden Teilnehmer in einer Gruppe hervorgehoben und andere auf der Karte abgeblendet"; + +"whatsNew.header" = "Entdecken"; +"whatsNew.highlightActiveRiders.description" = "Behalte den Überblick über die Karte.\n\nTeilnehmer, die nah beieinander sind, werden als Gruppen hervorgehoben, während Fahrer außerhalb einer Gruppe sichtbar bleiben, aber weniger auffällig sind."; +"whatsNew.observationMode.description" = "Du verfolgst die Tour, ohne mitzufahren – als Beifahrer, Fahrer oder Zuschauer? Aktiviere dies, damit dein Standort nicht als Teilnehmer geteilt wird."; +"whatsNew.continue" = "Weiter"; diff --git a/CriticalMapsKit/Sources/L10n/Resources/en.lproj/Localizable.strings b/CriticalMapsKit/Sources/L10n/Resources/en.lproj/Localizable.strings index a8041d1ba..874b291f0 100644 --- a/CriticalMapsKit/Sources/L10n/Resources/en.lproj/Localizable.strings +++ b/CriticalMapsKit/Sources/L10n/Resources/en.lproj/Localizable.strings @@ -12,10 +12,6 @@ "a11y.general.off" = "Off"; "a11y.general.selected" = "selected"; -"appCore.viewingModeAlert.title" = "Viewing Mode"; -"appCore.viewingModeAlert.message" = "Are you participating in the Critical Mass or are you only watching?"; -"appCore.viewingModeAlert.riding" = "Riding"; -"appCore.viewingModeAlert.watching" = "Watching"; "appView.overlay.nextUpdate" = "Next update"; "appView.overlay.riders" = "Riders"; @@ -335,3 +331,12 @@ "rideEvent.contextMenu.copyCoordinates" = "Copy Coordinates"; "rideEvent.contextMenu.copyLocationName" = "Copy Location Name"; + +"settings.highlightActiveRiders.label" = "Highlight active riders"; +"settings.highlightActiveRiders.description" = "Highlights cyclists riding in groups"; +"a11y.settings.highlightActiveRiders.hint" = "When enabled, riders in a group are highlighted and others appear dimmed on the map"; + +"whatsNew.header" = "Discover"; +"whatsNew.highlightActiveRiders.description" = "Get a clearer view of what's happening on the map.\n\nParticipants that are close to one another are highlighted as groups, while riders outside of a group remain visible but less prominent."; +"whatsNew.observationMode.description" = "Following the ride without taking part — as a passenger, driver or onlooker? Turn this on so your location isn't shared as a rider."; +"whatsNew.continue" = "Continue"; diff --git a/CriticalMapsKit/Sources/L10n/Resources/es.lproj/Localizable.strings b/CriticalMapsKit/Sources/L10n/Resources/es.lproj/Localizable.strings index 34d5fd740..64f4ba7c6 100644 --- a/CriticalMapsKit/Sources/L10n/Resources/es.lproj/Localizable.strings +++ b/CriticalMapsKit/Sources/L10n/Resources/es.lproj/Localizable.strings @@ -12,10 +12,6 @@ "a11y.general.off" = "Desactivado"; "a11y.general.selected" = "seleccionado"; -"appCore.viewingModeAlert.title" = "Viewing Mode"; -"appCore.viewingModeAlert.message" = "¿Estás participando en la Máscara Crítica o solo estás mirando?"; -"appCore.viewingModeAlert.riding" = "Riding"; -"appCore.viewingModeAlert.watching" = "Observando"; "appView.overlay.nextUpdate" = "Próxima actualización"; "appView.overlay.riders" = "Riders"; @@ -335,3 +331,12 @@ "rideEvent.contextMenu.copyCoordinates" = "Copiar coordenadas"; "rideEvent.contextMenu.copyLocationName" = "Copiar nombre del lugar"; + +"settings.highlightActiveRiders.label" = "Resaltar ciclistas activos"; +"settings.highlightActiveRiders.description" = "Resalta a los ciclistas que circulan en grupo"; +"a11y.settings.highlightActiveRiders.hint" = "Cuando está activado, los ciclistas en grupo se resaltan y los demás aparecen atenuados en el mapa"; + +"whatsNew.header" = "Descubre"; +"whatsNew.highlightActiveRiders.description" = "Obtén una visión más clara de lo que ocurre en el mapa.\n\nLos participantes que están cerca unos de otros se resaltan como grupos, mientras que los ciclistas fuera de un grupo siguen visibles pero menos destacados."; +"whatsNew.observationMode.description" = "¿Sigues la marcha sin participar, como pasajero, conductor o espectador? Actívalo para que tu ubicación no se comparta como ciclista."; +"whatsNew.continue" = "Continuar"; diff --git a/CriticalMapsKit/Sources/L10n/Resources/fr.lproj/Localizable.strings b/CriticalMapsKit/Sources/L10n/Resources/fr.lproj/Localizable.strings index eec29e39f..e58041d2a 100644 --- a/CriticalMapsKit/Sources/L10n/Resources/fr.lproj/Localizable.strings +++ b/CriticalMapsKit/Sources/L10n/Resources/fr.lproj/Localizable.strings @@ -12,10 +12,6 @@ "a11y.general.off" = "Off"; "a11y.general.selected" = "sélectionné(s)"; -"appCore.viewingModeAlert.title" = "Viewing Mode"; -"appCore.viewingModeAlert.message" = "Are you participating in the Critical Mass or are you only watching?"; -"appCore.viewingModeAlert.riding" = "Riding"; -"appCore.viewingModeAlert.watching" = "Watching"; "appView.overlay.nextUpdate" = "Next update"; "appView.overlay.riders" = "Riders"; @@ -335,3 +331,12 @@ "rideEvent.contextMenu.copyCoordinates" = "Copier les coordonnées"; "rideEvent.contextMenu.copyLocationName" = "Copier le nom du lieu"; + +"settings.highlightActiveRiders.label" = "Mettre en évidence les cyclistes actifs"; +"settings.highlightActiveRiders.description" = "Met en évidence les cyclistes roulant en groupe"; +"a11y.settings.highlightActiveRiders.hint" = "Lorsque cette option est activée, les cyclistes en groupe sont mis en évidence et les autres apparaissent estompés sur la carte"; + +"whatsNew.header" = "Découvrir"; +"whatsNew.highlightActiveRiders.description" = "Obtenez une vue plus claire de ce qui se passe sur la carte.\n\nLes participants proches les uns des autres sont mis en évidence en tant que groupes, tandis que les cyclistes hors d'un groupe restent visibles mais moins en évidence."; +"whatsNew.observationMode.description" = "Vous suivez la balade sans y participer — passager, conducteur ou spectateur ? Activez ceci pour que votre position ne soit pas partagée comme participant."; +"whatsNew.continue" = "Continuer"; diff --git a/CriticalMapsKit/Sources/L10n/Resources/it.lproj/Localizable.strings b/CriticalMapsKit/Sources/L10n/Resources/it.lproj/Localizable.strings index a63b9c474..f4008befc 100644 --- a/CriticalMapsKit/Sources/L10n/Resources/it.lproj/Localizable.strings +++ b/CriticalMapsKit/Sources/L10n/Resources/it.lproj/Localizable.strings @@ -12,10 +12,6 @@ "a11y.general.off" = "Spento"; "a11y.general.selected" = "selezionato"; -"appCore.viewingModeAlert.title" = "Viewing Mode"; -"appCore.viewingModeAlert.message" = "Partecipa alla Messa Critica o stai solo guardando?"; -"appCore.viewingModeAlert.riding" = "Riding"; -"appCore.viewingModeAlert.watching" = "Watching"; "appView.overlay.nextUpdate" = "Prossimo update"; "appView.overlay.riders" = "Riders"; @@ -335,3 +331,12 @@ "rideEvent.contextMenu.copyCoordinates" = "Copia coordinate"; "rideEvent.contextMenu.copyLocationName" = "Copia nome del luogo"; + +"settings.highlightActiveRiders.label" = "Evidenzia i ciclisti attivi"; +"settings.highlightActiveRiders.description" = "Evidenzia i ciclisti che pedalano in gruppo"; +"a11y.settings.highlightActiveRiders.hint" = "Se attivato, i ciclisti in gruppo vengono evidenziati e gli altri appaiono attenuati sulla mappa"; + +"whatsNew.header" = "Scopri"; +"whatsNew.highlightActiveRiders.description" = "Ottieni una visione più chiara di ciò che accade sulla mappa.\n\nI partecipanti vicini tra loro vengono evidenziati come gruppi, mentre i ciclisti al di fuori di un gruppo restano visibili ma meno in evidenza."; +"whatsNew.observationMode.description" = "Segui la pedalata senza parteciparvi, come passeggero, conducente o spettatore? Attivalo così la tua posizione non viene condivisa come partecipante."; +"whatsNew.continue" = "Continua"; diff --git a/CriticalMapsKit/Sources/L10n/Resources/pl.lproj/Localizable.strings b/CriticalMapsKit/Sources/L10n/Resources/pl.lproj/Localizable.strings index 2324bf019..178e62226 100644 --- a/CriticalMapsKit/Sources/L10n/Resources/pl.lproj/Localizable.strings +++ b/CriticalMapsKit/Sources/L10n/Resources/pl.lproj/Localizable.strings @@ -12,10 +12,6 @@ "a11y.general.off" = "Wył"; "a11y.general.selected" = "zaznaczony"; -"appCore.viewingModeAlert.title" = "Viewing Mode"; -"appCore.viewingModeAlert.message" = "Are you participating in the Critical Mass or are you only watching?"; -"appCore.viewingModeAlert.riding" = "Riding"; -"appCore.viewingModeAlert.watching" = "Watching"; "appView.overlay.nextUpdate" = "Next update"; "appView.overlay.riders" = "Riders"; @@ -332,3 +328,12 @@ "social.feed.loading" = "Ładowanie kanału"; "map.location.request.desciption" = "Twoja lokalizacja sprawia, że ta aplikacja działa lepiej. Proszę daj nam dostęp."; + +"settings.highlightActiveRiders.label" = "Wyróżnij aktywnych rowerzystów"; +"settings.highlightActiveRiders.description" = "Wyróżnia rowerzystów jadących w grupie"; +"a11y.settings.highlightActiveRiders.hint" = "Po włączeniu rowerzyści w grupie są wyróżnieni, a pozostali są przyciemnieni na mapie"; + +"whatsNew.header" = "Odkryj"; +"whatsNew.highlightActiveRiders.description" = "Zyskaj wyraźniejszy obraz tego, co dzieje się na mapie.\n\nUczestnicy znajdujący się blisko siebie są wyróżniani jako grupy, podczas gdy rowerzyści poza grupą pozostają widoczni, ale mniej wyeksponowani."; +"whatsNew.observationMode.description" = "Śledzisz przejazd, nie biorąc w nim udziału – jako pasażer, kierowca lub obserwator? Włącz to, aby Twoja lokalizacja nie była udostępniana jako uczestnik."; +"whatsNew.continue" = "Kontynuuj"; diff --git a/CriticalMapsKit/Sources/L10n/Resources/pt-BR.lproj/Localizable.strings b/CriticalMapsKit/Sources/L10n/Resources/pt-BR.lproj/Localizable.strings index f51186629..2e134a796 100644 --- a/CriticalMapsKit/Sources/L10n/Resources/pt-BR.lproj/Localizable.strings +++ b/CriticalMapsKit/Sources/L10n/Resources/pt-BR.lproj/Localizable.strings @@ -12,10 +12,6 @@ "a11y.general.off" = "Desativado"; "a11y.general.selected" = "selecionado"; -"appCore.viewingModeAlert.title" = "Viewing Mode"; -"appCore.viewingModeAlert.message" = "Are you participating in the Critical Mass or are you only watching?"; -"appCore.viewingModeAlert.riding" = "Riding"; -"appCore.viewingModeAlert.watching" = "Watching"; "appView.overlay.nextUpdate" = "Next update"; "appView.overlay.riders" = "Riders"; @@ -332,3 +328,12 @@ "social.feed.loading" = "Carregando feed"; "map.location.request.desciption" = "A localização torna este app melhor. Por favor nos dê acesso."; + +"settings.highlightActiveRiders.label" = "Destacar ciclistas ativos"; +"settings.highlightActiveRiders.description" = "Destaca os ciclistas que pedalam em grupo"; +"a11y.settings.highlightActiveRiders.hint" = "Quando ativado, os ciclistas em grupo são destacados e os demais aparecem esmaecidos no mapa"; + +"whatsNew.header" = "Descubra"; +"whatsNew.highlightActiveRiders.description" = "Tenha uma visão mais clara do que está acontecendo no mapa.\n\nOs participantes próximos uns dos outros são destacados como grupos, enquanto os ciclistas fora de um grupo permanecem visíveis, mas menos proeminentes."; +"whatsNew.observationMode.description" = "Acompanha o passeio sem participar — como passageiro, motorista ou espectador? Ative para que sua localização não seja compartilhada como participante."; +"whatsNew.continue" = "Continuar"; diff --git a/CriticalMapsKit/Sources/L10n/Resources/pt-PT.lproj/Localizable.strings b/CriticalMapsKit/Sources/L10n/Resources/pt-PT.lproj/Localizable.strings index f1151e4a5..996fca05b 100644 --- a/CriticalMapsKit/Sources/L10n/Resources/pt-PT.lproj/Localizable.strings +++ b/CriticalMapsKit/Sources/L10n/Resources/pt-PT.lproj/Localizable.strings @@ -12,10 +12,6 @@ "a11y.general.off" = "Desligado"; "a11y.general.selected" = "selecionado"; -"appCore.viewingModeAlert.title" = "Modo de Visualização"; -"appCore.viewingModeAlert.message" = "Você está participando da massa crítica ou você está apenas assistindo?"; -"appCore.viewingModeAlert.riding" = "Equitação"; -"appCore.viewingModeAlert.watching" = "Assistindo"; "appView.overlay.nextUpdate" = "Próxima actualização"; "appView.overlay.riders" = "Pilotos"; @@ -332,3 +328,12 @@ "social.feed.loading" = "A carregar feed"; "map.location.request.desciption" = "A localização torna esta app melhor. Por favor dá-nos acesso."; + +"settings.highlightActiveRiders.label" = "Destacar ciclistas ativos"; +"settings.highlightActiveRiders.description" = "Destaca os ciclistas que circulam em grupo"; +"a11y.settings.highlightActiveRiders.hint" = "Quando ativado, os ciclistas em grupo são destacados e os restantes aparecem esbatidos no mapa"; + +"whatsNew.header" = "Descubra"; +"whatsNew.highlightActiveRiders.description" = "Tenha uma visão mais clara do que se passa no mapa.\n\nOs participantes próximos uns dos outros são destacados como grupos, enquanto os ciclistas fora de um grupo permanecem visíveis, mas menos salientes."; +"whatsNew.observationMode.description" = "Segue o passeio sem participar — como passageiro, condutor ou espetador? Ative para que a sua localização não seja partilhada como participante."; +"whatsNew.continue" = "Continuar"; diff --git a/CriticalMapsKit/Sources/L10n/Resources/tr.lproj/Localizable.strings b/CriticalMapsKit/Sources/L10n/Resources/tr.lproj/Localizable.strings index ccca30fc0..a8ed9a264 100644 --- a/CriticalMapsKit/Sources/L10n/Resources/tr.lproj/Localizable.strings +++ b/CriticalMapsKit/Sources/L10n/Resources/tr.lproj/Localizable.strings @@ -12,10 +12,6 @@ "a11y.general.off" = "Kapalı"; "a11y.general.selected" = "seçildi"; -"appCore.viewingModeAlert.title" = "Viewing Mode"; -"appCore.viewingModeAlert.message" = "Are you participating in the Critical Mass or are you only watching?"; -"appCore.viewingModeAlert.riding" = "Riding"; -"appCore.viewingModeAlert.watching" = "Watching"; "appView.overlay.nextUpdate" = "Next update"; "appView.overlay.riders" = "Riders"; @@ -332,3 +328,12 @@ "social.feed.loading" = "Akış yükleniyor"; "map.location.request.desciption" = "Konum bu uygulamayı daha iyi yapar. Lütfen bize erişim ver."; + +"settings.highlightActiveRiders.label" = "Aktif sürücüleri vurgula"; +"settings.highlightActiveRiders.description" = "Grup hâlinde giden bisikletçileri vurgular"; +"a11y.settings.highlightActiveRiders.hint" = "Etkinleştirildiğinde, gruptaki sürücüler vurgulanır ve diğerleri haritada soluk görünür"; + +"whatsNew.header" = "Keşfet"; +"whatsNew.highlightActiveRiders.description" = "Haritada olanları daha net gör.\n\nBirbirine yakın katılımcılar grup olarak vurgulanır; bir grubun dışındaki sürücüler görünür kalır ancak daha az belirgindir."; +"whatsNew.observationMode.description" = "Sürüşe katılmadan mı takip ediyorsun – yolcu, sürücü ya da izleyici olarak? Bunu aç, böylece konumun bir katılımcı olarak paylaşılmaz."; +"whatsNew.continue" = "Devam et"; diff --git a/CriticalMapsKit/Sources/MapFeature/MapFeatureView.swift b/CriticalMapsKit/Sources/MapFeature/MapFeatureView.swift index 2f6b7468b..103f5d475 100644 --- a/CriticalMapsKit/Sources/MapFeature/MapFeatureView.swift +++ b/CriticalMapsKit/Sources/MapFeature/MapFeatureView.swift @@ -8,6 +8,7 @@ import SwiftUI public struct MapFeatureView: View { @Environment(\.accessibilityReduceTransparency) var reduceTransparency @Shared(.privacyZoneSettings) var privacyZoneSettings: PrivacyZoneSettings + @Shared(.userSettings) var userSettings: UserSettings @Bindable var store: StoreOf @@ -23,6 +24,7 @@ public struct MapFeatureView: View { nextRide: store.nextRide, rideEvents: store.rideEvents, privacyZones: privacyZoneSettings.zones, + highlightActiveRiders: userSettings.highlightActiveRiders, canShowPrivacyZonesOnMap: privacyZoneSettings.canShowOnMap, annotationsCount: $store.visibleRidersCount, centerRegion: $store.centerRegion, diff --git a/CriticalMapsKit/Sources/MapFeature/MapView.swift b/CriticalMapsKit/Sources/MapFeature/MapView.swift index fe7427508..d2848d9dc 100644 --- a/CriticalMapsKit/Sources/MapFeature/MapView.swift +++ b/CriticalMapsKit/Sources/MapFeature/MapView.swift @@ -20,6 +20,7 @@ struct MapView: ViewRepresentable { var rideEvents: [Ride] = [] let privacyZones: IdentifiedArrayOf let canShowPrivacyZonesOnMap: Bool + let highlightActiveRiders: Bool var mapMenuShareEventHandler: MenuActionHandle? var mapMenuRouteEventHandler: MenuActionHandle? @@ -30,6 +31,7 @@ struct MapView: ViewRepresentable { nextRide: Ride? = nil, rideEvents: [Ride] = [], privacyZones: IdentifiedArrayOf = [], + highlightActiveRiders: Bool = false, canShowPrivacyZonesOnMap: Bool = false, annotationsCount: Binding, centerRegion: Binding, @@ -42,6 +44,7 @@ struct MapView: ViewRepresentable { self.nextRide = nextRide self.rideEvents = rideEvents self.privacyZones = privacyZones + self.highlightActiveRiders = highlightActiveRiders self.canShowPrivacyZonesOnMap = canShowPrivacyZonesOnMap _annotationsCount = annotationsCount _centerRegion = centerRegion @@ -65,7 +68,12 @@ struct MapView: ViewRepresentable { return mapView } - func updateUIView(_ uiView: MKMapView, context _: Context) { + func updateUIView(_ uiView: MKMapView, context: Context) { + // Keep the coordinator's snapshot current so delegate callbacks (e.g. + // `viewFor`, which reads `highlightActiveRiders`) see the latest values + // instead of the stale `MapView` captured at `makeCoordinator()` time. + context.coordinator.parent = self + // rider handling centerRider(in: uiView) updateRiderAnnotations(in: uiView) @@ -89,7 +97,11 @@ struct MapView: ViewRepresentable { } func updateRiderAnnotations(in mapView: MKMapView) { - let updatedAnnotations = RiderAnnotationUpdateClient.update(riderCoordinates, mapView) + let updatedAnnotations = RiderAnnotationUpdateClient.update( + riderCoordinates, + mapView, + highlightActiveRiders: highlightActiveRiders + ) if !updatedAnnotations.removedAnnotations.isEmpty { mapView.removeAnnotations(updatedAnnotations.removedAnnotations) } @@ -177,26 +189,28 @@ final class MapCoordinator: NSObject, MKMapViewDelegate { } func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { - guard annotation is MKUserLocation == false else { - return nil - } - if annotation is RiderAnnotation { - return mapView.dequeueReusableAnnotationView( + guard annotation is MKUserLocation == false else { return nil } + + if let riderAnnotation = annotation as? RiderAnnotation { + let view = mapView.dequeueReusableAnnotationView( withIdentifier: RiderAnnotationView.reuseIdentifier, - for: annotation - ) + for: riderAnnotation + ) as? RiderAnnotationView + view?.isRiderActive = riderAnnotation.isActive + view?.highlightActiveRiders = parent.highlightActiveRiders + return view } - - if annotation is CriticalMassAnnotation { + + if let criticalMassAnnotation = annotation as? CriticalMassAnnotation { let view = mapView.dequeueReusableAnnotationView( withIdentifier: CMMarkerAnnotationView.reuseIdentifier, - for: annotation + for: criticalMassAnnotation ) as? CMMarkerAnnotationView view?.shareEventClosure = parent.mapMenuShareEventHandler view?.routeEventClosure = parent.mapMenuRouteEventHandler return view } - + return MKAnnotationView() } diff --git a/CriticalMapsKit/Sources/MapFeature/RiderAnnotation.swift b/CriticalMapsKit/Sources/MapFeature/RiderAnnotation.swift index d71118b27..3efb482e8 100644 --- a/CriticalMapsKit/Sources/MapFeature/RiderAnnotation.swift +++ b/CriticalMapsKit/Sources/MapFeature/RiderAnnotation.swift @@ -4,9 +4,11 @@ import SharedModels /// Map Annotation that renders CM participants. public final class RiderAnnotation: IdentifiableAnnotation { public let rider: Rider + public let isActive: Bool - public init(rider: Rider) { + public init(rider: Rider, isActive: Bool = true) { self.rider = rider + self.isActive = isActive super.init( location: rider.location, identifier: rider.id diff --git a/CriticalMapsKit/Sources/MapFeature/RiderAnnotationUpdateClient.swift b/CriticalMapsKit/Sources/MapFeature/RiderAnnotationUpdateClient.swift index ecc5c02e2..c24a32d87 100644 --- a/CriticalMapsKit/Sources/MapFeature/RiderAnnotationUpdateClient.swift +++ b/CriticalMapsKit/Sources/MapFeature/RiderAnnotationUpdateClient.swift @@ -1,3 +1,5 @@ +import Dependencies +import DependenciesMacros import Foundation import MapKit import SharedModels @@ -5,35 +7,88 @@ import SharedModels /// A client to update rider annotations on a map. @MainActor public enum RiderAnnotationUpdateClient { - /// Calculates the difference between displayed annotations and a collection of new rider elements. - /// - /// - Parameters: - /// - riderCoordinates: Collection of rider elements that should be displayed - /// - mapView: A MapView in which the annotations should be displayed - /// - Returns: A tuple containing annotations that should be added and removed + /// internal so tests can reset between runs + static var previousPositions: [String: (coordinate: Coordinate, timestamp: Double)] = [:] + public static func update( _ riderCoordinates: [Rider], - _ mapView: MKMapView - ) - -> ( - removedAnnotations: [RiderAnnotation], - addedAnnotations: [RiderAnnotation] - ) - { - let currentlyDisplayedPOIs = mapView.annotations.compactMap { $0 as? RiderAnnotation } + _ mapView: MKMapView, + highlightActiveRiders: Bool + ) -> ( + removedAnnotations: [RiderAnnotation], + addedAnnotations: [RiderAnnotation] + ) { + let currentlyDisplayedPOIs = mapView.annotations + .compactMap { $0 as? RiderAnnotation } .map(\.rider) - - // Riders that should be added + let addedRider = Set(riderCoordinates).subtracting(currentlyDisplayedPOIs) - // Riders that are not on the map anymore let removedRider = Set(currentlyDisplayedPOIs).subtracting(riderCoordinates) - - let addedAnnotations = addedRider.map(RiderAnnotation.init(rider:)) - // Annotations that should be removed + + // Classify all riders once — O(n²) over the full set so added annotations + // get the correct isActive relative to all current participants. + let activeIDs = RiderActivityFilter.classify(riderCoordinates) + + let addedAnnotations = addedRider.map { rider in + RiderAnnotation(rider: rider, isActive: activeIDs.contains(rider.id)) + } + let removedAnnotations = mapView.annotations .compactMap { $0 as? RiderAnnotation } .filter { removedRider.contains($0.rider) } - + + // Refresh isActive + highlightActiveRiders on already-displayed annotations + // so existing views reflect the latest classification and toggle state. + mapView.annotations + .compactMap { $0 as? RiderAnnotation } + .filter { !removedRider.contains($0.rider) } + .forEach { annotation in + let view = mapView.view(for: annotation) as? RiderAnnotationView + view?.isRiderActive = activeIDs.contains(annotation.rider.id) + view?.highlightActiveRiders = highlightActiveRiders + } + return (removedAnnotations, addedAnnotations) } } + +public enum RiderActivityFilter { + static let shortRangeMeters: Double = 250 + static let longRangeMeters: Double = 8000 + + public static func classify(_ riders: [Rider]) -> Set { + var activeIDs = Set() + + for rider in riders { + var shortRange = 0 + var longRange = 0 + + for other in riders where other.id != rider.id { + let distance = rider.coordinate.distance(to: other.coordinate) + if distance <= longRangeMeters { longRange += 1 } + if distance <= shortRangeMeters { shortRange += 1 } + } + + if isActive(shortRange: shortRange, longRange: longRange) { + activeIDs.insert(rider.id) + } + } + + return activeIDs + } + + private static func isActive(shortRange: Int, longRange: Int) -> Bool { + shortRange >= 3 + || (shortRange >= 2 && longRange < 15) + || (shortRange > 1 && longRange < 7) + } +} + +import CoreLocation + +extension Coordinate { + func distance(to other: Coordinate) -> CLLocationDistance { + CLLocation(latitude: latitude, longitude: longitude) + .distance(from: CLLocation(latitude: other.latitude, longitude: other.longitude)) + } +} diff --git a/CriticalMapsKit/Sources/MapFeature/RiderAnnotationView.swift b/CriticalMapsKit/Sources/MapFeature/RiderAnnotationView.swift index d12710e09..38219f4b8 100644 --- a/CriticalMapsKit/Sources/MapFeature/RiderAnnotationView.swift +++ b/CriticalMapsKit/Sources/MapFeature/RiderAnnotationView.swift @@ -2,25 +2,47 @@ import MapKit import UIKit final class RiderAnnotationView: MKAnnotationView { + // MARK: - State + + /// Whether active-group highlighting is enabled (the `highlightActiveRiders` + /// setting). + var highlightActiveRiders = false { + didSet { + guard oldValue != highlightActiveRiders else { return } + updateAppearance(animated: true) + } + } + + var isRiderActive = false { + didSet { + guard oldValue != isRiderActive else { return } + updateAppearance(animated: true) + } + } + + // MARK: - Init + override init(annotation: MKAnnotation?, reuseIdentifier: String?) { super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) commonInit() } - + required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) commonInit() } - + + // MARK: - Setup + private func commonInit() { canShowCallout = false - + configureView() - + layer.shouldRasterize = true layer.rasterizationScale = UIScreen.main.scale isAccessibilityElement = false - + registerForTraitChanges( [UITraitPreferredContentSizeCategory.self, UITraitUserInterfaceStyle.self], handler: { (self: Self, _: UITraitCollection) in @@ -28,13 +50,44 @@ final class RiderAnnotationView: MKAnnotationView { } ) } - + private func configureView() { - frame = defineFrame() - layer.cornerRadius = frame.height / 2 - backgroundColor = UIColor.label.resolvedColor(with: traitCollection) + let dotFrame = defineFrame() + frame = dotFrame + layer.cornerRadius = dotFrame.height / 2 + updateAppearance(animated: false) } - + + private func updateAppearance(animated: Bool) { + // Only highlight a rider as part of an active group when the toggle is on. + // With the toggle off no rider is highlighted (all neutral gray). + let highlightAsActive = isRiderActive && highlightActiveRiders + + let targetColor: UIColor = highlightAsActive + ? .brand500 + : highlightAsActive ? .systemGray : .label + + let targetScale: CGFloat = highlightAsActive ? 1.0 : 0.75 + + let applyChanges = { + self.backgroundColor = targetColor.resolvedColor(with: self.traitCollection) + self.transform = CGAffineTransform(scaleX: targetScale, y: targetScale) + } + + // Only animate changes for a view that's already on-screen. While a view is + // being configured before display (no window yet), apply instantly so newly + // added annotations render in their final color rather than fading in from gray. + if animated, window != nil { + UIView.animate(withDuration: 0.25, delay: 0, options: [.beginFromCurrentState, .curveEaseInOut]) { + applyChanges() + } + } else { + applyChanges() + } + } + + // MARK: - Frame + private func defineFrame() -> CGRect { switch traitCollection.preferredContentSizeCategory { case .extraSmall, .small, .medium, .large: @@ -56,6 +109,8 @@ final class RiderAnnotationView: MKAnnotationView { } } +// MARK: - CGRect sizes + private extension CGRect { static let defaultSize = Self(x: 0, y: 0, width: 7, height: 7) static let large = Self(x: 0, y: 0, width: 10, height: 10) diff --git a/CriticalMapsKit/Sources/SettingsFeature/SettingsView.swift b/CriticalMapsKit/Sources/SettingsFeature/SettingsView.swift index 6e93328ad..fee98663a 100644 --- a/CriticalMapsKit/Sources/SettingsFeature/SettingsView.swift +++ b/CriticalMapsKit/Sources/SettingsFeature/SettingsView.swift @@ -38,6 +38,8 @@ public struct SettingsView: View { Section { InfoRow() + + ActiveRidersSettingRow() Button( action: { store.send(.view(.rideEventSettingsRowTapped)) }, @@ -186,6 +188,25 @@ private struct InfoRow: View { } } +private struct ActiveRidersSettingRow: View { + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Shared(.userSettings) var userSettings + + var body: some View { + Toggle(isOn: Binding($userSettings.highlightActiveRiders)) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Settings.HighlightActiveRiders.label) + .font(.body) + Text(L10n.Settings.HighlightActiveRiders.description) + .foregroundColor(colorSchemeContrast.isIncreased ? Color.textPrimary : Color.textSilent) + .font(.subheadline) + } + } + .accessibilityLabel(L10n.Settings.HighlightActiveRiders.label) + .accessibilityHint(L10n.A11y.Settings.HighlightActiveRiders.hint) + } +} + private struct SupportSection: View { let store: StoreOf diff --git a/CriticalMapsKit/Sources/SharedKeys/SharedKeys.swift b/CriticalMapsKit/Sources/SharedKeys/SharedKeys.swift index 926ed5430..e8e510885 100644 --- a/CriticalMapsKit/Sources/SharedKeys/SharedKeys.swift +++ b/CriticalMapsKit/Sources/SharedKeys/SharedKeys.swift @@ -19,10 +19,11 @@ public extension SharedReaderKey where Self == AppStorageKey { } } -public extension SharedReaderKey where Self == AppStorageKey.Default { - /// Whether observation mode prompt was shown, defaults to false - static var didShowObservationModePrompt: Self { - Self[.appStorage("didShowObservationModePrompt"), default: false] +public extension SharedReaderKey where Self == AppStorageKey.Default { + /// The "What's New" version the user has already seen. Empty by default; set to + /// the announced version once dismissed so that release's sheet isn't shown again. + static var lastSeenWhatsNewVersion: Self { + Self[.appStorage("lastSeenWhatsNewVersion"), default: ""] } } diff --git a/CriticalMapsKit/Sources/SharedModels/UserSettings.swift b/CriticalMapsKit/Sources/SharedModels/UserSettings.swift index 0d627868c..a4f7b880d 100644 --- a/CriticalMapsKit/Sources/SharedModels/UserSettings.swift +++ b/CriticalMapsKit/Sources/SharedModels/UserSettings.swift @@ -7,13 +7,16 @@ import SwiftUI public struct UserSettings: Codable, Equatable, Sendable { public var isObservationModeEnabled: Bool public var showInfoViewEnabled: Bool + public var highlightActiveRiders: Bool public init( enableObservationMode: Bool = false, - showInfoViewEnabled: Bool = true + showInfoViewEnabled: Bool = true, + highlightActiveRiders: Bool = false ) { isObservationModeEnabled = enableObservationMode self.showInfoViewEnabled = showInfoViewEnabled + self.highlightActiveRiders = highlightActiveRiders } } diff --git a/CriticalMapsKit/Sources/Styleguide/CMButtonStyle.swift b/CriticalMapsKit/Sources/Styleguide/CMButtonStyle.swift index 8b6ff972b..df7791253 100644 --- a/CriticalMapsKit/Sources/Styleguide/CMButtonStyle.swift +++ b/CriticalMapsKit/Sources/Styleguide/CMButtonStyle.swift @@ -14,19 +14,10 @@ public struct CMButtonStyle: ButtonStyle { ? Color.textPrimaryLight.opacity(0.6) : Color.textPrimaryLight ) - .font(.body) + .font(.headline) .padding(.horizontal, .grid(4)) .padding(.vertical, .grid(4)) - .background( - Color.brand500 - .opacity(isEnabled ? 1.0 : 0.4) - ) - .if(!.iOS26) { view in - view.clipShape(.rect(cornerRadius: .grid(2))) - } - .if(.iOS26) { view in - view.clipShape(.capsule) - } + .modifier(CMButtonBackground(isEnabled: isEnabled)) .scaleEffect(configuration.isPressed ? 0.96 : 1) .accessibleAnimation( .snappy(duration: 0.24), @@ -35,6 +26,25 @@ public struct CMButtonStyle: ButtonStyle { } } +/// Brand button background: a tinted, interactive Liquid Glass capsule on iOS 26 +private struct CMButtonBackground: ViewModifier { + let isEnabled: Bool + + func body(content: Content) -> some View { + if #available(iOS 26, *) { + content + .glassEffect( + .regular.tint(Color.brand500.opacity(isEnabled ? 1 : 0.4)).interactive(), + in: .capsule + ) + } else { + content + .background(Color.brand500.opacity(isEnabled ? 1.0 : 0.4)) + .clipShape(.rect(cornerRadius: .grid(2))) + } + } +} + public extension ButtonStyle where Self == CMButtonStyle { static var criticalMaps: CMButtonStyle { CMButtonStyle() diff --git a/CriticalMapsKit/Sources/Styleguide/Generated/Images.swift b/CriticalMapsKit/Sources/Styleguide/Generated/Images.swift index c417d9256..39aceaa94 100644 --- a/CriticalMapsKit/Sources/Styleguide/Generated/Images.swift +++ b/CriticalMapsKit/Sources/Styleguide/Generated/Images.swift @@ -36,6 +36,8 @@ public enum Asset { public static let cmDotInLogo = ImageAsset(name: "cmDotIn-logo") public static let ghLogo = ImageAsset(name: "gh-logo") public static let translate = ImageAsset(name: "translate") + public static let highlightActiveOff = ImageAsset(name: "highlightActiveOff") + public static let highlightActiveOn = ImageAsset(name: "highlightActiveOn") public static let error = ImageAsset(name: "error") public static let pzLocationShieldSlash = SymbolAsset(name: "pz.location.shield.slash") public static let pzLocationShield = SymbolAsset(name: "pz.location.shield") diff --git a/CriticalMapsKit/Sources/Styleguide/Resources/Images.xcassets/WhatsNew/Contents.json b/CriticalMapsKit/Sources/Styleguide/Resources/Images.xcassets/WhatsNew/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/CriticalMapsKit/Sources/Styleguide/Resources/Images.xcassets/WhatsNew/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CriticalMapsKit/Sources/Styleguide/Resources/Images.xcassets/WhatsNew/highlightActiveOff.imageset/Contents.json b/CriticalMapsKit/Sources/Styleguide/Resources/Images.xcassets/WhatsNew/highlightActiveOff.imageset/Contents.json new file mode 100644 index 000000000..8ce8b5141 --- /dev/null +++ b/CriticalMapsKit/Sources/Styleguide/Resources/Images.xcassets/WhatsNew/highlightActiveOff.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "highlightActiveOff.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CriticalMapsKit/Sources/Styleguide/Resources/Images.xcassets/WhatsNew/highlightActiveOff.imageset/highlightActiveOff.jpg b/CriticalMapsKit/Sources/Styleguide/Resources/Images.xcassets/WhatsNew/highlightActiveOff.imageset/highlightActiveOff.jpg new file mode 100644 index 000000000..3d0c2b3c0 Binary files /dev/null and b/CriticalMapsKit/Sources/Styleguide/Resources/Images.xcassets/WhatsNew/highlightActiveOff.imageset/highlightActiveOff.jpg differ diff --git a/CriticalMapsKit/Sources/Styleguide/Resources/Images.xcassets/WhatsNew/highlightActiveOn.imageset/Contents.json b/CriticalMapsKit/Sources/Styleguide/Resources/Images.xcassets/WhatsNew/highlightActiveOn.imageset/Contents.json new file mode 100644 index 000000000..a241f1a73 --- /dev/null +++ b/CriticalMapsKit/Sources/Styleguide/Resources/Images.xcassets/WhatsNew/highlightActiveOn.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "highlightActiveOn.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CriticalMapsKit/Sources/Styleguide/Resources/Images.xcassets/WhatsNew/highlightActiveOn.imageset/highlightActiveOn.jpg b/CriticalMapsKit/Sources/Styleguide/Resources/Images.xcassets/WhatsNew/highlightActiveOn.imageset/highlightActiveOn.jpg new file mode 100644 index 000000000..a7c8f33b7 Binary files /dev/null and b/CriticalMapsKit/Sources/Styleguide/Resources/Images.xcassets/WhatsNew/highlightActiveOn.imageset/highlightActiveOn.jpg differ diff --git a/CriticalMapsKit/Tests/AppFeatureTests/AppFeatureFeatureTests.swift b/CriticalMapsKit/Tests/AppFeatureTests/AppFeatureFeatureTests.swift index f0bfc0c98..80797e42b 100644 --- a/CriticalMapsKit/Tests/AppFeatureTests/AppFeatureFeatureTests.swift +++ b/CriticalMapsKit/Tests/AppFeatureTests/AppFeatureFeatureTests.swift @@ -209,12 +209,139 @@ struct AppFeatureTests { } ) store.exhaustivity = .off - + await store.send(.onAppear) - + @Shared(.sessionID) var sessionID #expect(sessionID == "00000000-0000-0000-0000-000000000000") } + + @Test + func `onAppear presents WhatsNew sheet on a fresh install`() async { + let locationObserver = AsyncStream.makeStream() + var locationManager: LocationManager = .testValue + locationManager.delegate = { locationObserver.stream } + locationManager.authorizationStatus = { .notDetermined } + locationManager.requestAlwaysAuthorization = {} + locationManager.set = { @Sendable _ in } + + let store = TestStore( + initialState: AppFeature.State(), // lastSeenWhatsNewVersion empty == fresh install + reducer: { AppFeature() }, + withDependencies: { + $0.appVersion = "9999.0.0" // any version, even not the announced one + $0.uuid = .incrementing + $0.locationManager = locationManager + $0.mainQueue = .immediate + $0.mainRunLoop = .immediate + $0.date = .constant(date()) + $0.apiService.getChatMessages = { [] } + $0.apiService.getRiders = { [] } + $0.continuousClock = ImmediateClock() + $0.nextRideService.nextRide = { _, _, _ in [] } + $0.feedbackGenerator.prepare = { @Sendable in } + $0.uiApplicationClient.setUserInterfaceStyle = { _ in } + } + ) + store.exhaustivity = .off + + await store.send(.onAppear) { + $0.isWhatsNewPresented = true + } + } + + @Test + func `onAppear presents WhatsNew sheet when updating to the announced release`() async { + let locationObserver = AsyncStream.makeStream() + var locationManager: LocationManager = .testValue + locationManager.delegate = { locationObserver.stream } + locationManager.authorizationStatus = { .notDetermined } + locationManager.requestAlwaysAuthorization = {} + locationManager.set = { @Sendable _ in } + + var state = AppFeature.State() + state.$lastSeenWhatsNewVersion.withLock { $0 = "4.6.0" } // existing user on a prior version + + let store = TestStore( + initialState: state, + reducer: { AppFeature() }, + withDependencies: { + $0.appVersion = AppFeature.whatsNewVersion + $0.uuid = .incrementing + $0.locationManager = locationManager + $0.mainQueue = .immediate + $0.mainRunLoop = .immediate + $0.date = .constant(date()) + $0.apiService.getChatMessages = { [] } + $0.apiService.getRiders = { [] } + $0.continuousClock = ImmediateClock() + $0.nextRideService.nextRide = { _, _, _ in [] } + $0.feedbackGenerator.prepare = { @Sendable in } + $0.uiApplicationClient.setUserInterfaceStyle = { _ in } + } + ) + store.exhaustivity = .off + + await store.send(.onAppear) { + $0.isWhatsNewPresented = true + } + } + + @Test + func `onAppear does not present WhatsNew sheet once onboarded on a later version`() async { + let locationObserver = AsyncStream.makeStream() + var locationManager: LocationManager = .testValue + locationManager.delegate = { locationObserver.stream } + locationManager.authorizationStatus = { .notDetermined } + locationManager.requestAlwaysAuthorization = {} + locationManager.set = { @Sendable _ in } + + var state = AppFeature.State() + state.$lastSeenWhatsNewVersion.withLock { $0 = "5.0.0" } // already seen, not a fresh install + + let store = TestStore( + initialState: state, + reducer: { AppFeature() }, + withDependencies: { + $0.appVersion = "9999.0.0" // not the announced version + $0.uuid = .incrementing + $0.locationManager = locationManager + $0.mainQueue = .immediate + $0.mainRunLoop = .immediate + $0.date = .constant(date()) + $0.apiService.getChatMessages = { [] } + $0.apiService.getRiders = { [] } + $0.continuousClock = ImmediateClock() + $0.nextRideService.nextRide = { _, _, _ in [] } + $0.feedbackGenerator.prepare = { @Sendable in } + $0.uiApplicationClient.setUserInterfaceStyle = { _ in } + } + ) + store.exhaustivity = .off + + await store.send(.onAppear) + + #expect(store.state.isWhatsNewPresented == false) + } + + @Test + func `dismissing WhatsNew records the current version`() async { + var state = AppFeature.State() + state.isWhatsNewPresented = true + + let store = TestStore( + initialState: state, + reducer: { AppFeature() }, + withDependencies: { + $0.appVersion = "4.8.0" + } + ) + + await store.send(.whatsNewDismissed) { + $0.isWhatsNewPresented = false + $0.$lastSeenWhatsNewVersion.withLock { $0 = "4.8.0" } + } + } @Test func `map action did update locations should fetch next ride`() async { @@ -439,41 +566,6 @@ struct AppFeatureTests { } } - var cancellables: Set = [] - - @Test - mutating func `viewing mode prompt`() async { - let testQueue = DispatchQueue.test - - let store = TestStore( - initialState: AppFeature.State(), - reducer: { AppFeature() }, - withDependencies: { - $0.mainQueue = testQueue.eraseToAnyScheduler() - $0.continuousClock = ImmediateClock() - } - ) - - var didUpdateSettings: [Bool] = [] - store.state.$userSettings - .publisher - .dropFirst() - .sink { didUpdateSettings.append($0.isObservationModeEnabled) } - .store(in: &cancellables) - - await store.send(.settingsButtonTapped) { - $0.destination = .settings(SettingsFeature.State()) - } - await store.send( - .destination( - .presented( - .alert(.setObservationMode(enabled: false)) - ) - ) - ) - #expect(didUpdateSettings == [false]) - } - @Test func `post location should not post location when observer mode is enabled`() async { let state = AppFeature.State() diff --git a/CriticalMapsKit/Tests/MapFeatureTests/RiderActivityFilterTests.swift b/CriticalMapsKit/Tests/MapFeatureTests/RiderActivityFilterTests.swift new file mode 100644 index 000000000..038ca2312 --- /dev/null +++ b/CriticalMapsKit/Tests/MapFeatureTests/RiderActivityFilterTests.swift @@ -0,0 +1,168 @@ +@testable import MapFeature +import SharedModels +import Testing + +@MainActor +@Suite("RiderActivityFilter") +struct RiderActivityFilterTests { + init() { + // Reset static state between tests + RiderAnnotationUpdateClient.previousPositions = [:] + } + + // MARK: - Helpers + + /// Places riders in a tight cluster (< 250m apart) around a base coordinate + private func makeCluster( + around base: Coordinate, + count: Int, + startID: Int = 0 + ) -> [Rider] { + (0 ..< count).map { i in + Rider( + id: "rider-\(startID + i)", + location: .init( + coordinate: Coordinate( + latitude: base.latitude + Double(i) * 0.0005, // ~55m steps + longitude: base.longitude + ), + timestamp: 0 + ) + ) + } + } + + private let berlin = Coordinate(latitude: 52.520, longitude: 13.405) + private let hamburg = Coordinate(latitude: 53.550, longitude: 10.000) + + // MARK: - Isolation + + @Test + func `Single rider is never active`() { + let riders = [Rider(id: "solo", location: .init(coordinate: berlin, timestamp: 0))] + #expect(RiderActivityFilter.classify(riders).isEmpty) + } + + @Test + func `Two riders close together are never active`() { + let riders = makeCluster(around: berlin, count: 2) + #expect(RiderActivityFilter.classify(riders).isEmpty) + } + + @Test + func `Completely isolated rider is inactive regardless of ID count`() { + var riders = makeCluster(around: berlin, count: 10) + let loner = Rider(id: "loner", location: .init(coordinate: hamburg, timestamp: 0)) + riders.append(loner) + + let activeIDs = RiderActivityFilter.classify(riders) + #expect(!activeIDs.contains("loner")) + } + + // MARK: - Activation conditions + + @Test + func `3+ short-range neighbors → active (condition 1)`() { + let riders = makeCluster(around: berlin, count: 4) + let activeIDs = RiderActivityFilter.classify(riders) + #expect(activeIDs.count == 4) + } + + @Test + func `2 short-range + sparse long-range → active (condition 2)`() { + // 3 riders close together (each has 2 short-range neighbours) + // + 10 riders within 8km but outside 250m (longRange = 12 < 15) + var riders = makeCluster(around: berlin, count: 3) + riders += (0 ..< 10).map { i in + Rider( + id: "far-\(i)", + location: .init( + coordinate: Coordinate( + latitude: berlin.latitude + Double(i + 1) * 0.02, + longitude: berlin.longitude + ), + timestamp: 0 + ) + ) + } + let activeIDs = RiderActivityFilter.classify(riders) + // The tight cluster of 3 should be active + #expect(activeIDs.contains("rider-0")) + #expect(activeIDs.contains("rider-1")) + #expect(activeIDs.contains("rider-2")) + } + + @Test + func `All 3 conditions fail → cluster is inactive`() { + // 3 close riders at ~55m steps + var riders = makeCluster(around: berlin, count: 3) + + // 0.0072° ≈ 800m per step + // 8km / 800m = 10 slots → riders at steps 1–10 are inside, steps 11+ are outside + // We need 15 inside, so use smaller steps: 0.004° ≈ 445m + // 8km / 445m ≈ 17 slots → steps 1–17 inside ✓ + riders += (0 ..< 20).map { i in + Rider( + id: "far-\(i)", + location: .init( + coordinate: Coordinate( + latitude: berlin.latitude + Double(i + 1) * 0.004, + longitude: berlin.longitude + ), + timestamp: 0 + ) + ) + } + + // Verify geometry assumptions + let closeToFarDist = riders[0].coordinate.distance(to: riders[3].coordinate) + #expect(closeToFarDist > 250, "First far rider must be outside short range") + #expect(closeToFarDist < 8000, "First far rider must be inside long range") + + // Verify longRange count for rider-0 + let rider0 = riders[0] + let longRangeCount = riders.count(where: { other in + other.id != rider0.id && + rider0.coordinate.distance(to: other.coordinate) <= 8000 + }) + let shortRangeCount = riders.count(where: { other in + other.id != rider0.id && + rider0.coordinate.distance(to: other.coordinate) <= 250 + }) + + #expect(shortRangeCount == 2, "rider-0 should have exactly 2 short-range neighbors") + #expect(longRangeCount >= 7, "rider-0 should have >= 7 long-range neighbors to fail condition 3") + #expect(longRangeCount >= 15, "rider-0 should have >= 15 long-range neighbors to fail condition 2") + + let activeIDs = RiderActivityFilter.classify(riders) + #expect(!activeIDs.contains("rider-0")) + #expect(!activeIDs.contains("rider-1")) + #expect(!activeIDs.contains("rider-2")) + } + + // MARK: - Multiple independent groups + + @Test + func `Two separate groups are both classified independently`() { + let groupA = makeCluster(around: berlin, count: 5, startID: 0) + let groupB = makeCluster(around: hamburg, count: 5, startID: 5) + let activeIDs = RiderActivityFilter.classify(groupA + groupB) + + // All riders in both groups should be active + #expect(activeIDs.count == 10) + groupA.forEach { #expect(activeIDs.contains($0.id)) } + groupB.forEach { #expect(activeIDs.contains($0.id)) } + } + + @Test + func `Lone rider between two large groups stays inactive`() { + let groupA = makeCluster(around: berlin, count: 10, startID: 0) + let groupB = makeCluster(around: hamburg, count: 10, startID: 10) + // Place loner halfway between — within 8km of both groups + let midpoint = Coordinate(latitude: 53.035, longitude: 11.700) + let loner = Rider(id: "loner", location: .init(coordinate: midpoint, timestamp: 0)) + + let activeIDs = RiderActivityFilter.classify(groupA + groupB + [loner]) + #expect(!activeIDs.contains("loner")) + } +} diff --git a/CriticalMapsKit/Tests/MapFeatureTests/RiderAnnotationUpdateClientTests.swift b/CriticalMapsKit/Tests/MapFeatureTests/RiderAnnotationUpdateClientTests.swift index 6eafb5c5a..43e3d1dd4 100644 --- a/CriticalMapsKit/Tests/MapFeatureTests/RiderAnnotationUpdateClientTests.swift +++ b/CriticalMapsKit/Tests/MapFeatureTests/RiderAnnotationUpdateClientTests.swift @@ -64,35 +64,47 @@ struct RiderAnnotationUpdateClientTests { ] @Test - func `map with no annoations should add all and remove none`() { + func `map with no annotations should add all and remove none`() { let mapView = MKMapView() - let updatedAnnotations = RiderAnnotationUpdateClient.update(rider, mapView) + let updatedAnnotations = RiderAnnotationUpdateClient.update( + rider, + mapView, + highlightActiveRiders: false + ) #expect(updatedAnnotations.addedAnnotations.count == 2) #expect(updatedAnnotations.removedAnnotations.isEmpty) } @Test - func `map with no annoations should add some and remove none`() { + func `map with no annotations should add some and remove none`() { let mapView = MKMapView() - let annotations = rider.map(RiderAnnotation.init(rider:)) + let annotations = rider.map { RiderAnnotation(rider: $0) } mapView.addAnnotations(annotations) let newRiders = rider + updatedRiders - let updatedAnnotations = RiderAnnotationUpdateClient.update(newRiders, mapView) + let updatedAnnotations = RiderAnnotationUpdateClient.update( + newRiders, + mapView, + highlightActiveRiders: false + ) #expect(updatedAnnotations.addedAnnotations.count == 2) #expect(updatedAnnotations.removedAnnotations.isEmpty) } @Test - func `map with no annoations should add 2 and remove 2`() { + func `map with no annotations should add 2 and remove 2`() { let mapView = MKMapView() - let annotations = rider.map(RiderAnnotation.init(rider:)) + let annotations = rider.map { RiderAnnotation(rider: $0) } mapView.addAnnotations(annotations) - let updatedAnnotations = RiderAnnotationUpdateClient.update(updatedRiders, mapView) + let updatedAnnotations = RiderAnnotationUpdateClient.update( + updatedRiders, + mapView, + highlightActiveRiders: false + ) #expect(updatedAnnotations.addedAnnotations.count == 2) #expect(updatedAnnotations.removedAnnotations.count == 2) @@ -105,13 +117,17 @@ struct RiderAnnotationUpdateClientTests { } @Test - func `map with no annoations should add 1 and remove 1`() { + func `map with no annotations should add 1 and remove 1`() { let mapView = MKMapView() let newRiders = rider + [updatedRiders[1]] - let annotations = newRiders.map(RiderAnnotation.init(rider:)) + let annotations = newRiders.map { RiderAnnotation(rider: $0) } mapView.addAnnotations(annotations) - let updatedAnnotations = RiderAnnotationUpdateClient.update(rider + [updatedRiders[0]], mapView) + let updatedAnnotations = RiderAnnotationUpdateClient.update( + rider + [updatedRiders[0]], + mapView, + highlightActiveRiders: false + ) #expect(updatedAnnotations.addedAnnotations.count == 1) #expect(updatedAnnotations.removedAnnotations.count == 1) @@ -122,4 +138,93 @@ struct RiderAnnotationUpdateClientTests { let removedIds = updatedAnnotations.removedAnnotations.map(\.rider.id).sorted() #expect(removedIds == [updatedRiders[1]].map(\.id)) } + + @Test + func `Added annotations are stamped with correct isActive from classification`() { + let mapView = MKMapView() + + // Tight cluster of 4 — all should be active + let clusterRiders = (0 ..< 4).map { i in + Rider( + id: "rider-\(i)", + location: .init( + coordinate: Coordinate( + latitude: 52.520 + Double(i) * 0.0005, + longitude: 13.405 + ), + timestamp: fixedDate().timeIntervalSinceReferenceDate + ) + ) + } + + let result = RiderAnnotationUpdateClient.update( + clusterRiders, + mapView, + highlightActiveRiders: false + ) + + #expect(result.addedAnnotations.count == 4) + + let allSatisfyIsActive = result.addedAnnotations.allSatisfy(\.isActive) + #expect(allSatisfyIsActive) + } + + @Test + func `Isolated added rider is stamped as inactive`() { + let mapView = MKMapView() + + let loneRider = Rider( + id: "lone", + location: .init( + coordinate: Coordinate(latitude: 52.520, longitude: 13.405), + timestamp: fixedDate().timeIntervalSinceReferenceDate + ) + ) + + let result = RiderAnnotationUpdateClient.update( + [loneRider], + mapView, + highlightActiveRiders: false + ) + + #expect(result.addedAnnotations.count == 1) + #expect(result.addedAnnotations.first?.isActive == false) + } + + @Test + func `Active state reflects full rider set, not just added riders`() { + let mapView = MKMapView() + + // 3 already on map — they form a cluster with the 1 new rider + let existingRiders = (0 ..< 3).map { i in + Rider( + id: "existing-\(i)", + location: .init( + coordinate: Coordinate( + latitude: 52.520 + Double(i) * 0.0005, + longitude: 13.405 + ), + timestamp: fixedDate().timeIntervalSinceReferenceDate + ) + ) + } + mapView.addAnnotations(existingRiders.map { RiderAnnotation(rider: $0) }) + + let newRider = Rider( + id: "new", + location: .init( + coordinate: Coordinate(latitude: 52.5215, longitude: 13.405), + timestamp: fixedDate().timeIntervalSinceReferenceDate + ) + ) + + let result = RiderAnnotationUpdateClient.update( + existingRiders + [newRider], + mapView, + highlightActiveRiders: false + ) + + // New rider joins the cluster — should be active + #expect(result.addedAnnotations.first?.isActive == true) + } } diff --git a/README.md b/README.md index ce0de2273..0dc750bda 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,31 @@ xcodebuild test -scheme MapFeatureTests -destination 'platform=iOS Simulator,nam - `SettingsFeatureTests` - User preference management - And more... +### Local development with a mock server + +To develop the live map against fake but realistic ride data (instead of the +production API), use the local mock server. It lives in its own repository, +[`criticalmaps-mock-server`](https://github.com/mltbnz/critical-maps-mock-server), +and plays back a moving cluster of riders on a Berlin loop. + +There is **no build dependency** between the app and the server — the app only +talks to it over HTTP. To use it: + +1. Clone and start the server (it listens on `http://localhost:8080`): + ```sh + git clone https://github.com/mltbnz/critical-maps-mock-server + cd criticalmaps-mock-server && swift run + ``` +2. In Xcode, select the **`Critical Maps (Local)`** scheme and run. This build + sets a `DEBUG_LOCAL` flag (via `Config/Debug-Local.xcconfig`) so the app + injects a `ServerConfiguration` pointing at the local server with a fast poll + interval — see [`iOS/AppConfiguration.swift`](iOS/AppConfiguration.swift). +3. The boot log prints the active environment (`environment=LOCAL …`), and the + server logs each incoming request. Set the simulator location to Berlin + (Simulator → Features → Location → Custom: `52.515, 13.366`) to see the ride. + +The normal **`Critical Maps`** scheme is unaffected and always uses production. + ## Contribute - Please report bugs or feature requests with GitHub [issues](https://github.com/CriticalMaps/criticalmaps-ios/issues). diff --git a/groupFeatureAsset.png b/groupFeatureAsset.png new file mode 100644 index 000000000..0462a89cb Binary files /dev/null and b/groupFeatureAsset.png differ diff --git a/iOS/App.swift b/iOS/App.swift index 54dcf0ce9..939de6c68 100644 --- a/iOS/App.swift +++ b/iOS/App.swift @@ -1,6 +1,8 @@ +import ApiClient import AppFeature import AppIntentFeature import ComposableArchitecture +import OSLog import SwiftUI @main @@ -17,6 +19,30 @@ struct CriticalMapsApp: App { } init() { + // Inject the server configuration (production by default; local mock server on + // the `Critical Maps (Local)` build) before the store is first accessed. + let configuration = AppConfiguration.serverConfiguration + prepareDependencies { + $0.serverConfiguration = configuration + } + + // Log which backend the app booted against, so it's easy to confirm the + // active environment in the Xcode console. + let host = [configuration.locationsHost, configuration.locationsPort.map(String.init)] + .compactMap(\.self) + .joined(separator: ":") + Logger.boot.notice( + """ + 🚲 Critical Maps booting · environment=\(AppConfiguration.environmentName, privacy: .public) \ + · locations=\(configuration.scheme, privacy: .public)://\(host, privacy: .public)/locations \ + · pollInterval=\(configuration.pollIntervalSeconds, privacy: .public)s + """ + ) + CriticalMapsShortcuts.updateAppShortcutParameters() } } + +private extension Logger { + static let boot = Logger(subsystem: "CriticalMaps", category: "Boot") +} diff --git a/iOS/AppConfiguration.swift b/iOS/AppConfiguration.swift new file mode 100644 index 000000000..ebf3cf0ef --- /dev/null +++ b/iOS/AppConfiguration.swift @@ -0,0 +1,33 @@ +import ApiClient +import Foundation + +/// Resolves the `ServerConfiguration` for the current build. +/// +/// This lives in the **app target** (not the CriticalMapsKit package) because the +/// `DEBUG_LOCAL` compile flag is set per build configuration on the app target, +/// where compile flags reliably apply — they do not reliably propagate into +/// integrated Swift Package targets. The resolved value is injected into the +/// package at the app's composition root (see `CriticalMapsApp`). +enum AppConfiguration { + /// Human-readable name of the active environment, for logging. + static var environmentName: String { + #if DEBUG_LOCAL + return "LOCAL (mock server)" + #else + return "PRODUCTION" + #endif + } + + static var serverConfiguration: ServerConfiguration { + #if DEBUG_LOCAL + return ServerConfiguration( + scheme: "http", + locationsHost: "localhost", + locationsPort: 8080, + pollIntervalSeconds: 10 + ) + #else + return .production + #endif + } +} diff --git a/iOS/Info.plist b/iOS/Info.plist index 7f2cfab69..a101d8c96 100644 --- a/iOS/Info.plist +++ b/iOS/Info.plist @@ -86,6 +86,11 @@ $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSLocationAlwaysAndWhenInUseUsageDescription CriticalMaps needs the access to be able to share your location with other Critical Maps users. NSLocationAlwaysUsageDescription diff --git a/scripts/add_local_config.rb b/scripts/add_local_config.rb new file mode 100644 index 000000000..c373ac658 --- /dev/null +++ b/scripts/add_local_config.rb @@ -0,0 +1,70 @@ +#!/usr/bin/env ruby +# One-shot project setup for the local mock-server dev environment. +# Adds: iOS/AppConfiguration.swift to the app target, a "Debug Local" build +# configuration (duplicated from Debug) wired to Config/Debug-Local.xcconfig, +# and a shared "Critical Maps (Local)" scheme that runs under Debug Local. +# +# Idempotent: re-running will not create duplicates. + +require 'xcodeproj' +require 'fileutils' + +PROJECT_PATH = File.expand_path('../CriticalMaps.xcodeproj', __dir__) +APP_TARGET_NAME = 'Critical Maps' +NEW_CONFIG = 'Debug Local' +SOURCE_CONFIG = 'Debug' + +project = Xcodeproj::Project.open(PROJECT_PATH) +app_target = project.targets.find { |t| t.name == APP_TARGET_NAME } +raise "App target not found" unless app_target + +# --- 1. Reference the xcconfig files in a "Config" group --------------------- +config_group = project.main_group['Config'] || + project.main_group.new_group('Config', 'Config') +xcconfig_ref = config_group.files.find { |f| f.path == 'Debug-Local.xcconfig' } || + config_group.new_reference('Debug-Local.xcconfig') +unless config_group.files.any? { |f| f.path == 'Shared.xcconfig' } + config_group.new_reference('Shared.xcconfig') +end + +# --- 2. Add iOS/AppConfiguration.swift to the app target --------------------- +ios_group = project.main_group['iOS'] +raise "iOS group not found" unless ios_group +unless ios_group.files.any? { |f| f.display_name == 'AppConfiguration.swift' } + file_ref = ios_group.new_reference('AppConfiguration.swift') + app_target.add_file_references([file_ref]) +end + +# --- 3. Duplicate Debug -> "Debug Local" (project + app target) -------------- +def duplicate_config(proj, configurable, source_name, new_name, xcconfig_ref = nil) + list = configurable.build_configuration_list + return if list[new_name] + source = list[source_name] + raise "Missing source config #{source_name}" unless source + new_config = proj.new(Xcodeproj::Project::Object::XCBuildConfiguration) + new_config.name = new_name + new_config.build_settings = Marshal.load(Marshal.dump(source.build_settings)) + new_config.base_configuration_reference = xcconfig_ref if xcconfig_ref + list.build_configurations << new_config +end + +duplicate_config(project, project, SOURCE_CONFIG, NEW_CONFIG) # project level +duplicate_config(project, app_target, SOURCE_CONFIG, NEW_CONFIG, xcconfig_ref) # app target + +project.save + +# --- 4. Create the "Critical Maps (Local)" shared scheme -------------------- +# Load from a COPY, not the production scheme: the xcodeproj gem mutates the +# file it was initialized from, so loading the original would corrupt it. +schemes_dir = File.join(PROJECT_PATH, 'xcshareddata', 'xcschemes') +source_scheme_path = File.join(schemes_dir, 'Critical Maps.xcscheme') +new_scheme_path = File.join(schemes_dir, 'Critical Maps (Local).xcscheme') +FileUtils.cp(source_scheme_path, new_scheme_path) unless File.exist?(new_scheme_path) +scheme = Xcodeproj::XCScheme.new(new_scheme_path) +# Run the app against the local server; keep test/archive on their defaults. +scheme.launch_action.build_configuration = NEW_CONFIG +scheme.profile_action.build_configuration = NEW_CONFIG +scheme.analyze_action.build_configuration = NEW_CONFIG +scheme.save_as(PROJECT_PATH, 'Critical Maps (Local)', true) + +puts "Done: added '#{NEW_CONFIG}' config + 'Critical Maps (Local)' scheme."