diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml index 9fa50f91..cc440909 100644 --- a/.github/workflows/ios-release.yml +++ b/.github/workflows/ios-release.yml @@ -18,6 +18,10 @@ jobs: fetch-depth: 0 # Ensure that we can operate on the full history ref: main + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.0' + - name: Build iOS XCFramework run: ./build-ios.sh --release working-directory: common diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 4ae138a1..3b5228e5 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -44,6 +44,10 @@ jobs: with: file_pattern: 'Package.swift' + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.0' + - name: Build iOS XCFramework run: ./build-ios.sh --release working-directory: common @@ -82,7 +86,7 @@ jobs: - name: Configure Package.swift for local development run: sed -i '' 's/let useLocalFramework = false/let useLocalFramework = true/' Package.swift - - name: Download libferrostar-rs.xcframework. + - name: Download libferrostar-rs.xcframework uses: actions/download-artifact@v4 with: path: common @@ -94,7 +98,7 @@ jobs: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '15.3' + xcode-version: '16.0' - name: Install xcbeautify run: brew install xcbeautify @@ -149,7 +153,7 @@ jobs: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '15.3' + xcode-version: '16.0' - name: Install xcbeautify run: brew install xcbeautify diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e69de29b..00000000 diff --git a/apple/DemoApp/Demo/DemoNavigationView.swift b/apple/DemoApp/Demo/DemoNavigationView.swift index f2b06271..a4d194d2 100644 --- a/apple/DemoApp/Demo/DemoNavigationView.swift +++ b/apple/DemoApp/Demo/DemoNavigationView.swift @@ -16,7 +16,7 @@ struct DemoNavigationView: View { private let navigationDelegate = NavigationDelegate() // NOTE: This is probably not ideal but works for demo purposes. // This causes a thread performance checker warning log. - private let spokenInstructionObserver = AVSpeechSpokenInstructionObserver(isMuted: false) + private let spokenInstructionObserver = SpokenInstructionObserver.initAVSpeechSynthesizer(isMuted: false) private var locationProvider: LocationProviding @ObservedObject private var ferrostarCore: FerrostarCore @@ -79,6 +79,8 @@ struct DemoNavigationView: View { styleURL: style, camera: $camera, navigationState: ferrostarCore.state, + isMuted: spokenInstructionObserver.isMuted, + onTapMute: spokenInstructionObserver.toggleMute, onTapExit: { stopNavigation() }, makeMapContent: { let source = ShapeSource(identifier: "userLocation") { diff --git a/apple/Sources/FerrostarCore/NavigationState.swift b/apple/Sources/FerrostarCore/NavigationState.swift index 8de21b8a..ce1fb84c 100644 --- a/apple/Sources/FerrostarCore/NavigationState.swift +++ b/apple/Sources/FerrostarCore/NavigationState.swift @@ -57,4 +57,13 @@ public struct NavigationState: Hashable { return annotationJson } + + public var isNavigating: Bool { + switch tripState { + case .navigating: + true + case .complete, .idle: + false + } + } } diff --git a/apple/Sources/FerrostarCore/Speech.swift b/apple/Sources/FerrostarCore/Speech.swift deleted file mode 100644 index bc5abba1..00000000 --- a/apple/Sources/FerrostarCore/Speech.swift +++ /dev/null @@ -1,54 +0,0 @@ -import AVFoundation -import FerrostarCoreFFI -import Foundation - -public protocol SpokenInstructionObserver { - /// Handles spoken instructions as they are triggered. - /// - /// As long as it is used with the supplied ``FerrostarCore`` class, - /// implementors may assume this function will never be called twice - /// for the same instruction during a navigation session. - func spokenInstructionTriggered(_ instruction: SpokenInstruction) - - /// Stops speech and clears the queue of spoken utterances. - func stopAndClearQueue() - - var isMuted: Bool { get set } -} - -public class AVSpeechSpokenInstructionObserver: SpokenInstructionObserver { - public var isMuted: Bool { - didSet { - if isMuted, synthesizer.isSpeaking { - synthesizer.stopSpeaking(at: .immediate) - } - } - } - - public let synthesizer = AVSpeechSynthesizer() - - public init(isMuted: Bool) { - self.isMuted = isMuted - } - - public func spokenInstructionTriggered(_ instruction: FerrostarCoreFFI.SpokenInstruction) { - guard !isMuted else { - return - } - - let utterance: AVSpeechUtterance = if #available(iOS 16.0, *), - let ssml = instruction.ssml, - let ssmlUtterance = AVSpeechUtterance(ssmlRepresentation: ssml) - { - ssmlUtterance - } else { - AVSpeechUtterance(string: instruction.text) - } - - synthesizer.speak(utterance) - } - - public func stopAndClearQueue() { - synthesizer.stopSpeaking(at: .immediate) - } -} diff --git a/apple/Sources/FerrostarCore/Speech/SpeechSynthesizer.swift b/apple/Sources/FerrostarCore/Speech/SpeechSynthesizer.swift new file mode 100644 index 00000000..2ea1a542 --- /dev/null +++ b/apple/Sources/FerrostarCore/Speech/SpeechSynthesizer.swift @@ -0,0 +1,33 @@ +import AVFoundation +import Foundation + +/// An abstracted speech synthesizer that is used by the ``SpokenInstructionObserver`` +/// +/// Any functions that are needed for use in the ``SpokenInstructionObserver`` should be exposed through +/// this protocol. +public protocol SpeechSynthesizer { + // TODO: We could further abstract this to allow other speech synths. + // E.g. with a `struct SpeechUtterance` if and when another speech service comes along. + + var isSpeaking: Bool { get } + func speak(_ utterance: AVSpeechUtterance) + @discardableResult + func stopSpeaking(at boundary: AVSpeechBoundary) -> Bool +} + +extension AVSpeechSynthesizer: SpeechSynthesizer { + // No def required +} + +class PreviewSpeechSynthesizer: SpeechSynthesizer { + public var isSpeaking: Bool = false + + public func speak(_: AVSpeechUtterance) { + // No action for previews + } + + public func stopSpeaking(at _: AVSpeechBoundary) -> Bool { + // No action for previews + true + } +} diff --git a/apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift b/apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift new file mode 100644 index 00000000..a4e86755 --- /dev/null +++ b/apple/Sources/FerrostarCore/Speech/SpokenInstructionObserver.swift @@ -0,0 +1,76 @@ +import AVFoundation +import Combine +import FerrostarCoreFFI +import Foundation + +/// An Spoken instruction provider that takes a speech synthesizer. +public class SpokenInstructionObserver: ObservableObject { + @Published public private(set) var isMuted: Bool + + private let synthesizer: SpeechSynthesizer + private let queue = DispatchQueue(label: "ferrostar-spoken-instruction-observer", qos: .default) + + /// Create a spoken instruction observer with any ``SpeechSynthesizer`` + /// + /// - Parameters: + /// - synthesizer: The speech synthesizer. + /// - isMuted: Whether the speech synthesizer is currently muted. Assume false if unknown. + public init( + synthesizer: SpeechSynthesizer, + isMuted: Bool + ) { + self.synthesizer = synthesizer + self.isMuted = isMuted + } + + public func spokenInstructionTriggered(_ instruction: FerrostarCoreFFI.SpokenInstruction) { + guard !isMuted else { + return + } + + let utterance: AVSpeechUtterance = if #available(iOS 16.0, *), + let ssml = instruction.ssml, + let ssmlUtterance = AVSpeechUtterance(ssmlRepresentation: ssml) + { + ssmlUtterance + } else { + AVSpeechUtterance(string: instruction.text) + } + + queue.async { + self.synthesizer.speak(utterance) + } + } + + /// Toggle the mute. + public func toggleMute() { + let isCurrentlyMuted = isMuted + isMuted = !isCurrentlyMuted + + // This used to have `synthesizer.isSpeaking`, but I think we want to run it regardless. + if isMuted { + queue.async { + self.stopAndClearQueue() + } + } + } + + public func stopAndClearQueue() { + synthesizer.stopSpeaking(at: .immediate) + } +} + +public extension SpokenInstructionObserver { + /// Create a new spoken instruction observer with AFFoundation's AVSpeechSynthesizer. + /// + /// - Parameters: + /// - synthesizer: An instance of AVSpeechSynthesizer. One is provided by default, but you can inject your own. + /// - isMuted: If the synthesizer is muted. This should be false unless you're providing a "hot" synth that is + /// speaking. + /// - Returns: The instance of SpokenInstructionObserver + static func initAVSpeechSynthesizer(synthesizer: AVSpeechSynthesizer = AVSpeechSynthesizer(), + isMuted: Bool = false) -> SpokenInstructionObserver + { + SpokenInstructionObserver(synthesizer: synthesizer, isMuted: isMuted) + } +} diff --git a/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift b/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift index c9d7d430..50c0b6c5 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift @@ -27,6 +27,8 @@ public struct DynamicallyOrientingNavigationView: View, CustomizableNavigatingIn public var midLeading: (() -> AnyView)? public var bottomTrailing: (() -> AnyView)? + let isMuted: Bool + let onTapMute: () -> Void var onTapExit: (() -> Void)? public var minimumSafeAreaInsets: EdgeInsets @@ -49,13 +51,17 @@ public struct DynamicallyOrientingNavigationView: View, CustomizableNavigatingIn camera: Binding, navigationCamera: MapViewCamera = .automotiveNavigation(), navigationState: NavigationState?, + isMuted: Bool, minimumSafeAreaInsets: EdgeInsets = EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16), + onTapMute: @escaping () -> Void, onTapExit: (() -> Void)? = nil, @MapViewContentBuilder makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { self.styleURL = styleURL self.navigationState = navigationState + self.isMuted = isMuted self.minimumSafeAreaInsets = minimumSafeAreaInsets + self.onTapMute = onTapMute self.onTapExit = onTapExit userLayers = makeMapContent() @@ -88,6 +94,9 @@ public struct DynamicallyOrientingNavigationView: View, CustomizableNavigatingIn navigationState: navigationState, speedLimit: speedLimit, speedLimitStyle: speedLimitStyle, + isMuted: isMuted, + showMute: navigationState?.isNavigating == true, + onMute: onTapMute, showZoom: true, onZoomIn: { camera.incrementZoom(by: 1) }, onZoomOut: { camera.incrementZoom(by: -1) }, @@ -109,6 +118,9 @@ public struct DynamicallyOrientingNavigationView: View, CustomizableNavigatingIn navigationState: navigationState, speedLimit: speedLimit, speedLimitStyle: speedLimitStyle, + isMuted: isMuted, + showMute: navigationState?.isNavigating == true, + onMute: onTapMute, showZoom: true, onZoomIn: { camera.incrementZoom(by: 1) }, onZoomOut: { camera.incrementZoom(by: -1) }, @@ -151,7 +163,9 @@ public struct DynamicallyOrientingNavigationView: View, CustomizableNavigatingIn return DynamicallyOrientingNavigationView( styleURL: URL(string: "https://demotiles.maplibre.org/style.json")!, camera: .constant(.center(userLocation.clLocation.coordinate, zoom: 12)), - navigationState: state + navigationState: state, + isMuted: true, + onTapMute: {} ) .navigationFormatterCollection(FoundationFormatterCollection(distanceFormatter: formatter)) } @@ -170,7 +184,9 @@ public struct DynamicallyOrientingNavigationView: View, CustomizableNavigatingIn return DynamicallyOrientingNavigationView( styleURL: URL(string: "https://demotiles.maplibre.org/style.json")!, camera: .constant(.center(userLocation.clLocation.coordinate, zoom: 12)), - navigationState: state + navigationState: state, + isMuted: true, + onTapMute: {} ) .navigationFormatterCollection(FoundationFormatterCollection(distanceFormatter: formatter)) } diff --git a/apple/Sources/FerrostarMapLibreUI/Views/LandscapeNavigationView.swift b/apple/Sources/FerrostarMapLibreUI/Views/LandscapeNavigationView.swift index f418ab50..d313cfc0 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/LandscapeNavigationView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/LandscapeNavigationView.swift @@ -26,6 +26,8 @@ public struct LandscapeNavigationView: View, CustomizableNavigatingInnerGridView public var midLeading: (() -> AnyView)? public var bottomTrailing: (() -> AnyView)? + let isMuted: Bool + let onTapMute: () -> Void var onTapExit: (() -> Void)? public var minimumSafeAreaInsets: EdgeInsets @@ -49,13 +51,17 @@ public struct LandscapeNavigationView: View, CustomizableNavigatingInnerGridView camera: Binding, navigationCamera: MapViewCamera = .automotiveNavigation(), navigationState: NavigationState?, + isMuted: Bool, minimumSafeAreaInsets: EdgeInsets = EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16), + onTapMute: @escaping () -> Void, onTapExit: (() -> Void)? = nil, @MapViewContentBuilder makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { self.styleURL = styleURL self.navigationState = navigationState + self.isMuted = isMuted self.minimumSafeAreaInsets = minimumSafeAreaInsets + self.onTapMute = onTapMute self.onTapExit = onTapExit userLayers = makeMapContent() @@ -82,6 +88,9 @@ public struct LandscapeNavigationView: View, CustomizableNavigatingInnerGridView navigationState: navigationState, speedLimit: speedLimit, speedLimitStyle: speedLimitStyle, + isMuted: isMuted, + showMute: true, + onMute: onTapMute, showZoom: true, onZoomIn: { camera.incrementZoom(by: 1) }, onZoomOut: { camera.incrementZoom(by: -1) }, @@ -119,7 +128,9 @@ public struct LandscapeNavigationView: View, CustomizableNavigatingInnerGridView return LandscapeNavigationView( styleURL: URL(string: "https://demotiles.maplibre.org/style.json")!, camera: .constant(.center(userLocation.clLocation.coordinate, zoom: 12)), - navigationState: state + navigationState: state, + isMuted: true, + onTapMute: {} ) .navigationFormatterCollection(FoundationFormatterCollection(distanceFormatter: formatter)) } @@ -140,7 +151,9 @@ public struct LandscapeNavigationView: View, CustomizableNavigatingInnerGridView return LandscapeNavigationView( styleURL: URL(string: "https://demotiles.maplibre.org/style.json")!, camera: .constant(.center(userLocation.clLocation.coordinate, zoom: 12)), - navigationState: state + navigationState: state, + isMuted: true, + onTapMute: {} ) .navigationFormatterCollection(FoundationFormatterCollection(distanceFormatter: formatter)) } diff --git a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift index ea38b097..264fd2c0 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift @@ -9,7 +9,7 @@ import SwiftUI struct LandscapeNavigationOverlayView: View, CustomizableNavigatingInnerGridView { @Environment(\.navigationFormatterCollection) var formatterCollection: any FormatterCollection - private var navigationState: NavigationState? + private let navigationState: NavigationState? @State private var isInstructionViewExpanded: Bool = false @@ -20,17 +20,27 @@ struct LandscapeNavigationOverlayView: View, CustomizableNavigatingInnerGridView var speedLimit: Measurement? var speedLimitStyle: SpeedLimitView.SignageStyle? + var showZoom: Bool var onZoomIn: () -> Void var onZoomOut: () -> Void + var showCentering: Bool var onCenter: () -> Void + var onTapExit: (() -> Void)? + let showMute: Bool + let isMuted: Bool + let onMute: () -> Void + init( navigationState: NavigationState?, speedLimit: Measurement? = nil, speedLimitStyle: SpeedLimitView.SignageStyle? = nil, + isMuted: Bool, + showMute: Bool = true, + onMute: @escaping () -> Void, showZoom: Bool = false, onZoomIn: @escaping () -> Void = {}, onZoomOut: @escaping () -> Void = {}, @@ -41,6 +51,9 @@ struct LandscapeNavigationOverlayView: View, CustomizableNavigatingInnerGridView self.navigationState = navigationState self.speedLimit = speedLimit self.speedLimitStyle = speedLimitStyle + self.isMuted = isMuted + self.onMute = onMute + self.showMute = showMute self.showZoom = showZoom self.onZoomIn = onZoomIn self.onZoomOut = onZoomOut @@ -87,6 +100,9 @@ struct LandscapeNavigationOverlayView: View, CustomizableNavigatingInnerGridView NavigatingInnerGridView( speedLimit: speedLimit, speedLimitStyle: speedLimitStyle, + isMuted: isMuted, + showMute: showMute, + onMute: onMute, showZoom: showZoom, onZoomIn: onZoomIn, onZoomOut: onZoomOut, diff --git a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift index 56e53020..4fe5bd56 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift @@ -6,10 +6,12 @@ import MapLibreSwiftDSL import MapLibreSwiftUI import SwiftUI -struct PortraitNavigationOverlayView: View, CustomizableNavigatingInnerGridView { +struct PortraitNavigationOverlayView: View, + CustomizableNavigatingInnerGridView +{ @Environment(\.navigationFormatterCollection) var formatterCollection: any FormatterCollection - private var navigationState: NavigationState? + private let navigationState: NavigationState? @State private var isInstructionViewExpanded: Bool = false @State private var instructionsViewSizeWhenNotExpanded: CGSize = .zero @@ -21,17 +23,27 @@ struct PortraitNavigationOverlayView: View, CustomizableNavigatingInnerGridView var speedLimit: Measurement? var speedLimitStyle: SpeedLimitView.SignageStyle? + var showZoom: Bool var onZoomIn: () -> Void var onZoomOut: () -> Void + var showCentering: Bool var onCenter: () -> Void + var onTapExit: (() -> Void)? + let showMute: Bool + let isMuted: Bool + let onMute: () -> Void + init( navigationState: NavigationState?, speedLimit: Measurement? = nil, speedLimitStyle: SpeedLimitView.SignageStyle? = nil, + isMuted: Bool, + showMute: Bool = true, + onMute: @escaping () -> Void, showZoom: Bool = false, onZoomIn: @escaping () -> Void = {}, onZoomOut: @escaping () -> Void = {}, @@ -42,7 +54,10 @@ struct PortraitNavigationOverlayView: View, CustomizableNavigatingInnerGridView self.navigationState = navigationState self.speedLimit = speedLimit self.speedLimitStyle = speedLimitStyle + self.isMuted = isMuted + self.showMute = showMute self.showZoom = showZoom + self.onMute = onMute self.onZoomIn = onZoomIn self.onZoomOut = onZoomOut self.showCentering = showCentering @@ -62,6 +77,9 @@ struct PortraitNavigationOverlayView: View, CustomizableNavigatingInnerGridView NavigatingInnerGridView( speedLimit: speedLimit, speedLimitStyle: speedLimitStyle, + isMuted: isMuted, + showMute: showMute, + onMute: onMute, showZoom: showZoom, onZoomIn: onZoomIn, onZoomOut: onZoomOut, diff --git a/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift b/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift index 501eb3be..0226ce1b 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift @@ -29,6 +29,8 @@ public struct PortraitNavigationView: View, CustomizableNavigatingInnerGridView, @Binding var camera: MapViewCamera let navigationCamera: MapViewCamera + let isMuted: Bool + let onTapMute: () -> Void var onTapExit: (() -> Void)? /// Create a portrait navigation view. This view is optimized for display on a portrait screen where the @@ -50,13 +52,17 @@ public struct PortraitNavigationView: View, CustomizableNavigatingInnerGridView, camera: Binding, navigationCamera: MapViewCamera = .automotiveNavigation(), navigationState: NavigationState?, + isMuted: Bool, minimumSafeAreaInsets: EdgeInsets = EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16), + onTapMute: @escaping () -> Void, onTapExit: (() -> Void)? = nil, @MapViewContentBuilder makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { self.styleURL = styleURL self.navigationState = navigationState + self.isMuted = isMuted self.minimumSafeAreaInsets = minimumSafeAreaInsets + self.onTapMute = onTapMute self.onTapExit = onTapExit userLayers = makeMapContent() @@ -84,6 +90,9 @@ public struct PortraitNavigationView: View, CustomizableNavigatingInnerGridView, navigationState: navigationState, speedLimit: speedLimit, speedLimitStyle: speedLimitStyle, + isMuted: isMuted, + showMute: true, + onMute: onTapMute, showZoom: true, onZoomIn: { camera.incrementZoom(by: 1) }, onZoomOut: { camera.incrementZoom(by: -1) }, @@ -120,7 +129,9 @@ public struct PortraitNavigationView: View, CustomizableNavigatingInnerGridView, return PortraitNavigationView( styleURL: URL(string: "https://demotiles.maplibre.org/style.json")!, camera: .constant(.center(userLocation.clLocation.coordinate, zoom: 12)), - navigationState: state + navigationState: state, + isMuted: true, + onTapMute: {} ) .navigationFormatterCollection(FoundationFormatterCollection(distanceFormatter: formatter)) } @@ -140,7 +151,9 @@ public struct PortraitNavigationView: View, CustomizableNavigatingInnerGridView, return PortraitNavigationView( styleURL: URL(string: "https://demotiles.maplibre.org/style.json")!, camera: .constant(.center(userLocation.clLocation.coordinate, zoom: 12)), - navigationState: state + navigationState: state, + isMuted: true, + onTapMute: {} ) .navigationFormatterCollection(FoundationFormatterCollection(distanceFormatter: formatter)) } diff --git a/apple/Sources/FerrostarSwiftUI/ViewModifiers/CustomizableNavigatingInnerGridView.swift b/apple/Sources/FerrostarSwiftUI/ViewModifiers/CustomizableNavigatingInnerGridView.swift index 3ee5eef1..69f1f77b 100644 --- a/apple/Sources/FerrostarSwiftUI/ViewModifiers/CustomizableNavigatingInnerGridView.swift +++ b/apple/Sources/FerrostarSwiftUI/ViewModifiers/CustomizableNavigatingInnerGridView.swift @@ -1,5 +1,6 @@ import SwiftUI +// TODO: Extend this with the more mundane visibility properties too (ex: show/hide controls) public protocol CustomizableNavigatingInnerGridView where Self: View { var topCenter: (() -> AnyView)? { get set } var topTrailing: (() -> AnyView)? { get set } diff --git a/apple/Sources/FerrostarSwiftUI/Views/Controls/MuteUIButton.swift b/apple/Sources/FerrostarSwiftUI/Views/Controls/MuteUIButton.swift new file mode 100644 index 00000000..a204e6c8 --- /dev/null +++ b/apple/Sources/FerrostarSwiftUI/Views/Controls/MuteUIButton.swift @@ -0,0 +1,32 @@ +import FerrostarCore +import FerrostarCoreFFI +import SwiftUI + +public struct MuteUIButton: View { + let isMuted: Bool + let action: () -> Void + + public init(isMuted: Bool, action: @escaping () -> Void) { + self.isMuted = isMuted + self.action = action + } + + public var body: some View { + Button(action: action) { + Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.2.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18, height: 18) + .padding() + } + .foregroundColor(.black) + .background(Color.white) + .clipShape(Circle()) + } +} + +#Preview { + MuteUIButton(isMuted: true, action: {}) + + MuteUIButton(isMuted: false, action: {}) +} diff --git a/apple/Sources/FerrostarSwiftUI/Views/GridViews/NavigatingInnerGridView.swift b/apple/Sources/FerrostarSwiftUI/Views/GridViews/NavigatingInnerGridView.swift index 6a04ebcb..2dcbc3df 100644 --- a/apple/Sources/FerrostarSwiftUI/Views/GridViews/NavigatingInnerGridView.swift +++ b/apple/Sources/FerrostarSwiftUI/Views/GridViews/NavigatingInnerGridView.swift @@ -1,3 +1,4 @@ +import FerrostarCore import SwiftUI /// When navigation is underway, we use this standardized grid view with pre-defined metadata and interactions. @@ -9,12 +10,16 @@ public struct NavigatingInnerGridView: View, CustomizableNavigatingInnerGridView var speedLimit: Measurement? var speedLimitStyle: SpeedLimitView.SignageStyle? - var showZoom: Bool - var onZoomIn: () -> Void - var onZoomOut: () -> Void + let showZoom: Bool + let onZoomIn: () -> Void + let onZoomOut: () -> Void - var showCentering: Bool - var onCenter: () -> Void + let showCentering: Bool + let onCenter: () -> Void + + let showMute: Bool + let isMuted: Bool + let onMute: () -> Void // MARK: Customizable Containers @@ -31,17 +36,24 @@ public struct NavigatingInnerGridView: View, CustomizableNavigatingInnerGridView /// /// - Parameters: /// - speedLimit: The speed limit provided by the navigation state (or nil) - /// - speedLimitStyle: The speed limit style (Vienna Convention or MUTCD) + /// - speedLimitStyle: The speed limit style: Vienna Convention (most of the world) or MUTCD (US primarily). + /// - isMuted: Is speech currently muted? + /// - showMute: Whether to show the provided mute button or not. /// - showZoom: Whether to show the provided zoom control or not. /// - onZoomIn: The on zoom in tapped action. This should be used to zoom the user in one increment. /// - onZoomOut: The on zoom out tapped action. This should be used to zoom the user out one increment. /// - showCentering: Whether to show the centering control. This is typically determined by the Map's centering /// state. - /// - onCenter: The action that occurs when the user taps the centering control (to re-center the - /// map on the user). + /// - onCenter: The action that occurs when the user taps the centering control (to re-center the map on the + /// user). + /// - showMute: Whether to show the provided mute toggle or not. + /// - spokenInstructionObserver: The spoken instruction observer (for driving mute button state). public init( speedLimit: Measurement? = nil, speedLimitStyle: SpeedLimitView.SignageStyle? = nil, + isMuted: Bool, + showMute: Bool = true, + onMute: @escaping () -> Void, showZoom: Bool = false, onZoomIn: @escaping () -> Void = {}, onZoomOut: @escaping () -> Void = {}, @@ -50,6 +62,9 @@ public struct NavigatingInnerGridView: View, CustomizableNavigatingInnerGridView ) { self.speedLimit = speedLimit self.speedLimitStyle = speedLimitStyle + self.isMuted = isMuted + self.showMute = showMute + self.onMute = onMute self.showZoom = showZoom self.onZoomIn = onZoomIn self.onZoomOut = onZoomOut @@ -70,7 +85,12 @@ public struct NavigatingInnerGridView: View, CustomizableNavigatingInnerGridView } }, topCenter: { topCenter?() }, - topTrailing: { topTrailing?() }, + topTrailing: { + if showMute { + MuteUIButton(isMuted: isMuted, action: onMute) + .shadow(radius: 8) + } + }, midLeading: { midLeading?() }, midCenter: { // This view does not allow center content. @@ -114,8 +134,10 @@ public struct NavigatingInnerGridView: View, CustomizableNavigatingInnerGridView NavigatingInnerGridView( speedLimit: .init(value: 55, unit: .milesPerHour), - showZoom: true, - showCentering: true + speedLimitStyle: .viennaConvention, + isMuted: true, + showMute: true, + onMute: {} ) .padding(.horizontal, 16) diff --git a/apple/Sources/FerrostarSwiftUI/Views/SpeedLimit/USSpeedLimitView.swift b/apple/Sources/FerrostarSwiftUI/Views/SpeedLimit/USSpeedLimitView.swift index 753a826e..30910e1b 100644 --- a/apple/Sources/FerrostarSwiftUI/Views/SpeedLimit/USSpeedLimitView.swift +++ b/apple/Sources/FerrostarSwiftUI/Views/SpeedLimit/USSpeedLimitView.swift @@ -45,7 +45,7 @@ public struct USStyleSpeedLimitView: View { .minimumScaleFactor(0.4) .padding(.horizontal, 2) - Text(unitFormatter.string(from: speedLimit.unit)) + Text(speedLimit.unit.symbol) .font(.caption2.bold()) .foregroundStyle(Color.secondary) .padding(.horizontal, 2) diff --git a/apple/Tests/FerrostarCoreTests/Speech/SpokenObserverTests.swift b/apple/Tests/FerrostarCoreTests/Speech/SpokenObserverTests.swift new file mode 100644 index 00000000..3a950a4d --- /dev/null +++ b/apple/Tests/FerrostarCoreTests/Speech/SpokenObserverTests.swift @@ -0,0 +1,98 @@ +import AVFoundation +import Combine +import FerrostarCoreFFI +import XCTest +@testable import FerrostarCore + +final class MockSpeechSynthesizer: SpeechSynthesizer { + var isSpeaking: Bool = false + + var onSpeak: ((AVSpeechUtterance) -> Void)? + func speak(_ utterance: AVSpeechUtterance) { + onSpeak?(utterance) + } + + var onStopSpeaking: ((AVSpeechBoundary) -> Void)? + func stopSpeaking(at boundary: AVSpeechBoundary) -> Bool { + onStopSpeaking?(boundary) + return true + } +} + +final class SpokenObserverTests: XCTestCase { + var cancellables = Set() + + func test_mute() { + let mockSpeechSynthesizer = MockSpeechSynthesizer() + let spokenObserver = SpokenInstructionObserver(synthesizer: mockSpeechSynthesizer, isMuted: false) + + let muteExp = expectation(description: "isMuted is set to true") + spokenObserver.$isMuted + .sink { newIsMuted in + guard newIsMuted else { + return + } + muteExp.fulfill() + } + .store(in: &cancellables) + + let exp = expectation(description: "stop speaking is called") + mockSpeechSynthesizer.onStopSpeaking = { boundary in + XCTAssertEqual(boundary, .immediate) + exp.fulfill() + } + + spokenObserver.toggleMute() + + wait(for: [muteExp, exp], timeout: 3.0) + } + + func test_speakWhileMuted() { + let mockSpeechSynthesizer = MockSpeechSynthesizer() + let spokenObserver = SpokenInstructionObserver(synthesizer: mockSpeechSynthesizer, isMuted: false) + spokenObserver.toggleMute() + + mockSpeechSynthesizer.onSpeak = { _ in + XCTFail("Speak should never be called when isMuted is true") + } + + let exp = expectation(description: "") + Task { + spokenObserver.spokenInstructionTriggered(.init( + text: "Speak", + ssml: "Speak", + triggerDistanceBeforeManeuver: 1.0, + utteranceId: .init() + )) + try await Task.sleep(nanoseconds: 1_000_000_000) + exp.fulfill() + } + + wait(for: [exp], timeout: 3.0) + } + + func test_speakWhileUnmuted() { + let mockSpeechSynthesizer = MockSpeechSynthesizer() + let spokenObserver = SpokenInstructionObserver(synthesizer: mockSpeechSynthesizer, isMuted: false) + + let exp = expectation(description: "") + mockSpeechSynthesizer.onSpeak = { utterance in + XCTAssertEqual(utterance.speechString, "Speak") + exp.fulfill() + } + + let taskExp = expectation(description: "") + Task { + spokenObserver.spokenInstructionTriggered(.init( + text: "Speak", + ssml: "Speak", + triggerDistanceBeforeManeuver: 1.0, + utteranceId: .init() + )) + try await Task.sleep(nanoseconds: 1_000_000_000) + taskExp.fulfill() + } + + wait(for: [exp, taskExp], timeout: 3.0) + } +} diff --git a/apple/Tests/FerrostarSwiftUITests/Views/MuteButtonTests.swift b/apple/Tests/FerrostarSwiftUITests/Views/MuteButtonTests.swift new file mode 100644 index 00000000..06ec0275 --- /dev/null +++ b/apple/Tests/FerrostarSwiftUITests/Views/MuteButtonTests.swift @@ -0,0 +1,31 @@ +import SwiftUI +import XCTest +@testable import FerrostarSwiftUI + +final class MuteUIButtonTests: XCTestCase { + func test_muted() { + assertView { + MuteUIButton(isMuted: true, action: {}) + } + } + + func test_unmuted() { + assertView { + MuteUIButton(isMuted: false, action: {}) + } + } + + // MARK: Dark Mode + + func test_muted_darkMode() { + assertView(colorScheme: .dark) { + MuteUIButton(isMuted: true, action: {}) + } + } + + func test_unmuted_darkMode() { + assertView(colorScheme: .dark) { + MuteUIButton(isMuted: false, action: {}) + } + } +} diff --git a/apple/Tests/FerrostarSwiftUITests/Views/NavigatingInnerGridViewTests.swift b/apple/Tests/FerrostarSwiftUITests/Views/NavigatingInnerGridViewTests.swift index f556ae78..8b923faf 100644 --- a/apple/Tests/FerrostarSwiftUITests/Views/NavigatingInnerGridViewTests.swift +++ b/apple/Tests/FerrostarSwiftUITests/Views/NavigatingInnerGridViewTests.swift @@ -1,5 +1,6 @@ import SwiftUI import XCTest +@testable import FerrostarCore @testable import FerrostarSwiftUI final class NavigatingInnerGridViewTests: XCTestCase { @@ -8,6 +9,8 @@ final class NavigatingInnerGridViewTests: XCTestCase { NavigatingInnerGridView( speedLimit: .init(value: 55, unit: .milesPerHour), speedLimitStyle: .usStyle, + isMuted: true, + onMute: {}, showZoom: true, showCentering: true ) @@ -20,6 +23,22 @@ final class NavigatingInnerGridViewTests: XCTestCase { NavigatingInnerGridView( speedLimit: .init(value: 100, unit: .kilometersPerHour), speedLimitStyle: .viennaConvention, + isMuted: false, + onMute: {}, + showZoom: true, + showCentering: true + ) + .environment(\.locale, .init(identifier: "fr_FR")) + .padding() + } + } + + func test_muteIsHidden() { + assertView { + NavigatingInnerGridView( + isMuted: true, + showMute: false, + onMute: {}, showZoom: true, showCentering: true ) diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/MuteButtonTests/test_muted.1.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/MuteButtonTests/test_muted.1.png new file mode 100644 index 00000000..459825b4 Binary files /dev/null and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/MuteButtonTests/test_muted.1.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/MuteButtonTests/test_muted_darkMode.1.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/MuteButtonTests/test_muted_darkMode.1.png new file mode 100644 index 00000000..459825b4 Binary files /dev/null and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/MuteButtonTests/test_muted_darkMode.1.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/MuteButtonTests/test_unmuted.1.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/MuteButtonTests/test_unmuted.1.png new file mode 100644 index 00000000..b97d371d Binary files /dev/null and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/MuteButtonTests/test_unmuted.1.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/MuteButtonTests/test_unmuted_darkMode.1.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/MuteButtonTests/test_unmuted_darkMode.1.png new file mode 100644 index 00000000..b97d371d Binary files /dev/null and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/MuteButtonTests/test_unmuted_darkMode.1.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_USStyle_speedLimit_inGridView.1.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_USStyle_speedLimit_inGridView.1.png index 78a60c07..02796bb9 100644 Binary files a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_USStyle_speedLimit_inGridView.1.png and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_USStyle_speedLimit_inGridView.1.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_ViennaConventionStyle_speedLimit_inGridView.1.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_ViennaConventionStyle_speedLimit_inGridView.1.png index c2640c19..9ca34fb9 100644 Binary files a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_ViennaConventionStyle_speedLimit_inGridView.1.png and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_ViennaConventionStyle_speedLimit_inGridView.1.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_muteIsHidden.1.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_muteIsHidden.1.png new file mode 100644 index 00000000..3aef3cea Binary files /dev/null and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/test_muteIsHidden.1.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.1.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.1.png index 0e00f9cd..38d6a79b 100644 Binary files a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.1.png and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.1.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.2.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.2.png index 8bf2f2c1..0602b8a0 100644 Binary files a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.2.png and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.2.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.3.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.3.png index 0f5bf3e9..c485e9ae 100644 Binary files a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.3.png and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.3.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.4.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.4.png index a5affcba..865c350a 100644 Binary files a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.4.png and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews.4.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.1.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.1.png index 0e00f9cd..38d6a79b 100644 Binary files a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.1.png and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.1.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.2.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.2.png index 8bf2f2c1..0602b8a0 100644 Binary files a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.2.png and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.2.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.3.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.3.png index 0f5bf3e9..c485e9ae 100644 Binary files a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.3.png and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.3.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.4.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.4.png index a5affcba..865c350a 100644 Binary files a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.4.png and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/SpeedLimitViewTests/testUSStyleSpeedLimitViews_darkMode.4.png differ