From 5fb7dd560585ace3d73d7218ca29621352ccfca9 Mon Sep 17 00:00:00 2001 From: MBtheOtaku Date: Fri, 13 Mar 2026 16:40:09 -0400 Subject: [PATCH] Add Manage Callouts toggles for sound effects and delays Adds two new settings under Manage Callouts: Sound Effects and Delays. Persists both toggles in app settings with defaults enabled. Wires settings UI logic to surface and handle the new switches alongside existing callout options. Applies Sound Effects toggle to callout prefix/earcon behavior in automatic destination/intersection/POI paths. Applies Delays toggle to automatic callout spacing by enabling/disabling callout delay timing. Adds new en-US localization keys for the new toggle labels and helper text. Keeps existing Allow Callouts collapse behavior intact while including the new rows when enabled --- .../en-US.lproj/Localizable.strings | 12 ++ .../AutoCalloutSettingsProvider.swift | 2 + .../Code/App/Settings/SettingsContext.swift | 22 +++ .../Default/AutoCalloutGenerator.swift | 11 +- .../Default/BeaconCalloutGenerator.swift | 9 +- .../Default/Callouts/DestinationCallout.swift | 5 +- .../Callouts/IntersectionCallout.swift | 5 +- .../Default/IntersectionGenerator.swift | 10 +- .../Settings/CalloutSettingsCellView.swift | 23 ++++ .../Settings/SettingsViewController.swift | 126 +++++++++++++++--- 10 files changed, 197 insertions(+), 28 deletions(-) diff --git a/apps/ios/GuideDogs/Assets/Localization/en-US.lproj/Localizable.strings b/apps/ios/GuideDogs/Assets/Localization/en-US.lproj/Localizable.strings index a2384bcb3..418f657ae 100644 --- a/apps/ios/GuideDogs/Assets/Localization/en-US.lproj/Localizable.strings +++ b/apps/ios/GuideDogs/Assets/Localization/en-US.lproj/Localizable.strings @@ -1626,6 +1626,18 @@ /* Button, Allow Callouts */ "callouts.allow_callouts" = "Allow Callouts"; +/* Toggle label for enabling/disabling callout sound effects */ +"callouts.sound_effects" = "Sound Effects"; + +/* Toggle description for enabling/disabling callout sound effects */ +"callouts.sound_effects.info" = "Sounds that play before spoken callouts"; + +/* Toggle label for enabling/disabling pauses between automatic callouts */ +"callouts.delays" = "Delays"; + +/* Toggle description for enabling/disabling pauses between automatic callouts */ +"callouts.delays.info" = "Short pauses between automatic callouts"; + /* Callouts Title, Places and Landmarks */ "callouts.places_and_landmarks" = "Places and Landmarks"; diff --git a/apps/ios/GuideDogs/Code/App/Settings/AutoCalloutSettingsProvider.swift b/apps/ios/GuideDogs/Code/App/Settings/AutoCalloutSettingsProvider.swift index e331d4bdf..b1671cd5b 100644 --- a/apps/ios/GuideDogs/Code/App/Settings/AutoCalloutSettingsProvider.swift +++ b/apps/ios/GuideDogs/Code/App/Settings/AutoCalloutSettingsProvider.swift @@ -8,6 +8,8 @@ protocol AutoCalloutSettingsProvider: AnyObject { var automaticCalloutsEnabled: Bool { get set } + var calloutSoundEffectsEnabled: Bool { get set } + var calloutDelaysEnabled: Bool { get set } var placeSenseEnabled: Bool { get set } var landmarkSenseEnabled: Bool { get set } var mobilitySenseEnabled: Bool { get set } diff --git a/apps/ios/GuideDogs/Code/App/Settings/SettingsContext.swift b/apps/ios/GuideDogs/Code/App/Settings/SettingsContext.swift index bca0eddd0..8a9d26164 100644 --- a/apps/ios/GuideDogs/Code/App/Settings/SettingsContext.swift +++ b/apps/ios/GuideDogs/Code/App/Settings/SettingsContext.swift @@ -44,6 +44,8 @@ class SettingsContext { fileprivate static let useOldBeacon = "GDASettingsUseOldBeacon" fileprivate static let playBeaconStartEndMelody = "GDAPlayBeaconStartEndMelody" fileprivate static let automaticCalloutsEnabled = "GDASettingsAutomaticCalloutsEnabled" + fileprivate static let calloutSoundEffectsEnabled = "GDASettingsCalloutSoundEffectsEnabled" + fileprivate static let calloutDelaysEnabled = "GDASettingsCalloutDelaysEnabled" fileprivate static let shakeCalloutsEnabled = "GDASettingsShakeCalloutsEnabled" fileprivate static let sensePlace = "GDASettingsPlaceSenseEnabled" fileprivate static let senseLandmark = "GDASettingsLandmarkSenseEnabled" @@ -99,6 +101,8 @@ class SettingsContext { Keys.useOldBeacon: false, Keys.playBeaconStartEndMelody: false, Keys.automaticCalloutsEnabled: true, + Keys.calloutSoundEffectsEnabled: true, + Keys.calloutDelaysEnabled: true, Keys.shakeCalloutsEnabled: false, Keys.sensePlace: true, Keys.senseLandmark: true, @@ -425,6 +429,24 @@ extension SettingsContext: AutoCalloutSettingsProvider { NotificationCenter.default.post(name: .automaticCalloutsEnabledChanged, object: self, userInfo: [Keys.enabled: newValue]) } } + + var calloutSoundEffectsEnabled: Bool { + get { + return userDefaults.bool(forKey: Keys.calloutSoundEffectsEnabled) + } + set(newValue) { + userDefaults.set(newValue, forKey: Keys.calloutSoundEffectsEnabled) + } + } + + var calloutDelaysEnabled: Bool { + get { + return userDefaults.bool(forKey: Keys.calloutDelaysEnabled) + } + set(newValue) { + userDefaults.set(newValue, forKey: Keys.calloutDelaysEnabled) + } + } var shakeCalloutsEnabled: Bool { get { diff --git a/apps/ios/GuideDogs/Code/Behaviors/Default/AutoCalloutGenerator.swift b/apps/ios/GuideDogs/Code/Behaviors/Default/AutoCalloutGenerator.swift index f007d716e..3bc7f1fda 100644 --- a/apps/ios/GuideDogs/Code/Behaviors/Default/AutoCalloutGenerator.swift +++ b/apps/ios/GuideDogs/Code/Behaviors/Default/AutoCalloutGenerator.swift @@ -118,7 +118,10 @@ class AutoCalloutGenerator: AutomaticGenerator, ManualGenerator { // MARK: - Private Constants private let inVehicleBeaconUpdateDistance: CLLocationDistance = 1000.0 // meters - private let calloutDelay = 0.75 + private let defaultCalloutDelay: TimeInterval = 0.75 + private var calloutDelay: TimeInterval? { + return settings.calloutDelaysEnabled ? defaultCalloutDelay : nil + } // MARK: - Private Properties @@ -435,7 +438,7 @@ class AutoCalloutGenerator: AutomaticGenerator, ManualGenerator { guard let dataView = spatialData.getDataView(for: location, searchDistance: context.searchDistance) else { let callouts = filterAnnounceablePOIs(prioritizedPOIs, near: location, context: context) { - return POICallout(.auto, poi: $0, location: location) + return POICallout(.auto, poi: $0, location: location, includePrefixSound: settings.calloutSoundEffectsEnabled) } if callouts.isEmpty { @@ -452,11 +455,11 @@ class AutoCalloutGenerator: AutomaticGenerator, ManualGenerator { // Get the POIs sorted by distance, and filtered for duplicate names let prioritizedCallouts = filterAnnounceablePOIs(prioritizedPOIs, near: location, context: context) { - return POICallout(.auto, poi: $0, location: location) + return POICallout(.auto, poi: $0, location: location, includePrefixSound: settings.calloutSoundEffectsEnabled) } let defaultCallouts = !settings.automaticCalloutsEnabled ? [] : filterAnnounceablePOIs(dataView.pois, near: location, context: context) { - return POICallout(.auto, key: $0.key, location: location) + return POICallout(.auto, key: $0.key, location: location, includePrefixSound: settings.calloutSoundEffectsEnabled) } if defaultCallouts.count + prioritizedCallouts.count == 0 { diff --git a/apps/ios/GuideDogs/Code/Behaviors/Default/BeaconCalloutGenerator.swift b/apps/ios/GuideDogs/Code/Behaviors/Default/BeaconCalloutGenerator.swift index f381ddd27..3240b9a4c 100644 --- a/apps/ios/GuideDogs/Code/Behaviors/Default/BeaconCalloutGenerator.swift +++ b/apps/ios/GuideDogs/Code/Behaviors/Default/BeaconCalloutGenerator.swift @@ -62,7 +62,10 @@ class BeaconCalloutGenerator: AutomaticGenerator, ManualGenerator { // MARK: - Private Constants private let inVehicleBeaconUpdateDistance: CLLocationDistance = 1000.0 // meters - private let calloutDelay: TimeInterval = 0.75 + private let defaultCalloutDelay: TimeInterval = 0.75 + private var calloutDelay: TimeInterval? { + return settings.calloutDelaysEnabled ? defaultCalloutDelay : nil + } // MARK: - Private Properties @@ -100,7 +103,7 @@ class BeaconCalloutGenerator: AutomaticGenerator, ManualGenerator { func handle(event: UserInitiatedEvent, verbosity: Verbosity) -> HandledEventAction? { switch event { case let event as BeaconCalloutEvent: - return .playCallouts(CalloutGroup([DestinationCallout(.auto, event.beaconId)], action: .interruptAndClear, logContext: event.logContext)) + return .playCallouts(CalloutGroup([DestinationCallout(.auto, event.beaconId, includePrefixSound: settings.calloutSoundEffectsEnabled)], action: .interruptAndClear, logContext: event.logContext)) case let event as BeaconChangedEvent: guard settings.automaticCalloutsEnabled else { @@ -302,7 +305,7 @@ class BeaconCalloutGenerator: AutomaticGenerator, ManualGenerator { configureDestinationUpdates() // Build the destination callout - return [DestinationCallout(origin, key, causedAudioDisable)] + return [DestinationCallout(origin, key, causedAudioDisable, includePrefixSound: settings.calloutSoundEffectsEnabled)] } // MARK: - Helper Methods diff --git a/apps/ios/GuideDogs/Code/Behaviors/Default/Callouts/DestinationCallout.swift b/apps/ios/GuideDogs/Code/Behaviors/Default/Callouts/DestinationCallout.swift index aaf398f2b..1968c93a2 100644 --- a/apps/ios/GuideDogs/Code/Behaviors/Default/Callouts/DestinationCallout.swift +++ b/apps/ios/GuideDogs/Code/Behaviors/Default/Callouts/DestinationCallout.swift @@ -23,7 +23,7 @@ struct DestinationCallout: POICalloutProtocol { return "destination" } - let includePrefixSound = true + let includePrefixSound: Bool var prefixSound: Sound? { return GlyphSound(.startJourney) @@ -52,10 +52,11 @@ struct DestinationCallout: POICalloutProtocol { return marker?.getPOI() } - init(_ calloutOrigin: CalloutOrigin, _ entityKey: String, _ causedAudioDisabled: Bool = false) { + init(_ calloutOrigin: CalloutOrigin, _ entityKey: String, _ causedAudioDisabled: Bool = false, includePrefixSound: Bool = true) { self.origin = calloutOrigin self.entityKey = entityKey self.causedAudioDisabled = causedAudioDisabled + self.includePrefixSound = includePrefixSound } func hasSameEntity(_ rhs: POICallout) -> Bool { diff --git a/apps/ios/GuideDogs/Code/Behaviors/Default/Callouts/IntersectionCallout.swift b/apps/ios/GuideDogs/Code/Behaviors/Default/Callouts/IntersectionCallout.swift index c2827f361..fb7b1982c 100644 --- a/apps/ios/GuideDogs/Code/Behaviors/Default/Callouts/IntersectionCallout.swift +++ b/apps/ios/GuideDogs/Code/Behaviors/Default/Callouts/IntersectionCallout.swift @@ -25,7 +25,7 @@ struct IntersectionCallout: CalloutProtocol { return "intersection" } - let includePrefixSound = true + let includePrefixSound: Bool var prefixSound: Sound? { return GlyphSound(SuperCategory.intersections.glyph) @@ -38,11 +38,12 @@ struct IntersectionCallout: CalloutProtocol { return SpatialDataCache.intersectionByKey(key) } - init(_ calloutOrigin: CalloutOrigin, _ intersectionKey: String, _ isRoundabout: Bool = false, _ userHeading: CLLocationDirection) { + init(_ calloutOrigin: CalloutOrigin, _ intersectionKey: String, _ isRoundabout: Bool = false, _ userHeading: CLLocationDirection, includePrefixSound: Bool = true) { self.origin = calloutOrigin self.key = intersectionKey self.isRoundabout = isRoundabout self.heading = userHeading + self.includePrefixSound = includePrefixSound } func sounds(for location: CLLocation?, isRepeat: Bool, automotive: Bool = false) -> Sounds { diff --git a/apps/ios/GuideDogs/Code/Behaviors/Default/IntersectionGenerator.swift b/apps/ios/GuideDogs/Code/Behaviors/Default/IntersectionGenerator.swift index bf36af9a1..946ecd3f8 100644 --- a/apps/ios/GuideDogs/Code/Behaviors/Default/IntersectionGenerator.swift +++ b/apps/ios/GuideDogs/Code/Behaviors/Default/IntersectionGenerator.swift @@ -136,7 +136,11 @@ class IntersectionGenerator: AutomaticGenerator { return .noAction case let event as IntersectionArrivalEvent: - let callout = IntersectionCallout(.intersection, event.key, event.isRoundabout, event.heading) + let callout = IntersectionCallout(.intersection, + event.key, + event.isRoundabout, + event.heading, + includePrefixSound: SettingsContext.shared.calloutSoundEffectsEnabled) GDATelemetry.track("callout", with: ["context": "intersection.arrival", "type": callout.logCategory, @@ -146,7 +150,9 @@ class IntersectionGenerator: AutomaticGenerator { return .playCallouts(CalloutGroup([callout], action: .interruptAndClear, logContext: "intersections")) case let event as IntersectionDepartureEvent: - let callout = event.geocodedLocation.buildCallout(origin: .intersection, sound: true, useClosestRoadIfAvailable: true) + let callout = event.geocodedLocation.buildCallout(origin: .intersection, + sound: SettingsContext.shared.calloutSoundEffectsEnabled, + useClosestRoadIfAvailable: true) return .playCallouts(CalloutGroup([callout], action: .enqueue, logContext: "intersections")) default: diff --git a/apps/ios/GuideDogs/Code/Visual UI/Controls/Settings/CalloutSettingsCellView.swift b/apps/ios/GuideDogs/Code/Visual UI/Controls/Settings/CalloutSettingsCellView.swift index 8b6d09251..bd7d01fa2 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/Controls/Settings/CalloutSettingsCellView.swift +++ b/apps/ios/GuideDogs/Code/Visual UI/Controls/Settings/CalloutSettingsCellView.swift @@ -13,7 +13,14 @@ protocol CalloutSettingsCellViewDelegate: AnyObject { } internal enum CalloutSettingCellType { +<<<<<<< Updated upstream case all, poi, mobility, beacon, shake +======= + case all, soundEffects, delays, poi, beacon, shake + case transportation + case intersection + case safety +>>>>>>> Stashed changes } class CalloutSettingsCellView: UITableViewCell { @@ -32,7 +39,14 @@ class CalloutSettingsCellView: UITableViewCell { switch type { case .all: settingSwitch.isOn = SettingsContext.shared.automaticCalloutsEnabled +<<<<<<< Updated upstream return +======= + case .soundEffects: + settingSwitch.isOn = SettingsContext.shared.calloutSoundEffectsEnabled + case .delays: + settingSwitch.isOn = SettingsContext.shared.calloutDelaysEnabled +>>>>>>> Stashed changes case .poi: settingSwitch.isOn = SettingsContext.shared.placeSenseEnabled return @@ -71,8 +85,17 @@ class CalloutSettingsCellView: UITableViewCell { case .all: SettingsContext.shared.automaticCalloutsEnabled = isOn GDATelemetry.track("settings.allow_callouts", value: isOn.description) +<<<<<<< Updated upstream return +======= + case .soundEffects: + SettingsContext.shared.calloutSoundEffectsEnabled = isOn + GDATelemetry.track("settings.callout_sound_effects", value: isOn.description) + case .delays: + SettingsContext.shared.calloutDelaysEnabled = isOn + GDATelemetry.track("settings.callout_delays", value: isOn.description) +>>>>>>> Stashed changes case .poi: // Places, Landmark, and Information Senses SettingsContext.shared.placeSenseEnabled = isOn diff --git a/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Settings/SettingsViewController.swift b/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Settings/SettingsViewController.swift index d5e5ef20c..129fbf2bb 100644 --- a/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Settings/SettingsViewController.swift +++ b/apps/ios/GuideDogs/Code/Visual UI/View Controllers/Settings/SettingsViewController.swift @@ -21,12 +21,26 @@ class SettingsViewController: BaseTableViewController { // case telemetry = 6 } +<<<<<<< Updated upstream private enum CalloutsRow: Int, CaseIterable { case all = 0 case poi = 1 case mobility = 2 case beacon = 3 case shake = 4 +======= + /// Rows in the "Callouts" section + private enum CalloutsRow: Int, CaseIterable { + case all = 0 + case soundEffects = 1 + case delays = 2 + case poi = 3 + case mobility = 4 + case safety = 5 + case intersection = 6 + case beacon = 7 + case shake = 8 +>>>>>>> Stashed changes } private static let cellIdentifiers: [IndexPath: String] = [ @@ -51,12 +65,19 @@ class SettingsViewController: BaseTableViewController { // IndexPath(row: 0, section: Section.telemetry.rawValue): "telemetry" ] +<<<<<<< Updated upstream private static let collapsibleCalloutIndexPaths: [IndexPath] = [ IndexPath(row: CalloutsRow.poi.rawValue, section: Section.callouts.rawValue), IndexPath(row: CalloutsRow.mobility.rawValue, section: Section.callouts.rawValue), IndexPath(row: CalloutsRow.beacon.rawValue, section: Section.callouts.rawValue), IndexPath(row: CalloutsRow.shake.rawValue, section: Section.callouts.rawValue) ] +======= + /// Which sub‑rows collapse/expand under "Allow Callouts" + private static let collapsibleCalloutIndexPaths: [IndexPath] = CalloutsRow.allCases + .filter { $0 != .all } + .map { IndexPath(row: $0.rawValue, section: Section.callouts.rawValue) } +>>>>>>> Stashed changes // MARK: Properties @@ -84,7 +105,7 @@ class SettingsViewController: BaseTableViewController { switch sectionType { case .general: return 6 case .audio: return 1 - case .callouts: return SettingsContext.shared.automaticCalloutsEnabled ? 5 : 1 + case .callouts: return SettingsContext.shared.automaticCalloutsEnabled ? CalloutsRow.allCases.count : 1 case .streetPreview: return 1 case .troubleshooting: return 1 case .about: return 1 @@ -99,26 +120,37 @@ class SettingsViewController: BaseTableViewController { return tableView.dequeueReusableCell(withIdentifier: identifier ?? "default", for: indexPath) } +<<<<<<< Updated upstream switch sectionType { - case .callouts: - let cell = tableView.dequeueReusableCell(withIdentifier: identifier ?? "default", for: indexPath) as! CalloutSettingsCellView - cell.delegate = self - - if let rowType = CalloutsRow(rawValue: indexPath.row) { - switch rowType { - case .all: cell.type = .all - case .poi: cell.type = .poi - case .mobility: cell.type = .mobility - case .beacon: cell.type = .beacon - case .shake: cell.type = .shake - } - } - +======= + if Section(rawValue: indexPath.section) == .callouts, + let rowType = CalloutsRow(rawValue: indexPath.row), + rowType == .soundEffects || rowType == .delays { + let cell = makeProgrammaticCalloutCell(for: rowType) + configureCalloutCell(cell, at: indexPath) return cell + } + + let identifier = SettingsViewController.cellIdentifiers[indexPath] ?? "default" + let cell = tableView.dequeueReusableCell(withIdentifier: identifier, + for: indexPath) + + switch Section(rawValue: indexPath.section) { +>>>>>>> Stashed changes + case .callouts: + let calloutCell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as! CalloutSettingsCellView + configureCalloutCell(calloutCell, at: indexPath) + return calloutCell +<<<<<<< Updated upstream // case .telemetry: // let cell = tableView.dequeueReusableCell(withIdentifier: identifier ?? "default", for: indexPath) as! TelemetrySettingsTableViewCell // cell.parent = self +======= + case .telemetry: + let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as! TelemetrySettingsTableViewCell + cell.parent = self +>>>>>>> Stashed changes // return cell @@ -133,9 +165,73 @@ class SettingsViewController: BaseTableViewController { } +<<<<<<< Updated upstream // MARK: UITableViewDataSource override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { +======= + private func configureCalloutCell(_ cell: CalloutSettingsCellView, + at indexPath: IndexPath) { + cell.delegate = self + guard let rowType = CalloutsRow(rawValue: indexPath.row) else { + return + } + + switch rowType { + case .all: + cell.type = .all + case .soundEffects: + cell.type = .soundEffects + case .delays: + cell.type = .delays + case .poi: + cell.type = .poi + case .mobility: + // the "Mobility" toggle now maps to the transportation case + cell.type = .transportation + case .safety: + cell.type = .safety + case .intersection: + cell.type = .intersection + case .beacon: + cell.type = .beacon + case .shake: + cell.type = .shake + } + } + + private func makeProgrammaticCalloutCell(for rowType: CalloutsRow) -> CalloutSettingsCellView { + let cell = CalloutSettingsCellView(style: .subtitle, reuseIdentifier: "callout.dynamic.\(rowType.rawValue)") + cell.selectionStyle = .none + cell.backgroundColor = UIColor(named: "Background 1") + cell.textLabel?.textColor = .white + cell.detailTextLabel?.textColor = Colors.Foreground.primary + + let settingSwitch = UISwitch(frame: .zero) + settingSwitch.onTintColor = .white + settingSwitch.thumbTintColor = UIColor(named: "Background Base") + settingSwitch.addTarget(cell, action: #selector(CalloutSettingsCellView.onSettingValueChanged(_:)), for: .valueChanged) + cell.accessoryView = settingSwitch + + switch rowType { + case .soundEffects: + cell.textLabel?.text = GDLocalizedString("callouts.sound_effects") + cell.detailTextLabel?.text = GDLocalizedString("callouts.sound_effects.info") + case .delays: + cell.textLabel?.text = GDLocalizedString("callouts.delays") + cell.detailTextLabel?.text = GDLocalizedString("callouts.delays.info") + default: + break + } + + return cell + } + + // MARK: Headers & Footers + + override func tableView(_ tableView: UITableView, + titleForHeaderInSection section: Int) -> String? { +>>>>>>> Stashed changes guard let sectionType = Section(rawValue: section) else { return nil } switch sectionType {