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 {