From 83f0041af902e78bf0f80555bd64f242c157cef1 Mon Sep 17 00:00:00 2001 From: "Daniel J." Date: Thu, 19 Sep 2024 15:02:05 +0200 Subject: [PATCH] Add audible speed advisories (#628) * change min speed to 7 km/h * put instruction under sg * line length * some improvements * adapt routing distances * Create separate audio service * Finish refactoring (reroute and button) * Add some more comments * Properly reset audio instructions service * Add setting for using audio routing instructions seperately * Start fixing the wait for green audio information * Fix play audio when waiting for green * First speed advisory instructions only implementation * Fix audio instruction min distance * Place audio speed advisory option in main settings * Fix calc second next phase * Enhance audio speed advisory instruction generation and start adding test cases to validate this functionality * Add test data for audio text generation * Add new text generation algo with test cases * Round distance to sg up to 25m accuracy * Add audio service to enable hearing music and speed advisory instructions * Fix speech rate * Add prediction not valid anymore and adjust speechrate values * Refactor code * Fix countdown exact second * Fix unnecessary creation of instructions * Add audio speech rate to main settings * Add speed advisory to tracking data * Add speed advisory green in 5 to track * Add check for division with 0 * Fix green in 5 set active * Refactor code, removing routing instruction generation * Refactor speed advisory instruction trigger range * Refactor comments * Minor fix audio init and settings exception * Refactor function description * Refactor function description * Fix last speed values and add drescription for default value * Use average instead of median * Remove unused routing instruction code * move speech rate to normal settings * Add toast and hint that audio is deactivated when the user selects a SG manually * Fix prediction invalid when user selects sg --------- Co-authored-by: P4u14 Co-authored-by: PaulPickhardt --- ios/Podfile.lock | 6 + lib/ride/models/audio.dart | 15 + lib/ride/models/recommendation.dart | 3 + lib/ride/services/audio.dart | 661 +++++++ lib/ride/services/ride.dart | 297 --- lib/ride/views/audio_button.dart | 88 + lib/ride/views/main.dart | 22 +- lib/routing/models/instruction.dart | 53 +- lib/routing/models/route.dart | 7 - lib/routing/services/routing.dart | 344 ---- lib/settings/services/settings.dart | 60 +- lib/settings/views/internal.dart | 16 - lib/settings/views/main.dart | 50 +- lib/tracking/models/battery_history.dart | 36 + .../models/speed_advisory_instruction.dart | 35 + lib/tracking/models/track.dart | 53 +- lib/tracking/services/tracking.dart | 8 + pubspec.lock | 16 + pubspec.yaml | 3 +- .../audio_speed_advisory_text_generation.dart | 1676 +++++++++++++++++ ..._advisory_text_generation_bad_quality.dart | 67 + 21 files changed, 2734 insertions(+), 782 deletions(-) create mode 100644 lib/ride/models/audio.dart create mode 100644 lib/ride/services/audio.dart create mode 100644 lib/ride/views/audio_button.dart create mode 100644 lib/tracking/models/battery_history.dart create mode 100644 lib/tracking/models/speed_advisory_instruction.dart create mode 100644 test/audio_speed_advisory_text_generation.dart create mode 100644 test/audio_speed_advisory_text_generation_bad_quality.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a0b4195a8..6b3242c4c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - audio_session (0.0.1): + - Flutter - battery_plus (1.0.0): - Flutter - connectivity_plus (0.0.1): @@ -122,6 +124,7 @@ PODS: - Flutter DEPENDENCIES: + - audio_session (from `.symlinks/plugins/audio_session/ios`) - battery_plus (from `.symlinks/plugins/battery_plus/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) @@ -163,6 +166,8 @@ SPEC REPOS: - Turf EXTERNAL SOURCES: + audio_session: + :path: ".symlinks/plugins/audio_session/ios" battery_plus: :path: ".symlinks/plugins/battery_plus/ios" connectivity_plus: @@ -209,6 +214,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + audio_session: 088d2483ebd1dc43f51d253d4a1c517d9a2e7207 battery_plus: 1ff2e16ba75af2a78387f65476057a390b47885e connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d diff --git a/lib/ride/models/audio.dart b/lib/ride/models/audio.dart new file mode 100644 index 000000000..0841c43dd --- /dev/null +++ b/lib/ride/models/audio.dart @@ -0,0 +1,15 @@ +enum SpeechRate { + fast, + normal, +} + +extension SpeechRateDescription on SpeechRate { + String get description { + switch (this) { + case SpeechRate.fast: + return "Schnell"; + case SpeechRate.normal: + return "Normal"; + } + } +} diff --git a/lib/ride/models/recommendation.dart b/lib/ride/models/recommendation.dart index 143e098c5..88924d4a4 100644 --- a/lib/ride/models/recommendation.dart +++ b/lib/ride/models/recommendation.dart @@ -14,6 +14,9 @@ class Recommendation { /// The predicted current signal phase, calculated periodically. final Phase calcCurrentSignalPhase; + /// The timestamp of the last recommendation. + final DateTime timestamp = DateTime.now(); + Recommendation( this.calcPhasesFromNow, this.calcQualitiesFromNow, this.calcCurrentPhaseChangeTime, this.calcCurrentSignalPhase); } diff --git a/lib/ride/services/audio.dart b/lib/ride/services/audio.dart new file mode 100644 index 000000000..328d0d64f --- /dev/null +++ b/lib/ride/services/audio.dart @@ -0,0 +1,661 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:audio_session/audio_session.dart'; +import 'package:flutter_tts/flutter_tts.dart'; +import 'package:priobike/main.dart'; +import 'package:priobike/positioning/services/positioning.dart'; +import 'package:priobike/ride/interfaces/prediction.dart'; +import 'package:priobike/ride/messages/prediction.dart'; +import 'package:priobike/ride/models/audio.dart'; +import 'package:priobike/ride/models/recommendation.dart'; +import 'package:priobike/ride/services/ride.dart'; +import 'package:priobike/routing/models/instruction.dart'; +import 'package:priobike/routing/models/route.dart'; +import 'package:priobike/settings/models/speed.dart'; +import 'package:priobike/settings/services/settings.dart'; +import 'package:priobike/tracking/models/speed_advisory_instruction.dart'; +import 'package:priobike/tracking/services/tracking.dart'; + +/// The class that represents a trigger range for speed advisory instructions. +class _SpeedAdvisoryInstructionTriggerRange { + /// The minimum distance to the next traffic light when a speed advisory can be triggered. + int minDistance; + + /// The maximum distance to the next traffic light until a speed advisory can be triggered. + int maxDistance; + + _SpeedAdvisoryInstructionTriggerRange(this.minDistance, this.maxDistance); +} + +/// The distances in front of a traffic light when a speed advisory instruction should be played (triggered). +List<_SpeedAdvisoryInstructionTriggerRange> _speedAdvisoryInstructionTriggerDistances = [ + _SpeedAdvisoryInstructionTriggerRange(300, 200), + _SpeedAdvisoryInstructionTriggerRange(100, 50) +]; + +/// The threshold of the prediction quality that needs to be covered when giving speed advisory instructions. +const double predictionQualityThreshold = 0.85; + +class Audio { + /// An instance for text-to-speach. + FlutterTts? ftts; + + /// The tracking service instance. + Tracking? tracking; + + /// The ride service instance. + Ride? ride; + + /// The settings service instance. + Settings? settings; + + /// The positioning service instance. + Positioning? positioning; + + /// Whether the audio service is initialized. + bool initialized = false; + + /// A map that holds information about the last recommendation to check the difference when a new recommendation is received. + Map lastRecommendation = {}; + + /// The current route. + Route? currentRoute; + + /// The last signal group id for which a wait for green info was played. + String? didStartWaitForGreenInfoTimerForSg; + + /// The wait for green timer that is used to time the wait for green instruction. + Timer? waitForGreenTimer; + + /// The current state of the speed advisory instruction. + int currentSpeedAdvisoryInstructionState = 0; + + /// The last signal group id. + int lastSignalGroupId = -1; + + /// The last 11 values of the user speed. + List lastSpeedValues = []; + + /// An instance of the audio session. + AudioSession? audioSession; + + /// The last prediction from the prediction service. + Prediction? lastPrediction; + + /// Constructor. + Audio() { + settings = getIt(); + settings!.addListener(_processSettingsUpdates); + + if (settings!.audioSpeedAdvisoryInstructionsEnabled) { + initialized = true; + _init(); + } + } + + /// Initializes the audio service. + Future _init() async { + ride ??= getIt(); + ride!.addListener(_processRideUpdates); + positioning ??= getIt(); + positioning!.addListener(_processPositioningUpdates); + _initializeTTS(); + } + + /// Initializes the text-to-speech instance. + Future _initializeTTS() async { + ftts = FlutterTts(); + + await _initAudioService(); + + if (Platform.isIOS) { + // Use siri voice if available. + List voices = await ftts!.getVoices; + if (voices.any((element) => element["name"] == "Helena" && element["locale"] == "de-DE")) { + await ftts!.setVoice({ + "name": "Helena", + "locale": "de-DE", + }); + } + + await ftts!.setSpeechRate(settings!.speechRate == SpeechRate.fast ? 0.54 : 0.5); //speed of speech + await ftts!.setVolume(1.0); //volume of speech + await ftts!.setPitch(1.1); //pitch of sound + await ftts!.autoStopSharedSession(false); + await ftts!.awaitSpeakCompletion(true); + + await ftts!.setIosAudioCategory(IosTextToSpeechAudioCategory.playback, + [IosTextToSpeechAudioCategoryOptions.allowBluetooth, IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP]); + } else { + // Use android voice if available. + List engines = await ftts!.getEngines; + if (engines.any((element) => element == "com.google.android.tts")) { + await ftts!.setEngine("com.google.android.tts"); + } + + List voices = await ftts!.getVoices; + + if (voices.any((element) => element["name"] == "de-DE-language" && element["locale"] == "de-DE")) { + await ftts!.setVoice({ + "name": "de-DE-language", + "locale": "de-DE", + }); + } + + await ftts!.setQueueMode(0); + await ftts!.awaitSpeakCompletion(true); + await ftts!.setSpeechRate(settings!.speechRate == SpeechRate.fast ? 0.7 : 0.6); //speed of speech + await ftts!.setVolume(1.0); //volume of speech + await ftts!.setPitch(1.1); //pitch of sound + } + + // Trigger the speak function with an empty text to prevent wait time when the first instruction should be played. + // In the current implementation of the tts package there is always an error that causes a delay when first using the speak method. + // The delay at this point doesn't effect the user. + ftts!.speak(" "); + } + + /// Initializes the audio service (package). + Future _initAudioService() async { + audioSession = await AudioSession.instance; + await audioSession!.configure(const AudioSessionConfiguration( + avAudioSessionCategory: AVAudioSessionCategory.playback, + avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.duckOthers, + avAudioSessionMode: AVAudioSessionMode.defaultMode, + avAudioSessionRouteSharingPolicy: AVAudioSessionRouteSharingPolicy.defaultPolicy, + avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none, + androidAudioAttributes: AndroidAudioAttributes( + contentType: AndroidAudioContentType.speech, + flags: AndroidAudioFlags.none, + usage: AndroidAudioUsage.media, + ), + androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransientMayDuck, + androidWillPauseWhenDucked: true, + )); + } + + /// Resets the text-to-speech instance. + Future _resetFTTS() async { + await ftts?.pause(); + await ftts?.stop(); + await ftts?.clearVoice(); + ftts = null; + } + + /// Cleans up the audio service when deactivated. + /// Can be called internally when the audio settings changes. + Future _cleanUp() async { + ride = null; + positioning?.removeListener(_processPositioningUpdates); + positioning = null; + await _resetFTTS(); + // Deactivate the audio session to allow other audio to play. + audioSession?.setActive(false); + audioSession = null; + lastRecommendation.clear(); + waitForGreenTimer?.cancel(); + waitForGreenTimer = null; + didStartWaitForGreenInfoTimerForSg = null; + } + + /// Resets the complete audio service. + /// Should be called when the audio service instance can be discarded. + Future reset() async { + settings?.removeListener(_processSettingsUpdates); + settings = null; + await _cleanUp(); + } + + /// Process updates by the ride service to detect rerouting and prediction invalidation. + Future _processRideUpdates() async { + if (ride?.navigationIsActive != true) return; + if (ride?.route == null) return; + // If the current route is null, we won't see a rerouting but the first route. + if (currentRoute == null) { + currentRoute = ride!.route; + return; + } + // Notify user if a rerouting was triggered. + if (currentRoute != ride!.route && ride?.route != null) { + currentRoute = ride?.route; + if (ftts == null) await _initializeTTS(); + ftts?.speak("Neue Route berechnet"); + currentSpeedAdvisoryInstructionState = _getNextSpeedAdvisoryInstructionState(); + } + + if (ride!.userSelectedSG != null) { + lastPrediction = null; + return; + } + if (ride!.calcCurrentSG == null) return; + + // Check if the prediction is not valid anymore. + if (lastSignalGroupId == ride!.calcCurrentSGIndex?.toInt()) { + // Check if the current prediction is still valid. + // If the prediction quality is not good enough and the last prediction was good enough, inform the user. + if (lastPrediction?.predictionQuality != null && + lastPrediction!.predictionQuality! > predictionQualityThreshold && + (ride!.predictionProvider?.prediction?.predictionQuality == null || + ride!.predictionProvider!.prediction!.predictionQuality! < predictionQualityThreshold) && + currentSpeedAdvisoryInstructionState > 0) { + // Inform the user that the prediction is not valid any more. + _playPredictionNotValidAnymore(); + } + } + + lastPrediction = ride!.predictionProvider?.prediction; + } + + /// Check if the audio instructions setting has changed. + Future _processSettingsUpdates() async { + if (initialized && !settings!.audioSpeedAdvisoryInstructionsEnabled) { + initialized = false; + _cleanUp(); + } else if (!initialized && settings!.audioSpeedAdvisoryInstructionsEnabled) { + initialized = true; + _init(); + } + } + + /// Process positioning updates to play audio instructions. + Future _processPositioningUpdates() async { + if (settings?.audioSpeedAdvisoryInstructionsEnabled != true) { + return; + } + + if (ride?.navigationIsActive != true) { + return; + } + + if (ftts == null) { + await _initializeTTS(); + } + + _addSpeedValueToLastSpeedValues(); + + // The next two functions check if an instruction should be triggered. + await _playSpeedAdvisoryInstruction(); + + // Check if sg was passed. + if (lastSignalGroupId != ride!.calcCurrentSGIndex?.toInt()) { + // Reset the state if the signal group has changed. + lastSignalGroupId = ride!.calcCurrentSGIndex?.toInt() ?? -1; + lastPrediction = null; + currentSpeedAdvisoryInstructionState = _getNextSpeedAdvisoryInstructionState(); + didStartWaitForGreenInfoTimerForSg = null; + waitForGreenTimer?.cancel(); + waitForGreenTimer = null; + } + } + + /// Returns the next speed advisory instruction state regarding the distance to the sg. + int _getNextSpeedAdvisoryInstructionState() { + // If there is no information on the distance, we start with state end. + if (ride!.calcDistanceToNextSG == null) return 0; + + // If the distance is to close, we skip to the last state. + if (ride!.calcDistanceToNextSG! < + _speedAdvisoryInstructionTriggerDistances[_speedAdvisoryInstructionTriggerDistances.length - 1].maxDistance) { + return _speedAdvisoryInstructionTriggerDistances.length; + } + + // Search for the next state according to the distance. + for (int i = 0; i < _speedAdvisoryInstructionTriggerDistances.length; i++) { + if (ride!.calcDistanceToNextSG! > _speedAdvisoryInstructionTriggerDistances[i].maxDistance) { + return i; + } + } + + // Default state. + return 0; + } + + /// Add the current speed value to the last speed values list. + _addSpeedValueToLastSpeedValues() { + if (positioning!.lastPosition == null) { + return; + } + // Only store values greater the 1.5 m/s since the can be considered start or stop motions. + if (positioning!.lastPosition!.speed < 1.5) { + return; + } + + // Store the last 20 seconds of speed values. + if (lastSpeedValues.length > 20) { + lastSpeedValues.removeAt(0); + } + lastSpeedValues.add(positioning!.lastPosition?.speed ?? 0); + } + + /// Returns the median speed of the last speed values. + double getAverageOfLastSpeedValues() { + if (lastSpeedValues.isEmpty) { + // Default is 5m/s (18km/h) since this is considered average driving speed for cyclists. + return 5; + } + return lastSpeedValues.reduce((speedSum, speed) => speedSum + speed) / lastSpeedValues.length; + } + + /// Returns a list with the change times of the recommendation. + List _calculateChangesTimesOfRecommendation(Recommendation recommendation) { + List changeTimes = []; + Phase lastPhase = recommendation.calcCurrentSignalPhase; + for (int i = 0; i < recommendation.calcPhasesFromNow.length; i++) { + // Only consider red and green. + if (!(recommendation.calcPhasesFromNow[i] == Phase.red || recommendation.calcPhasesFromNow[i] == Phase.green)) { + continue; + } + + if (recommendation.calcPhasesFromNow[i] != lastPhase) { + changeTimes.add(i); + lastPhase = recommendation.calcPhasesFromNow[i]; + } + } + return changeTimes; + } + + /// Returns the closest change time in reach. + int _getClosestChangeTimeInReach( + int arrivalTime, List changeTimes, SpeedMode speedMode, double distanceToNextSg) { + double maxSpeed = (speedMode.maxSpeed - 3) / 3.6; // Convert to m/s. + + return changeTimes + .reduce((a, b) => (a - arrivalTime).abs() < (b - arrivalTime).abs() && distanceToNextSg / a < maxSpeed ? a : b); + } + + /// Check if instruction contains sg information and if so add countdown. + /// Speed in m/s. + InstructionText? generateTextToPlay(InstructionText instructionText) { + ride ??= getIt(); + + // Check if prediction quality is not good enough. + if (ride!.calcCurrentSG == null || + ride!.predictionProvider?.recommendation == null || + (ride!.predictionProvider?.prediction?.predictionQuality ?? 0) < predictionQualityThreshold) { + // No sg countdown information can be added and thus instruction part must not be played. + return null; + } + + final recommendation = ride!.predictionProvider!.recommendation!; + + List changeTimes = _calculateChangesTimesOfRecommendation(recommendation); + + if (changeTimes.isEmpty) return null; + + double lastAverageSpeed = getAverageOfLastSpeedValues(); + + // Calculate the arrival time at the sg. + int arrivalTime = (instructionText.distanceToNextSg / lastAverageSpeed).round(); + + int countdownOffset = (DateTime.now().difference(recommendation.timestamp).inMilliseconds / 1000).round(); + + // Check if the arrival is at red or to far away. + if (recommendation.calcPhasesFromNow.length <= arrivalTime || + recommendation.calcPhasesFromNow[arrivalTime] == Phase.red) { + // If the arrival is at red, we use the max of 5m/s and median speed for the arrival time. + arrivalTime = (instructionText.distanceToNextSg / max(5, lastAverageSpeed)).round(); + } + + // Get the closest change time to the arrival time that is in reach. + int closestChangeTimeInReach = _getClosestChangeTimeInReach( + arrivalTime, changeTimes, settings?.speedMode ?? SpeedMode.max30kmh, instructionText.distanceToNextSg); + + // Check if closest change time switches to red or green. + if (recommendation.calcPhasesFromNow[closestChangeTimeInReach] == Phase.red) { + // If the closest change time switches to red, add countdown to red. + // Subtract 2 for starting at second 0 and 1 for the second before change. + int countdown = closestChangeTimeInReach - countdownOffset - 2; + instructionText.addCountdown(countdown); + instructionText.text = "${instructionText.text} rot in"; + return instructionText; + } else if (recommendation.calcPhasesFromNow[closestChangeTimeInReach] == Phase.green) { + // If the closest change time switches to green, add countdown to green. + // Subtract 2 for starting at second 0 and 1 for the second before change. + int countdown = closestChangeTimeInReach - countdownOffset - 2; + instructionText.addCountdown(countdown); + instructionText.text = "${instructionText.text} grün in"; + return instructionText; + } + + // No recommendation can be made. + return null; + } + + /// Checks if the user is at slow speed or standing still close to a traffic light and plays a countdown for the next traffic light when waiting for green. + void _checkPlayCountdownWhenWaitingForGreen() { + ride ??= getIt(); + positioning ??= getIt(); + + if (didStartWaitForGreenInfoTimerForSg != null && didStartWaitForGreenInfoTimerForSg != ride!.calcCurrentSG?.id) { + // Do not play instruction if the sg is the same as the last played sg. + waitForGreenTimer?.cancel(); + waitForGreenTimer = null; + didStartWaitForGreenInfoTimerForSg = null; + return; + } + + final speed = getIt().lastPosition?.speed ?? 0; + if (speed * 3.6 > 7) { + // All speed over 7 km/h is considered normal driving. + waitForGreenTimer?.cancel(); + waitForGreenTimer = null; + didStartWaitForGreenInfoTimerForSg = null; + return; + } + + // If the timer is already running, do not start a new one. + if (waitForGreenTimer != null) { + return; + } + + if (!_canCreateInstructionForRecommendation()) return; + + final recommendation = ride!.predictionProvider!.recommendation!; + + // If the current phase is green, we do not start a timer. + if (recommendation.calcPhasesFromNow[0] == Phase.green) return; + + // Get the countdown. + int countdown = recommendation.calcCurrentPhaseChangeTime!.difference(DateTime.now()).inSeconds; + + // Do not play instruction if countdown < 6. + if (countdown < 6) return; + + final snap = getIt().snap; + if (snap == null) return; + var distOnRoute = snap.distanceOnRoute; + var idx = currentRoute!.signalGroups.indexWhere((element) => element.id == ride!.calcCurrentSG!.id); + var distSgOnRoute = 0.0; + if (idx != -1) { + distSgOnRoute = currentRoute!.signalGroupsDistancesOnRoute[idx]; + } else { + // Do not play instruction if the sg is not on the route. + return; + } + + var distanceToSg = distSgOnRoute - distOnRoute; + if (distanceToSg > 25) { + // Do not play instruction if the distance to the sg is more than 25m. + return; + } + + didStartWaitForGreenInfoTimerForSg = ride!.calcCurrentSG!.id; + + // Start a timer that executes the audio instruction 5 seconds before the traffic light turns green. + // Subtracting 5 seconds for the countdown and 1 second for the speaking delay. + waitForGreenTimer = Timer.periodic(Duration(seconds: countdown - 6), (timer) async { + // If wait for green instruction can't be played close the timer. + if (ftts == null || audioSession == null || ride!.userSelectedSG != null) { + didStartWaitForGreenInfoTimerForSg = null; + timer.cancel(); + waitForGreenTimer = null; + return; + } + + await audioSession!.setActive(true); + await Future.delayed(const Duration(milliseconds: 500)); + + await ftts!.speak("Grün in"); + await ftts!.speak("5"); + + // Add some buffer because the end of speak can not be detected. + await Future.delayed(const Duration(milliseconds: 500)); + + // Needs to be checked because function is async. + if (audioSession == null) return; + // Deactivate the audio session to allow other audio to play. + await audioSession!.setActive(false); + + // Timer should be canceled after instruction was played. + didStartWaitForGreenInfoTimerForSg = null; + timer.cancel(); + waitForGreenTimer = null; + + // Add the speed advisory instruction to the current track. + if (tracking != null && positioning?.snap != null) { + tracking!.addSpeedAdvisoryInstruction( + SpeedAdvisoryInstruction( + text: "Grün in", + countdown: 5, + lat: positioning!.snap!.position.latitude, + lon: positioning!.snap!.position.longitude), + ); + } + }); + } + + bool _canCreateInstructionForRecommendation() { + ride ??= getIt(); + + // Check if Not supported crossing + // or we do not have all auxiliary data that the app calculated + // or prediction quality is not good enough. + if (ride!.calcCurrentSG == null || + ride!.predictionProvider?.recommendation == null || + (ride!.predictionProvider?.prediction?.predictionQuality ?? 0) < predictionQualityThreshold) { + // No sg countdown information can be added and thus instruction part must not be played. + return false; + } + + // Check if the prediction is a recommendation for the next traffic light on the route + // and do not play instruction if this is not the case. + final thingName = ride!.predictionProvider?.status?.thingName; + bool isRecommendation = thingName != null ? ride!.calcCurrentSG!.id == thingName : false; + if (!isRecommendation) return false; + + // If the phase change time is null, instruction part must not be played. + final recommendation = ride!.predictionProvider!.recommendation!; + if (recommendation.calcCurrentPhaseChangeTime == null) return false; + + // If there is only one color, instruction part must not be played. + final uniqueColors = recommendation.calcPhasesFromNow.map((e) => e.color).toSet(); + if (uniqueColors.length == 1) return false; + + return true; + } + + /// Play speed advisory instruction only. + Future _playSpeedAdvisoryInstruction() async { + ride ??= getIt(); + tracking ??= getIt(); + positioning ??= getIt(); + if (positioning!.snap == null || ride!.route == null) return; + + if (ftts == null) return; + + // Do not play audio instructions when user selects sg manually. + if (ride!.userSelectedSG != null) return; + + _checkPlayCountdownWhenWaitingForGreen(); + + // If the state is higher than the length of the speed advisory distances, do not play any more instructions. + if (currentSpeedAdvisoryInstructionState > _speedAdvisoryInstructionTriggerDistances.length - 1) { + return; + } + + if (!_canCreateInstructionForRecommendation()) return; + + // Check if the activation distance of the current state is reached. + if (ride?.calcDistanceToNextSG == null || + ride!.calcDistanceToNextSG! >= + _speedAdvisoryInstructionTriggerDistances[currentSpeedAdvisoryInstructionState].minDistance) { + return; + } + + // Create the audio advisory instruction. + String sgType = (ride!.calcCurrentSG!.laneType == "Radfahrer") ? "Radampel" : "Ampel"; + int roundedDistance = (ride!.calcDistanceToNextSG! / 25).ceil() * 25; + InstructionText instructionText = InstructionText( + text: "In $roundedDistance meter $sgType", + distanceToNextSg: ride!.calcDistanceToNextSG!, + ); + + var textToPlay = generateTextToPlay(instructionText); + + if (textToPlay == null) return; + + currentSpeedAdvisoryInstructionState++; + + // Activate the audio session to duck others in case of music or other audio playing. + // Needs to be checked because function is async. + if (audioSession == null) return; + await audioSession!.setActive(true); + await Future.delayed(const Duration(milliseconds: 500)); + + // Needs to be checked because function is async. + if (ftts == null) return; + await ftts!.speak(textToPlay.text); + + // Calc updatedCountdown since initial creation and time that has passed while speaking + // (to avoid countdown inaccuracy) + int updatedCountdown = textToPlay.countdown! - + ((DateTime.now().difference(textToPlay.countdownTimeStamp).inMilliseconds) / 1000).round(); + + // Needs to be checked because function is async. + if (ftts == null) return; + + // Add the speed advisory instruction to the current track. + if (tracking != null) { + tracking!.addSpeedAdvisoryInstruction( + SpeedAdvisoryInstruction( + text: instructionText.text, + countdown: updatedCountdown, + lat: positioning!.snap!.position.latitude, + lon: positioning!.snap!.position.longitude), + ); + } + + await ftts!.speak(updatedCountdown.toString()); + + // Add some buffer because the end of speak can not be detected. + await Future.delayed(const Duration(milliseconds: 500)); + + // Needs to be checked because function is async. + if (audioSession == null) return; + // Deactivate the audio session to allow other audio to play. + await audioSession!.setActive(false); + } + + Future _playPredictionNotValidAnymore() async { + if (ftts == null) return; + if (audioSession == null) return; + + audioSession!.setActive(true); + await Future.delayed(const Duration(milliseconds: 500)); + ftts!.speak("Achtung, aktuelle Prognose nicht mehr gültig"); + + if (positioning?.snap != null) { + tracking!.addSpeedAdvisoryInstruction( + SpeedAdvisoryInstruction( + text: "Achtung, aktuelle Prognose nicht mehr gültig", + countdown: 0, + lat: positioning!.snap!.position.latitude, + lon: positioning!.snap!.position.longitude), + ); + } + + await audioSession!.setActive(false); + } +} diff --git a/lib/ride/services/ride.dart b/lib/ride/services/ride.dart index 4da5b60a4..93a3a9400 100644 --- a/lib/ride/services/ride.dart +++ b/lib/ride/services/ride.dart @@ -1,18 +1,12 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; -import 'dart:math'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart' hide Route, Shortcuts; -import 'package:flutter_tts/flutter_tts.dart'; import 'package:latlong2/latlong.dart'; import 'package:priobike/logging/logger.dart'; import 'package:priobike/main.dart'; import 'package:priobike/positioning/services/positioning.dart'; -import 'package:priobike/ride/messages/prediction.dart'; import 'package:priobike/ride/services/prediction.dart'; -import 'package:priobike/routing/models/instruction.dart'; import 'package:priobike/routing/models/route.dart'; import 'package:priobike/routing/models/sg.dart'; import 'package:priobike/routing/models/waypoint.dart'; @@ -70,12 +64,6 @@ class Ride with ChangeNotifier { /// Selected Route id if the last ride got killed by the os. int lastRouteID = 0; - /// An instance for text-to-speach. - FlutterTts? ftts; - - /// A map that holds information about the last recommendation to check the difference when a new recommendation is received. - Map lastRecommendation = {}; - static const lastRouteKey = "priobike.ride.lastRoute"; static const lastRouteIDKey = "priobike.ride.lastRouteID"; @@ -307,291 +295,6 @@ class Ride with ChangeNotifier { notifyListeners(); } - /// Check if instruction contains sg information and if so add countdown - InstructionText? _generateTextToPlay(InstructionText instructionText, double speed) { - // Check if Not supported crossing - // or we do not have all auxiliary data that the app calculated - // or prediction quality is not good enough. - if (calcCurrentSG == null || - predictionProvider?.recommendation == null || - (predictionProvider?.prediction?.predictionQuality ?? 0) < Ride.qualityThreshold) { - // No sg countdown information can be added and thus instruction part must not be played. - return null; - } - - final recommendation = predictionProvider!.recommendation!; - if (recommendation.calcCurrentPhaseChangeTime == null) { - // If the phase change time is null, instruction part must not be played. - return null; - } - - Phase? currentPhase = recommendation.calcCurrentSignalPhase; - // Calculate the countdown. - int countdown = recommendation.calcCurrentPhaseChangeTime!.difference(DateTime.now()).inSeconds; - if (countdown < 0) { - countdown = 0; // Must not be negative for later calculations. - } - - // Save the current recommendation information for comparison with updates later. - lastRecommendation.clear(); - lastRecommendation = {'phase': currentPhase, 'countdown': countdown, 'timestamp': DateTime.now()}; - - Phase? nextPhase; - int durationNextPhase = -1; - Phase? secondNextPhase; - - // The current phase ends at index countdown + 2. - if (recommendation.calcPhasesFromNow.length > countdown + 2) { - // Calculate the time and color of the next phase after the current phase. - durationNextPhase = _calcTimeToNextPhaseAfterIndex(countdown + 2) ?? -1; - nextPhase = recommendation.calcPhasesFromNow[countdown + 2]; - - if (recommendation.calcPhasesFromNow.length > countdown + durationNextPhase + 2) { - // Calculate the color of the second next phase after the current phase. - secondNextPhase = recommendation.calcPhasesFromNow[countdown + durationNextPhase + 2]; - } - } - - if (currentPhase == Phase.green && nextPhase == Phase.red) { - if (countdown >= instructionText.distanceToNextSg / max(25, speed) && countdown > 3) { - // The traffic light is green and can be crossed with the max of current speed or 25km/h. - // before turning red. - instructionText.addCountdown(countdown); - instructionText.text = "${instructionText.text} rot in"; - return instructionText; - } else if ((secondNextPhase == Phase.green && - instructionText.distanceToNextSg * 3.6 / (countdown + durationNextPhase) >= 8 && - countdown + durationNextPhase > 3)) { - // The traffic light will turn red and then green again - // and can be crossed with a minimum speed of 8km/h without stopping. - instructionText.addCountdown(countdown + durationNextPhase); - instructionText.text = "${instructionText.text} grün in"; - return instructionText; - } else if (countdown > 3) { - // Let the user know when the traffic light is going to turn red. - instructionText.addCountdown(countdown); - instructionText.text = "${instructionText.text} rot in"; - return instructionText; - } else if (countdown + durationNextPhase > 3) { - // Let the user know when the traffic light is going to turn green again. - instructionText.addCountdown(countdown + durationNextPhase); - instructionText.text = "${instructionText.text} grün in"; - return instructionText; - } - } else if (nextPhase == Phase.green) { - if (countdown + durationNextPhase >= instructionText.distanceToNextSg / speed && countdown > 3) { - // The traffic light will turn green and can be crossed with the current speed. - instructionText.addCountdown(countdown); - instructionText.text = "${instructionText.text} grün in"; - return instructionText; - } else if (secondNextPhase == Phase.red && countdown + durationNextPhase > 3) { - // Let the user know when the traffic light is going to turn red. - instructionText.addCountdown(countdown + durationNextPhase); - instructionText.text = "${instructionText.text} rot in"; - return instructionText; - } - } - - // No recommendation can be made. - return null; - } - - /// Calculates the time to the next phase after the given index. - int? _calcTimeToNextPhaseAfterIndex(int index) { - final recommendation = predictionProvider!.recommendation!; - - final phases = recommendation.calcPhasesFromNow.sublist(index, recommendation.calcPhasesFromNow.length - 1); - final nextPhaseColor = phases.first; - final indexNextPhaseEnd = phases.indexWhere((element) => element != nextPhaseColor); - - return indexNextPhaseEnd; - } - - /// Configure the TTS. - Future initializeTTS() async { - ftts = FlutterTts(); - - if (Platform.isIOS) { - // Use siri voice if available. - List voices = await ftts!.getVoices; - if (voices.any((element) => element["name"] == "Helena" && element["locale"] == "de-DE")) { - await ftts!.setVoice({ - "name": "Helena", - "locale": "de-DE", - }); - } - - await ftts!.setSpeechRate(0.55); //speed of speech - await ftts!.setVolume(1); //volume of speech - await ftts!.setPitch(1); //pitch of sound - await ftts!.awaitSpeakCompletion(true); - await ftts!.autoStopSharedSession(false); - - await ftts!.setIosAudioCategory(IosTextToSpeechAudioCategory.playback, [ - IosTextToSpeechAudioCategoryOptions.duckOthers, - IosTextToSpeechAudioCategoryOptions.allowBluetooth, - IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP - ]); - } else { - // Use android voice if available. - List voices = await ftts!.getVoices; - if (voices.any((element) => element["name"] == "de-DE-language" && element["locale"] == "de-DE")) { - await ftts!.setVoice({ - "name": "de-DE-language", - "locale": "de-DE", - }); - } - - await ftts!.setSpeechRate(0.7); //speed of speech - await ftts!.setVolume(1); //volume of speech - await ftts!.setPitch(1); //pitch of sound - await ftts!.awaitSpeakCompletion(true); - } - } - - /// Play audio instruction. - Future playAudioInstruction() async { - final snap = getIt().snap; - if (snap == null || route == null) return; - if (ftts == null) return; - - Instruction? currentInstruction = route!.instructions.firstWhereOrNull( - (element) => !element.executed && vincenty.distance(LatLng(element.lat, element.lon), snap.position) < 20); - - if (currentInstruction != null) { - currentInstruction.executed = true; - - Iterator it = currentInstruction.text.iterator; - while (it.moveNext()) { - // Put this here to avoid music interruption in case that there is no instruction to play. - if (it.current.type == InstructionTextType.direction) { - // No countdown information needs to be added. - await ftts!.speak(it.current.text); - } else { - final speed = getIt().lastPosition?.speed ?? 0; - // Check for countdown information. - var instructionTextToPlay = _generateTextToPlay(it.current, speed); - if (instructionTextToPlay == null) { - continue; - } - await ftts!.speak(instructionTextToPlay.text); - // Calc updatedCountdown since initial creation and time that has passed while speaking - // (to avoid countdown inaccuracy) - // Also take into account 1s delay for actually speaking the countdown. - int updatedCountdown = instructionTextToPlay.countdown! - - (DateTime.now().difference(instructionTextToPlay.countdownTimeStamp!).inSeconds) - - 1; - await ftts!.speak(updatedCountdown.toString()); - } - } - } - } - - void playNewPredictionStatusInformation() async { - if (ftts == null) return; - // Check if Not supported crossing - // or we do not have all auxiliary data that the app calculated - // or prediction quality is not good enough. - if (calcCurrentSG == null || - predictionProvider?.recommendation == null || - (predictionProvider?.prediction?.predictionQuality ?? 0) < Ride.qualityThreshold) { - // No sg countdown information can be added and thus instruction part must not be played. - return; - } - - // Check if the prediction is a recommendation for the next traffic light on the route - // and do not play instruction if this is not the case. - final thingName = predictionProvider?.status?.thingName; - bool isRecommendation = thingName != null ? calcCurrentSG!.id == thingName : false; - if (!isRecommendation) return; - - // If the phase change time is null, instruction part must not be played. - final recommendation = predictionProvider!.recommendation!; - if (recommendation.calcCurrentPhaseChangeTime == null) return; - - // If there is only one color, instruction part must not be played. - final uniqueColors = recommendation.calcPhasesFromNow.map((e) => e.color).toSet(); - if (uniqueColors.length == 1) return; - - // Do not play instruction part for amber or redamber. - if (recommendation.calcCurrentSignalPhase == Phase.amber) return; - if (recommendation.calcCurrentSignalPhase == Phase.redAmber) return; - - Phase? currentPhase = recommendation.calcCurrentSignalPhase; - // Calculate the countdown. - int countdown = recommendation.calcCurrentPhaseChangeTime!.difference(DateTime.now()).inSeconds; - // Do not play instruction if countdown < 5. - if (countdown < 5) return; - Phase? nextPhase; - - // The current phase ends at index countdown + 2. - if (recommendation.calcPhasesFromNow.length > countdown + 2) { - // Calculate the color of the next phase after the current phase. - nextPhase = recommendation.calcPhasesFromNow[countdown + 2]; - } - - // Check if the recommendation phase has changed. - bool hasPhaseChanged = - lastRecommendation['phase'] == null ? true : lastRecommendation['phase'] as Phase != currentPhase; - // Check if the countdown has changed more than 3 seconds. - bool hasSignificantTimeChange; - int? lastCountdown = lastRecommendation['countdown'] as int?; - if (lastCountdown != null) { - int lastTimeDifference = DateTime.now().difference(lastRecommendation['timestamp'] as DateTime).inSeconds; - hasSignificantTimeChange = ((lastCountdown - lastTimeDifference) - countdown).abs() > 3; - } else { - hasSignificantTimeChange = true; - } - - bool closeToInstruction; - final snap = getIt().snap; - if (snap == null) { - closeToInstruction = false; - } else { - var distanceToSg = - vincenty.distance(snap.position, LatLng(calcCurrentSG!.position.lat, calcCurrentSG!.position.lon)); - if (distanceToSg > 500) { - // Do not play instruction if the distance to the sg is more than 500m. - return; - } - - // Check if the current position is in a radius of 50m of an instruction that contains sg information. - var nextInstruction = route!.instructions.firstWhereOrNull((element) => - (element.instructionType != InstructionType.directionOnly) && - vincenty.distance(LatLng(element.lat, element.lon), snap.position) < 50); - closeToInstruction = nextInstruction != null; - } - - if (!closeToInstruction && (hasPhaseChanged || hasSignificantTimeChange)) { - var instructionTimeStamp = DateTime.now(); - - // Save the current recommendation information for comparison with updates later BEFORE playing the instruction. - lastRecommendation.clear(); - lastRecommendation = {'phase': currentPhase, 'countdown': countdown, 'timestamp': instructionTimeStamp}; - - // Cannot make a recommendation if the next phase is not known. - if (nextPhase == null) return; - - String sgType = (calcCurrentSG!.laneType == "Radfahrer") ? "Radampel" : "Ampel"; - InstructionText instructionText = - InstructionText(text: "Nächste $sgType", type: InstructionTextType.signalGroup, distanceToNextSg: 0); - final speed = getIt().lastPosition?.speed ?? 0; - var textToPlay = _generateTextToPlay(instructionText, speed); - if (textToPlay == null) return; - await ftts!.speak(textToPlay.text); - // Calc updatedCountdown since initial creation and time that has passed while speaking - // (to avoid countdown inaccuracy) - // Also take into account 1s delay for actually speaking the countdown. - int updatedCountdown = - textToPlay.countdown! - (DateTime.now().difference(textToPlay.countdownTimeStamp!).inSeconds) - 1; - await ftts!.speak(updatedCountdown.toString()); - } else { - // Nevertheless save the current recommendation information for comparison with updates later. - lastRecommendation.clear(); - lastRecommendation = {'phase': currentPhase, 'countdown': countdown, 'timestamp': DateTime.timestamp()}; - } - } - /// Stop the navigation. Future stopNavigation() async { if (predictionProvider != null) predictionProvider!.stopNavigation(); diff --git a/lib/ride/views/audio_button.dart b/lib/ride/views/audio_button.dart new file mode 100644 index 000000000..b63492b0c --- /dev/null +++ b/lib/ride/views/audio_button.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:priobike/common/layout/tiles.dart'; +import 'package:priobike/logging/toast.dart'; +import 'package:priobike/main.dart'; +import 'package:priobike/ride/services/ride.dart'; +import 'package:priobike/settings/services/settings.dart'; + +class AudioButton extends StatefulWidget { + const AudioButton({super.key}); + + @override + State createState() => _AudioButtonState(); +} + +class _AudioButtonState extends State { + /// The associated settings service, which is injected by the provider. + late Settings settings; + + /// The associated ride service, which is incjected by the provider. + late Ride ride; + + /// Called when a listener callback of a ChangeNotifier is fired. + void update() => setState(() {}); + + @override + void initState() { + super.initState(); + + settings = getIt(); + settings.addListener(update); + + ride = getIt(); + ride.addListener(update); + } + + @override + void dispose() { + settings.removeListener(update); + ride.removeListener(update); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final orientation = MediaQuery.of(context).orientation; + final isLandscapeMode = orientation == Orientation.landscape; + + return Positioned( + top: 132, // Below the MapBox attribution. + // Button is on the right in portrait mode and on the left in landscape mode. + right: isLandscapeMode ? null : 12, + left: isLandscapeMode ? 8 : null, + child: SafeArea( + child: SizedBox( + width: 58, + height: 58, + child: Tile( + onPressed: () { + if (ride.userSelectedSG == null) { + settings.setAudioInstructionsEnabled(!settings.audioSpeedAdvisoryInstructionsEnabled); + } else { + getIt().showError("Zentrieren, um Audio zu aktivieren"); + } + }, + padding: const EdgeInsets.all(10), + fill: Theme.of(context).colorScheme.surfaceVariant, + content: settings.audioSpeedAdvisoryInstructionsEnabled + ? Icon( + Icons.volume_up, + size: 32, + color: ride.userSelectedSG != null + ? Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.2) + : Theme.of(context).colorScheme.onSurfaceVariant, + ) + : Icon( + Icons.volume_off, + size: 32, + color: ride.userSelectedSG != null + ? Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.2) + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ); + } +} diff --git a/lib/ride/views/main.dart b/lib/ride/views/main.dart index 8b490ec34..2b19078a4 100644 --- a/lib/ride/views/main.dart +++ b/lib/ride/views/main.dart @@ -12,8 +12,10 @@ import 'package:priobike/logging/toast.dart'; import 'package:priobike/main.dart'; import 'package:priobike/positioning/services/positioning.dart'; import 'package:priobike/positioning/views/location_access_denied_dialog.dart'; +import 'package:priobike/ride/services/audio.dart'; import 'package:priobike/ride/services/datastream.dart'; import 'package:priobike/ride/services/ride.dart'; +import 'package:priobike/ride/views/audio_button.dart'; import 'package:priobike/ride/views/datastream.dart'; import 'package:priobike/ride/views/finish_button.dart'; import 'package:priobike/ride/views/map.dart'; @@ -47,6 +49,9 @@ class RideViewState extends State { /// The associated ride service, which is injected by the provider. late Ride ride; + /// The associated audio service. + Audio? audio; + /// A lock that avoids rapid rerouting. final lock = Lock(milliseconds: 10000); @@ -71,6 +76,8 @@ class RideViewState extends State { void initState() { super.initState(); + audio = Audio(); + settings = getIt(); settings.addListener(update); ride = getIt(); @@ -99,11 +106,6 @@ class RideViewState extends State { await positioning.selectRoute(routing.selectedRoute); // Start a new session. - if (settings.audioInstructionsEnabled) { - // Configure the TTS. - await ride.initializeTTS(); - } - // Save current route if the app crashes or the user unintentionally closes it. ride.setLastRoute(routing.selectedWaypoints!, routing.selectedRoute!.idx); @@ -128,12 +130,6 @@ class RideViewState extends State { await ride.updatePosition(); await tracking.updatePosition(); - // Play audio instructions if enabled. - if (settings.audioInstructionsEnabled) { - ride.playAudioInstruction(); - ride.playNewPredictionStatusInformation(); - } - // If we are > m from the route, we need to reroute. if ((positioning.snap?.distanceToRoute ?? 0) > rerouteDistance || needsReroute) { // Use a timed lock to avoid rapid refreshing of routes. @@ -150,7 +146,6 @@ class RideViewState extends State { "Berechne neue Route", important: true, ); - ride.ftts?.speak("Berechne neue Route"); await routing.selectRemainingWaypoints(); final routes = await routing.loadRoutes(fetchOptionalData: false); @@ -207,6 +202,8 @@ class RideViewState extends State { @override void dispose() { settings.removeListener(update); + audio?.reset(); + audio = null; /// Reenable the bottom navigation bar on Android after hiding it. if (Platform.isAndroid) { @@ -284,6 +281,7 @@ class RideViewState extends State { ), if (settings.datastreamMode == DatastreamMode.enabled) const DatastreamView(), FinishRideButton(), + const AudioButton(), if (!cameraFollowsUserLocation) Positioned( top: MediaQuery.of(context).padding.top + 8, diff --git a/lib/routing/models/instruction.dart b/lib/routing/models/instruction.dart index 8f9288a0e..327f9d90c 100644 --- a/lib/routing/models/instruction.dart +++ b/lib/routing/models/instruction.dart @@ -1,70 +1,21 @@ -/// An enum for the type of the custom instruction -/// This type is derived from the InstructionTextType. -enum InstructionType { - directionOnly, - signalGroupOnly, - directionAndSignalGroup, -} - -/// An enum for the type of the instruction text. -enum InstructionTextType { - direction, - signalGroup, -} - class InstructionText { /// The instruction text. String text; - /// The type of the instruction text. - final InstructionTextType type; - /// The countdown of the instruction /// Only used for InstructionTextType signalGroup. int? countdown; /// The timestamp when the countdown was started. - DateTime? countdownTimeStamp; + DateTime countdownTimeStamp = DateTime.now(); /// The distance to the next signal group. double distanceToNextSg = 0; - InstructionText( - {required this.text, - required this.type, - this.countdown, - this.countdownTimeStamp, - required this.distanceToNextSg}); + InstructionText({required this.text, this.countdown, required this.distanceToNextSg}); /// Adds a countdown to the instructionText as well as the current timestamp. void addCountdown(int countdown) { this.countdown = countdown; - countdownTimeStamp = DateTime.now(); } } - -class Instruction { - /// The instruction latitude. - final double lat; - - /// The instruction longitude. - final double lon; - - /// The instruction text. - List text; - - /// If the instruction has already been executed. - bool executed = false; - - /// The instruction type. - InstructionType instructionType; - - /// The ID of the corresponding signal group. - String? signalGroupId; - - /// If the instruction has already been concatenated. - bool alreadyConcatenated = false; - - Instruction( - {required this.lat, required this.lon, required this.text, required this.instructionType, this.signalGroupId}); -} diff --git a/lib/routing/models/route.dart b/lib/routing/models/route.dart index 7224b82c4..e161ef5f8 100644 --- a/lib/routing/models/route.dart +++ b/lib/routing/models/route.dart @@ -4,7 +4,6 @@ import 'package:latlong2/latlong.dart'; import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; import 'package:priobike/routing/messages/graphhopper.dart'; import 'package:priobike/routing/models/crossing.dart'; -import 'package:priobike/routing/models/instruction.dart'; import 'package:priobike/routing/models/navigation.dart'; import 'package:priobike/routing/models/poi.dart'; import 'package:priobike/routing/models/sg.dart'; @@ -164,9 +163,6 @@ class Route { /// A list of crossing distances on the route, in the order of `crossings`. final List crossingsDistancesOnRoute; - /// A list of instructions. - final List instructions; - /// The most unique attribute of the route within a set of routes. String? mostUniqueAttribute; @@ -196,7 +192,6 @@ class Route { required this.signalGroupsDistancesOnRoute, required this.crossings, required this.crossingsDistancesOnRoute, - required this.instructions, required this.osmTags, }) { osmWayNames = {}; @@ -227,7 +222,6 @@ class Route { signalGroupsDistancesOnRoute: (json['signalGroupsDistancesOnRoute'] as List).map((e) => e as double).toList(), crossings: (json['crossings'] as List).map((e) => Crossing.fromJson(e)).toList(), crossingsDistancesOnRoute: (json['crossingsDistancesOnRoute'] as List).map((e) => e as double).toList(), - instructions: [], osmTags: (json['osmTags'] as Map).map((key, value) => MapEntry(int.parse(key), Map.from(value))), ); @@ -279,7 +273,6 @@ class Route { ], crossings: crossings, crossingsDistancesOnRoute: crossingsDistancesOnRoute, - instructions: instructions, osmTags: osmTags, ); } diff --git a/lib/routing/services/routing.dart b/lib/routing/services/routing.dart index 826f5597b..1f6338a06 100644 --- a/lib/routing/services/routing.dart +++ b/lib/routing/services/routing.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import 'package:priobike/home/models/profile.dart'; @@ -12,8 +11,6 @@ import 'package:priobike/positioning/services/positioning.dart'; import 'package:priobike/routing/messages/graphhopper.dart'; import 'package:priobike/routing/messages/sgselector.dart'; import 'package:priobike/routing/models/crossing.dart'; -import 'package:priobike/routing/models/instruction.dart'; -import 'package:priobike/routing/models/navigation.dart'; import 'package:priobike/routing/models/poi.dart'; import 'package:priobike/routing/models/route.dart' as r; import 'package:priobike/routing/models/sg.dart'; @@ -477,13 +474,6 @@ class Routing with ChangeNotifier { orderedCrossingsDistancesOnRoute.add(tuple.distance); } - // Only create instructions if the user has enabled audio. - List instructions = List.empty(growable: true); - if (getIt().audioInstructionsEnabled) { - // Add an instruction for each relevant waypoint. - instructions = createInstructions(sgSelectorResponse, path); - } - final osmTagsForRoute = osmTags[i]; var route = r.Route( @@ -494,7 +484,6 @@ class Routing with ChangeNotifier { signalGroupsDistancesOnRoute: signalGroupsDistancesOnRoute, crossings: orderedCrossings, crossingsDistancesOnRoute: orderedCrossingsDistancesOnRoute, - instructions: instructions, osmTags: osmTagsForRoute, ); // Connect the route to the start and end points. @@ -541,339 +530,6 @@ class Routing with ChangeNotifier { notifyListeners(); } - /// Find the waypoint x meters before the current instruction - /// DistanceToInstructionPoint x must be given as an argument. - LatLng? findWaypointMetersBeforeInstruction(int distanceToInstructionPoint, SGSelectorResponse sgSelectorResponse, - int currentNavigationNodeIdx, LatLng? lastInstructionPoint, bool? isFirstInstruction) { - double totalDistanceToInstructionPoint = 0; - LatLng p2 = LatLng( - sgSelectorResponse.route[currentNavigationNodeIdx].lat, sgSelectorResponse.route[currentNavigationNodeIdx].lon); - LatLng p1; - // Iterating backwards from the current navigation node until the first matching node. - for (var j = currentNavigationNodeIdx - 1; j >= 0; j--) { - p1 = LatLng(sgSelectorResponse.route[j].lat, sgSelectorResponse.route[j].lon); - - var distanceToPreviousNavigationNode = Snapper.vincenty.distance(p1, p2); - totalDistanceToInstructionPoint += distanceToPreviousNavigationNode; - - if (lastInstructionPoint?.latitude == p1.latitude && lastInstructionPoint?.longitude == p1.longitude) { - // there is already an instruction at this instruction point - return null; - } - - if (totalDistanceToInstructionPoint == distanceToInstructionPoint) { - return p1; - } else if (totalDistanceToInstructionPoint > distanceToInstructionPoint) { - var distanceBefore = totalDistanceToInstructionPoint - distanceToPreviousNavigationNode; - var remainingDistance = distanceToInstructionPoint - distanceBefore; - // calculate point c between a and b such that distance between b and c is remainingDistance - double bearing = Snapper.vincenty.bearing(p1, p2); - LatLng c = Snapper.vincenty.offset(p2, remainingDistance, bearing); - return c; - } - p2 = p1; - } - return null; - } - - /// Get the text of all GraphHopper instructions that belong to a specific waypoint. - String getGHInstructionTextForWaypoint(GHRouteResponsePath path, NavigationNode waypoint) { - List instructionList = []; - - // Get the GraphHopper coordinates that matches lat and long of current waypoint. - final ghCoordinate = path.points.coordinates - .firstWhereOrNull((element) => element.lat == waypoint.lat && element.lon == waypoint.lon); - - if (ghCoordinate != null) { - final index = path.points.coordinates.indexOf(ghCoordinate); - - // Get all GraphHopper instructions that match the index of the coordinate. - for (final instruction in path.instructions) { - if (instruction.interval.first == index) { - // Skip waypoint instructions and "Dem Straßenverlauf folgen" instruction after a waypoint. - final isWaypoint = instruction.text.startsWith("Wegpunkt"); - - int instructionIndex = path.instructions.indexOf(instruction); - final previousInstruction = instructionIndex > 0 ? path.instructions[instructionIndex - 1] : null; - final isFollowTheRouteInstructionAfterWaypoint = instruction.text == "Dem Straßenverlauf folgen" && - previousInstruction != null && - previousInstruction.text.startsWith("Wegpunkt"); - - if (!isWaypoint && !isFollowTheRouteInstructionAfterWaypoint) { - instructionList.add(instruction); - } - } - } - } - - // Compose all ghInstructions for waypoint to a single instruction text. - String completeInstructionText = ""; - for (int i = 0; i < instructionList.length; i++) { - completeInstructionText += instructionList[i].text; - if (i < instructionList.length - 1) { - completeInstructionText += " und "; - } - } - - return completeInstructionText; - } - - /// Get the signal group id that belongs to a specific waypoint. - String? getSignalGroupIdForWaypoint(NavigationNode waypoint, bool hasGHInstruction, double? distance) { - if (!hasGHInstruction && waypoint.distanceToNextSignal == 0.0 && waypoint.signalGroupId != null) { - // if waypoint does not belong to a GHInstruction check if there is a sg at the exact point - return waypoint.signalGroupId; - } else if (hasGHInstruction && - waypoint.distanceToNextSignal != null && - waypoint.distanceToNextSignal! <= distance!) { - // if waypoint belongs to a GHInstruction check if there is a sg near the point - return waypoint.signalGroupId; - } - return null; - } - - /// Create the instruction text based on the type of instruction. - List createInstructionText( - bool isFirstCall, - InstructionType instructionType, - String ghInstructionText, - String? signalGroupId, - String laneType, - double distanceToNextSg, - int distanceToNextInstruction) { - String prefix = isFirstCall ? "In $distanceToNextInstruction Metern" : ""; - String sgType = (laneType == "Radfahrer") ? "Radampel" : "Ampel"; - - switch (instructionType) { - case InstructionType.directionOnly: - return [ - InstructionText( - text: "$prefix $ghInstructionText", - type: InstructionTextType.direction, - distanceToNextSg: distanceToNextSg) - ]; - case InstructionType.signalGroupOnly: - return [ - InstructionText( - text: "$prefix $sgType", type: InstructionTextType.signalGroup, distanceToNextSg: distanceToNextSg) - ]; - case InstructionType.directionAndSignalGroup: - return [ - InstructionText( - text: "$prefix $ghInstructionText", - type: InstructionTextType.direction, - distanceToNextSg: distanceToNextSg), - InstructionText(text: sgType, type: InstructionTextType.signalGroup, distanceToNextSg: distanceToNextSg) - ]; - default: - return []; - } - } - - /// Get sgType for a specific signal group id. - String getSGTypeForSignalGroupId(String signalGroupId, SGSelectorResponse sgSelectorResponse) { - final signalGroup = sgSelectorResponse.signalGroups[signalGroupId]; - return signalGroup!.id; - } - - /// Create the instructions for each route. - List createInstructions(SGSelectorResponse sgSelectorResponse, GHRouteResponsePath path) { - final instructions = List.empty(growable: true); - LatLng? lastInstructionPoint; - - // Check for every navigation node in the route if there should be an instruction created. - for (var currentNavigationNodeIdx = 0; - currentNavigationNodeIdx < sgSelectorResponse.route.length; - currentNavigationNodeIdx++) { - final currentWaypoint = sgSelectorResponse.route[currentNavigationNodeIdx]; - String ghInstructionText = getGHInstructionTextForWaypoint(path, currentWaypoint); - String? signalGroupId = getSignalGroupIdForWaypoint(currentWaypoint, ghInstructionText.isNotEmpty, 25); - String laneType = sgSelectorResponse.signalGroups[signalGroupId]?.laneType ?? ""; - InstructionType? instructionType = getInstructionType(ghInstructionText, signalGroupId); - if (instructionType == null) { - continue; // no instruction to be created. - } - - // Try to create first instruction call 300m before the point the instruction is referring to - // Or to concatenate with the previous instruction if the distance is less than 300m - // If concatenation is not possible no firstInstructionCall will be added to the route. - var waypointFirstInstructionCall = findWaypointMetersBeforeInstruction( - 300, sgSelectorResponse, currentNavigationNodeIdx, lastInstructionPoint, instructions.isEmpty); - if (waypointFirstInstructionCall != null) { - Instruction firstInstructionCall = Instruction( - lat: waypointFirstInstructionCall.latitude, - lon: waypointFirstInstructionCall.longitude, - text: createInstructionText(true, instructionType, ghInstructionText, signalGroupId, laneType, 300, 300), - instructionType: instructionType, - signalGroupId: signalGroupId); - instructions.add(firstInstructionCall); - lastInstructionPoint = LatLng(currentWaypoint.lat, currentWaypoint.lon); - } else if (lastInstructionPoint != null && - (!instructions.last.alreadyConcatenated || instructionType == InstructionType.signalGroupOnly)) { - if (instructions.last.instructionType != InstructionType.directionOnly) { - // Put the instruction call at the point when crossing the previous sg is finished - // This point is equal to the last point in the signal crossing geometry attribute. - var sgId = instructions.last.signalGroupId; - var previousSgLaneEnd = sgSelectorResponse.signalGroups[sgId]!.geometry!.last; - var previousDistToSg = instructions.last.text.last.distanceToNextSg; - var distanceToActualInstructionPoint = Snapper.vincenty.distance( - LatLng(previousSgLaneEnd[1], previousSgLaneEnd[0]), LatLng(currentWaypoint.lat, currentWaypoint.lon)); - var threshold = (instructionType != InstructionType.directionOnly) ? 150 : 50; - if (distanceToActualInstructionPoint > threshold) { - // Only put firstInstructionCall if distance to actual instruction point is greater than threshold. - Instruction firstInstructionCall = Instruction( - lat: previousSgLaneEnd[1], - lon: previousSgLaneEnd[0], - text: createInstructionText(true, instructionType, ghInstructionText, signalGroupId, laneType, - previousDistToSg, distanceToActualInstructionPoint.toInt()), - instructionType: instructionType, - signalGroupId: signalGroupId); - instructions.add(firstInstructionCall); - lastInstructionPoint = LatLng(currentWaypoint.lat, currentWaypoint.lon); - } - } else { - var distanceToActualInstructionPoint = - Snapper.vincenty.distance(lastInstructionPoint, LatLng(currentWaypoint.lat, currentWaypoint.lon)); - var threshold = (instructionType != InstructionType.directionOnly) ? 150 : 50; - // Only concatenate firstInstructionCall if distance to actual instruction point is greater than threshold. - if (distanceToActualInstructionPoint > threshold) { - concatenateInstructions(instructionType, ghInstructionText, signalGroupId, instructions, laneType); - } - } - } - - // Create second instruction call at the point the instruction is referring to. - if (instructionType != InstructionType.directionOnly) { - // Put instruction point 100m before sg. - var waypointSecondInstructionCall = findWaypointMetersBeforeInstruction( - 100, sgSelectorResponse, currentNavigationNodeIdx, lastInstructionPoint, instructions.isEmpty); - if (waypointSecondInstructionCall != null && - lastInstructionPoint != null && - Snapper.vincenty.distance(lastInstructionPoint, waypointSecondInstructionCall) > 50) { - // Only put secondInstructionCall if distance to actual instruction point is greater than 50m for reasons of overlapping speak times. - Instruction secondInstructionCall = Instruction( - lat: waypointSecondInstructionCall.latitude, - lon: waypointSecondInstructionCall.longitude, - text: createInstructionText(false, instructionType, ghInstructionText, signalGroupId, laneType, 100, 0), - instructionType: instructionType, - signalGroupId: signalGroupId); - instructions.add(secondInstructionCall); - lastInstructionPoint = LatLng(currentWaypoint.lat, currentWaypoint.lon); - } else if (instructions.isNotEmpty && instructions.last.instructionType != InstructionType.directionOnly) { - // Put the instruction call at the point when crossing the previous sg is finished - // This point is equal to the last point in the signal crossing geometry attribute. - var sgId = instructions.last.signalGroupId; - var previousSgLaneEnd = sgSelectorResponse.signalGroups[sgId]!.geometry!.last; - var previousDistToSg = instructions.last.text.last.distanceToNextSg; - Instruction secondInstructionCall = Instruction( - lat: previousSgLaneEnd[1], - lon: previousSgLaneEnd[0], - text: createInstructionText( - false, instructionType, ghInstructionText, signalGroupId, laneType, previousDistToSg, 0), - instructionType: instructionType, - signalGroupId: signalGroupId); - instructions.add(secondInstructionCall); - lastInstructionPoint = LatLng(currentWaypoint.lat, currentWaypoint.lon); - } else { - concatenateInstructions(instructionType, ghInstructionText, signalGroupId, instructions, laneType); - } - } else { - // Put instruction point 10m before the crossing. - var waypointSecondInstructionCall = findWaypointMetersBeforeInstruction( - 10, sgSelectorResponse, currentNavigationNodeIdx, lastInstructionPoint, instructions.isEmpty); - if (waypointSecondInstructionCall != null) { - // Put the instruction at the point provided by GraphHopper. - Instruction secondInstructionCall = Instruction( - lat: waypointSecondInstructionCall.latitude, - lon: waypointSecondInstructionCall.longitude, - text: createInstructionText(false, instructionType, ghInstructionText, signalGroupId, laneType, - currentWaypoint.distanceToNextSignal ?? 0, 0), - instructionType: instructionType, - signalGroupId: signalGroupId); - instructions.add(secondInstructionCall); - lastInstructionPoint = LatLng(currentWaypoint.lat, currentWaypoint.lon); - } else if (instructions.isNotEmpty && - instructions.last.instructionType == InstructionType.directionOnly && - !instructions.last.alreadyConcatenated) { - // Concatenate instructions if possible. - concatenateInstructions(instructionType, ghInstructionText, signalGroupId, instructions, laneType); - } else { - // Put the instruction at the point provided by GraphHopper. - Instruction secondInstructionCall = Instruction( - lat: currentWaypoint.lat, - lon: currentWaypoint.lon, - text: createInstructionText(false, instructionType, ghInstructionText, signalGroupId, laneType, - currentWaypoint.distanceToNextSignal ?? 0, 0), - instructionType: instructionType, - signalGroupId: signalGroupId); - instructions.add(secondInstructionCall); - lastInstructionPoint = LatLng(currentWaypoint.lat, currentWaypoint.lon); - } - } - } - return instructions; - } - - /// Determine the instruction type after concatenation. - InstructionType getInstructionTypeAfterConcatenation( - InstructionType originalInstructionType, InstructionType addedInstructionType) { - switch (originalInstructionType) { - case InstructionType.signalGroupOnly: - if (addedInstructionType == InstructionType.directionOnly || - addedInstructionType == InstructionType.directionAndSignalGroup) { - return InstructionType.directionAndSignalGroup; - } - return InstructionType.signalGroupOnly; - case InstructionType.directionOnly: - if (addedInstructionType == InstructionType.signalGroupOnly || - addedInstructionType == InstructionType.directionAndSignalGroup) { - return InstructionType.directionAndSignalGroup; - } - return InstructionType.directionOnly; - case InstructionType.directionAndSignalGroup: - return InstructionType.directionAndSignalGroup; - } - } - - /// Concatenate the current instruction with the previous one. - void concatenateInstructions(InstructionType instructionType, String ghInstructionText, String? signalGroupId, - List instructions, String laneType) { - if (instructions.isEmpty) return; - - if (instructions.last.instructionType == InstructionType.signalGroupOnly && - instructionType == InstructionType.signalGroupOnly && - instructions.last.signalGroupId == signalGroupId) { - // Do not concatenate two information about the same signal group. - return; - } - var previousDistToSg = instructions.last.text.last.distanceToNextSg; - var textToConcatenate = - createInstructionText(false, instructionType, ghInstructionText, signalGroupId, laneType, previousDistToSg, 0); - for (int i = 0; i < textToConcatenate.length; i++) { - textToConcatenate[i].text = "und dann ${textToConcatenate[i].text}"; - instructions.last.text.add(textToConcatenate[i]); - } - if (signalGroupId != null) { - instructions.last.signalGroupId = signalGroupId; - } - instructions.last.alreadyConcatenated = true; - instructions.last.instructionType = - getInstructionTypeAfterConcatenation(instructions.last.instructionType, instructionType); - } - - /// Determine the type of instruction to be created. - InstructionType? getInstructionType(String ghInstructionText, String? signalGroupId) { - if (ghInstructionText.isNotEmpty && signalGroupId != null) { - return InstructionType.directionAndSignalGroup; - } else if (ghInstructionText.isNotEmpty) { - return InstructionType.directionOnly; - } else if (signalGroupId != null) { - return InstructionType.signalGroupOnly; - } else { - return null; - } - } - /// Select a route. Future switchToRoute(int idx) async { if (idx < 0 || idx >= allRoutes!.length) return; diff --git a/lib/settings/services/settings.dart b/lib/settings/services/settings.dart index 5c5c04765..34e3c7e7c 100644 --- a/lib/settings/services/settings.dart +++ b/lib/settings/services/settings.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' hide Shortcuts; import 'package:priobike/logging/logger.dart'; import 'package:priobike/main.dart'; +import 'package:priobike/ride/models/audio.dart'; import 'package:priobike/ride/services/live_tracking.dart'; import 'package:priobike/settings/models/backend.dart' hide Simulator, LiveTracking; import 'package:priobike/settings/models/color_mode.dart'; @@ -73,8 +74,8 @@ class Settings with ChangeNotifier { /// If the save battery mode is enabled. bool saveBatteryModeEnabled; - /// If the audio instructions are enabled. - bool audioInstructionsEnabled; + /// If the audio speed advisory instructions are enabled. + bool audioSpeedAdvisoryInstructionsEnabled; /// If the user had migrate background images. bool didMigrateBackgroundImages = false; @@ -88,6 +89,8 @@ class Settings with ChangeNotifier { /// If we want to show the speed with increased precision in the speedometer. bool isIncreasedSpeedPrecisionInSpeedometerEnabled = false; + /// The speech rate for the audio instructions. + SpeechRate speechRate = SpeechRate.normal; static const enableLogPersistenceKey = "priobike.settings.enableLogPersistence"; static const defaultEnableLogPersistence = false; @@ -373,17 +376,18 @@ class Settings with ChangeNotifier { return success; } - static const audioInstructionsEnabledKey = "priobike.settings.audioInstructionsEnabled"; - static const defaultSaveAudioInstructionsEnabled = false; + static const audioInstructionsEnabledKey = "priobike.settings.audioSpeedAdvisoryInstructionsEnabled"; + static const defaultAudioInstructionsEnabled = false; - Future setAudioInstructionsEnabled(bool audioInstructionsEnabled, [SharedPreferences? storage]) async { + Future setAudioInstructionsEnabled(bool audioSpeedAdvisoryInstructionsEnabled, + [SharedPreferences? storage]) async { storage ??= await SharedPreferences.getInstance(); - final prev = this.audioInstructionsEnabled; - this.audioInstructionsEnabled = audioInstructionsEnabled; - final bool success = await storage.setBool(audioInstructionsEnabledKey, audioInstructionsEnabled); + final prev = this.audioSpeedAdvisoryInstructionsEnabled; + this.audioSpeedAdvisoryInstructionsEnabled = audioSpeedAdvisoryInstructionsEnabled; + final bool success = await storage.setBool(audioInstructionsEnabledKey, audioSpeedAdvisoryInstructionsEnabled); if (!success) { - log.e("Failed to set audioInstructionsEnabled to $audioInstructionsEnabled"); - this.audioInstructionsEnabled = prev; + log.e("Failed to set audioInstructionsEnabled to $audioSpeedAdvisoryInstructionsEnabled"); + this.audioSpeedAdvisoryInstructionsEnabled = prev; } else { notifyListeners(); } @@ -452,6 +456,23 @@ class Settings with ChangeNotifier { return success; } + static const speechRateKey = "priobike.settings.speechRate"; + static const defaultSpeechRate = SpeechRate.normal; + + Future setSpeechRate(SpeechRate speechRate, [SharedPreferences? storage]) async { + storage ??= await SharedPreferences.getInstance(); + final prev = this.speechRate; + this.speechRate = speechRate; + final bool success = await storage.setString(speechRateKey, speechRate.name); + if (!success) { + log.e("Failed to set speechRate to $speechRate"); + this.speechRate = prev; + } else { + notifyListeners(); + } + return success; + } + Settings({ this.city = defaultCity, this.enableLogPersistence = defaultEnableLogPersistence, @@ -468,12 +489,13 @@ class Settings with ChangeNotifier { this.sgSelector = defaultSGSelector, this.trackingSubmissionPolicy = defaultTrackingSubmissionPolicy, this.saveBatteryModeEnabled = defaultSaveBatteryModeEnabled, - this.audioInstructionsEnabled = defaultSaveAudioInstructionsEnabled, + this.audioSpeedAdvisoryInstructionsEnabled = defaultAudioInstructionsEnabled, this.useCounter = defaultUseCounter, this.didMigrateBackgroundImages = defaultDidMigrateBackgroundImages, this.enableSimulatorMode = defaultSimulatorMode, this.enableLiveTrackingMode = defaultLiveTrackingMode, this.isIncreasedSpeedPrecisionInSpeedometerEnabled = defaultIsIncreasedSpeedPrecisionInSpeedometerEnabled, + this.speechRate = defaultSpeechRate, }); /// Load the internal settings from the shared preferences. @@ -515,11 +537,6 @@ class Settings with ChangeNotifier { } catch (e) { /* Do nothing and use the default value given by the constructor. */ } - try { - audioInstructionsEnabled = storage.getBool(audioInstructionsEnabledKey) ?? defaultSaveAudioInstructionsEnabled; - } catch (e) { - /* Do nothing and use the default value given by the constructor. */ - } } /// Load the stored settings. @@ -560,6 +577,17 @@ class Settings with ChangeNotifier { } catch (e) { /* Do nothing and use the default value given by the constructor. */ } + try { + audioSpeedAdvisoryInstructionsEnabled = + storage.getBool(audioInstructionsEnabledKey) ?? defaultAudioInstructionsEnabled; + } catch (e) { + /* Do nothing and use the default value given by the constructor. */ + } + try { + speechRate = SpeechRate.values.byName(storage.getString(speechRateKey) ?? defaultSpeechRate.name); + } catch (e) { + /* Do nothing and use the default value given by the constructor. */ + } hasLoaded = true; notifyListeners(); diff --git a/lib/settings/views/internal.dart b/lib/settings/views/internal.dart index f3b0b9fd0..7521ec1d1 100644 --- a/lib/settings/views/internal.dart +++ b/lib/settings/views/internal.dart @@ -228,22 +228,6 @@ class InternalSettingsViewState extends State { ], ), const VSpace(), - Padding( - padding: const EdgeInsets.only(top: 8), - child: SettingsElement( - title: "Sprachausgabe aktivieren", - icon: settings.audioInstructionsEnabled ? Icons.check_box : Icons.check_box_outline_blank, - callback: () => settings.setAudioInstructionsEnabled(!settings.audioInstructionsEnabled), - ), - ), - Padding( - padding: const EdgeInsets.only(left: 34, top: 8, bottom: 8, right: 24), - child: Small( - text: - "Aktiviere die Sprachausgabe, um während der Fahrt Informationen über Lautsprecher oder Kopfhörer zu erhalten. Du kannst die App somit jetzt auch ohne eingeschaltetes Display aus der Hosentasche heraus nutzen.", - context: context, - ), - ), Padding( padding: const EdgeInsets.only(top: 8), child: SettingsElement( diff --git a/lib/settings/views/main.dart b/lib/settings/views/main.dart index 22ab8afcd..f25ea2215 100644 --- a/lib/settings/views/main.dart +++ b/lib/settings/views/main.dart @@ -11,6 +11,7 @@ import 'package:priobike/licenses/views.dart'; import 'package:priobike/logging/toast.dart'; import 'package:priobike/main.dart'; import 'package:priobike/privacy/views.dart'; +import 'package:priobike/ride/models/audio.dart'; import 'package:priobike/settings/models/color_mode.dart'; import 'package:priobike/settings/models/speed.dart'; import 'package:priobike/settings/models/tracking.dart'; @@ -211,6 +212,14 @@ class SettingsViewState extends State { if (mounted) Navigator.pop(context); } + /// A callback that is executed when a tracking submission policy is selected. + Future onSelectSpeechRate(SpeechRate speechRate) async { + // Tell the settings service that we selected the new tracking submission policy. + await settings.setSpeechRate(speechRate); + + if (mounted) Navigator.pop(context); + } + /// A callback that is executed when the save battery mode is changed. Future onChangeSaveBatteryMode(bool saveBatteryModeEnabled) async { // Tell the settings service that we selected the new save battery mode. @@ -354,6 +363,45 @@ class SettingsViewState extends State { ), ), const VSpace(), + Padding( + padding: const EdgeInsets.only(top: 8), + child: SettingsElement( + title: "Sprachausgabe aktivieren", + icon: settings.audioSpeedAdvisoryInstructionsEnabled + ? Icons.check_box + : Icons.check_box_outline_blank, + callback: () => + settings.setAudioInstructionsEnabled(!settings.audioSpeedAdvisoryInstructionsEnabled), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 34, top: 8, bottom: 8, right: 24), + child: Small( + text: + "Somit kannst du die App auch mit ausgeschaltetem Display für bekannte Strecken verwenden. Dir werden die Ampelinformationen über Lautsprecher oder Kopfhörer ausgegeben.", + context: context, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8), + child: SettingsElement( + title: "Sprachausgabe Geschwindigkeit", + subtitle: settings.speechRate.description, + icon: Icons.expand_more, + callback: () { + showAppSheet( + context: context, + builder: (BuildContext context) { + return SettingsSelection( + elements: SpeechRate.values, + selected: settings.speechRate, + title: (SpeechRate e) => e.description, + callback: onSelectSpeechRate); + }, + ); + }), + ), + const VSpace(), SettingsElement( title: "App bewerten", icon: Icons.rate_review_outlined, @@ -442,7 +490,7 @@ class SettingsViewState extends State { Content(text: "App-ID", context: context), InkWell( child: BoldSmall( - text: "${userId.substring(0, 5)}...", + text: "${userId.length > 5 ? userId.substring(0, 5) : userId}...", context: context, ), ), diff --git a/lib/tracking/models/battery_history.dart b/lib/tracking/models/battery_history.dart new file mode 100644 index 000000000..47944a778 --- /dev/null +++ b/lib/tracking/models/battery_history.dart @@ -0,0 +1,36 @@ +class BatteryHistory { + /// The battery level, in percent. + int? level; + + /// The timestamp of the battery state. + int? timestamp; + + /// The state of the battery. + String? batteryState; + + /// If the system is in battery save mode. + bool? isInBatterySaveMode; + + BatteryHistory( + {required this.level, required this.timestamp, required this.batteryState, required this.isInBatterySaveMode}); + + /// Convert the battery state to a json object. + Map toJson() { + return { + 'level': level, + 'timestamp': timestamp, + 'batteryState': batteryState, + 'isInBatterySaveMode': isInBatterySaveMode, + }; + } + + /// Create a battery state from a json object. + factory BatteryHistory.fromJson(Map json) { + return BatteryHistory( + level: json.containsKey('level') ? json['level'] : null, + timestamp: json.containsKey('timestamp') ? json['timestamp'] : null, + batteryState: json.containsKey('batteryState') ? json['batteryState'] : null, + isInBatterySaveMode: json.containsKey('isInBatterySaveMode') ? json['isInBatterySaveMode'] : null, + ); + } +} diff --git a/lib/tracking/models/speed_advisory_instruction.dart b/lib/tracking/models/speed_advisory_instruction.dart new file mode 100644 index 000000000..c41c7df82 --- /dev/null +++ b/lib/tracking/models/speed_advisory_instruction.dart @@ -0,0 +1,35 @@ +class SpeedAdvisoryInstruction { + /// The instruction text. + String text; + + /// The given countdown. + int countdown; + + /// The latitude of the instruction. + double lat; + + /// The longitude of the instruction. + double lon; + + SpeedAdvisoryInstruction({required this.text, required this.countdown, required this.lat, required this.lon}); + + /// Convert the speed advisory instruction to a json object. + Map toJson() { + return { + 'text': text, + 'countdown': countdown, + 'lat': lat, + 'lon': lon, + }; + } + + /// Create a speed advisroy instruction from a json object. + factory SpeedAdvisoryInstruction.fromJson(Map json) { + return SpeedAdvisoryInstruction( + text: json.containsKey('text') ? json['text'] : null, + countdown: json.containsKey('countdown') ? json['countdown'] : null, + lat: json.containsKey('lat') ? json['lat'] : null, + lon: json.containsKey('lon') ? json['lon'] : null, + ); + } +} diff --git a/lib/tracking/models/track.dart b/lib/tracking/models/track.dart index 562052c0a..9ba6ba860 100644 --- a/lib/tracking/models/track.dart +++ b/lib/tracking/models/track.dart @@ -8,45 +8,10 @@ import 'package:priobike/routing/models/waypoint.dart'; import 'package:priobike/settings/models/backend.dart'; import 'package:priobike/settings/models/positioning.dart'; import 'package:priobike/status/messages/summary.dart'; +import 'package:priobike/tracking/models/battery_history.dart'; +import 'package:priobike/tracking/models/speed_advisory_instruction.dart'; import 'package:priobike/tracking/models/tap_tracking.dart'; -class BatteryHistory { - /// The battery level, in percent. - int? level; - - /// The timestamp of the battery state. - int? timestamp; - - /// The state of the battery. - String? batteryState; - - /// If the system is in battery save mode. - bool? isInBatterySaveMode; - - BatteryHistory( - {required this.level, required this.timestamp, required this.batteryState, required this.isInBatterySaveMode}); - - /// Convert the battery state to a json object. - Map toJson() { - return { - 'level': level, - 'timestamp': timestamp, - 'batteryState': batteryState, - 'isInBatterySaveMode': isInBatterySaveMode, - }; - } - - /// Create a battery state from a json object. - factory BatteryHistory.fromJson(Map json) { - return BatteryHistory( - level: json.containsKey('level') ? json['level'] : null, - timestamp: json.containsKey('timestamp') ? json['timestamp'] : null, - batteryState: json.containsKey('batteryState') ? json['batteryState'] : null, - isInBatterySaveMode: json.containsKey('isInBatterySaveMode') ? json['isInBatterySaveMode'] : null, - ); - } -} - class Track { /// The start time of this track, in milliseconds since the epoch. int startTime; @@ -147,6 +112,9 @@ class Track { /// The battery states sampled during the ride. List batteryStates = []; + /// The speed advisory instructions during the ride. + List speedAdvisoryInstructions = []; + /// The lightning mode. bool? isDarkMode; @@ -190,6 +158,7 @@ class Track { required this.routes, required this.subVersion, required this.batteryStates, + required this.speedAdvisoryInstructions, this.canUseGamification = false, required this.isDarkMode, required this.saveBatteryModeEnabled, @@ -228,6 +197,7 @@ class Track { }) .toList(), 'batteryStates': batteryStates.map((e) => e.toJson()).toList(), + 'speedAdvisoryInstructions': speedAdvisoryInstructions.map((e) => e.toJson()).toList(), 'isDarkMode': isDarkMode, 'saveBatteryModeEnabled': saveBatteryModeEnabled, }; @@ -241,6 +211,14 @@ class Track { if (json.containsKey("batteryStates")) { batteryStates = (json['batteryStates'] as List).map((e) => BatteryHistory.fromJson(e)).toList(); } + + List speedAdvisoryInstructions = []; + if (json.containsKey("speedAdvisoryInstructions")) { + speedAdvisoryInstructions = (json['speedAdvisoryInstructions'] as List) + .map((e) => SpeedAdvisoryInstruction.fromJson(e)) + .toList(); + } + return Track( uploaded: json['uploaded'], // If the track was stored before we added the hasFileData field, @@ -272,6 +250,7 @@ class Track { subVersion: json['subVersion'], canUseGamification: json['canUseGamification'], batteryStates: batteryStates, + speedAdvisoryInstructions: speedAdvisoryInstructions, isDarkMode: json.containsKey("isDarkMode") ? json["isDarkMode"] : null, saveBatteryModeEnabled: json.containsKey("saveBatteryModeEnabled") ? json["saveBatteryModeEnabled"] : null, ); diff --git a/lib/tracking/services/tracking.dart b/lib/tracking/services/tracking.dart index 389036dc5..f85e4a47d 100644 --- a/lib/tracking/services/tracking.dart +++ b/lib/tracking/services/tracking.dart @@ -22,6 +22,8 @@ import 'package:priobike/settings/models/tracking.dart'; import 'package:priobike/settings/services/features.dart'; import 'package:priobike/settings/services/settings.dart'; import 'package:priobike/status/services/summary.dart'; +import 'package:priobike/tracking/models/battery_history.dart'; +import 'package:priobike/tracking/models/speed_advisory_instruction.dart'; import 'package:priobike/tracking/models/track.dart'; import 'package:priobike/user.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -158,6 +160,7 @@ class Tracking with ChangeNotifier { routes: routing.selectedRoute == null ? {} : {startTime: routing.selectedRoute!}, subVersion: feature.buildTrigger, batteryStates: [], + speedAdvisoryInstructions: [], saveBatteryModeEnabled: saveBatteryModeEnabled, isDarkMode: isDarkMode, ); @@ -200,6 +203,11 @@ class Tracking with ChangeNotifier { } } + void addSpeedAdvisoryInstruction(SpeedAdvisoryInstruction speedAdvisoryInstruction) { + if (track == null) return; + track!.speedAdvisoryInstructions.add(speedAdvisoryInstruction); + } + /// Start collecting GPS data. Future startCollectingGPSData() async { gpsCache = CSVCache( diff --git a/pubspec.lock b/pubspec.lock index 9ab1d37d1..933c3db87 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + audio_session: + dependency: "direct main" + description: + name: audio_session + sha256: "343e83bc7809fbda2591a49e525d6b63213ade10c76f15813be9aed6657b3261" + url: "https://pub.dev" + source: hosted + version: "0.1.21" battery_plus: dependency: "direct main" description: @@ -1032,6 +1040,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" share_plus: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 42a0c3adb..43bd898ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,7 +67,8 @@ dependencies: flutter_tts: ^4.0.2 # Used for text-to-speech flutter_blue_plus: 1.20.8 # Used for connecting to BLE devices. Note: keep this Version due to problems with newer versions. flutter_markdown: ^0.7.1 # Used to display the privacy policy - uuid: ^4.5.0 # Used for user and session IDs + audio_session: ^0.1.21 # Used to play audio in the background + uuid: ^4.5.0 # Used for user and session IDs dev_dependencies: flutter_launcher_icons: ^0.13.1 # Generate app icons diff --git a/test/audio_speed_advisory_text_generation.dart b/test/audio_speed_advisory_text_generation.dart new file mode 100644 index 000000000..ff2fec54e --- /dev/null +++ b/test/audio_speed_advisory_text_generation.dart @@ -0,0 +1,1676 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:priobike/common/models/point.dart'; +import 'package:priobike/ride/interfaces/prediction.dart'; +import 'package:priobike/ride/messages/prediction.dart'; +import 'package:priobike/ride/models/recommendation.dart'; +import 'package:priobike/ride/services/audio.dart'; +import 'package:priobike/ride/services/prediction.dart'; +import 'package:priobike/ride/services/ride.dart'; +import 'package:priobike/routing/models/instruction.dart'; +import 'package:priobike/routing/models/sg.dart'; +import 'package:priobike/settings/models/speed.dart'; +import 'package:priobike/settings/services/settings.dart'; + +void main() { + /// The central getIt instance that is used to access the singleton services. + final getIt = GetIt.instance; + + getIt.registerSingleton(Settings(speedMode: SpeedMode.max30kmh)); + getIt.registerSingleton