diff --git a/src/androidTest/java/de/dennisguse/opentracks/content/data/TestDataUtil.java b/src/androidTest/java/de/dennisguse/opentracks/content/data/TestDataUtil.java index 38100f62d..b252bbf79 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/content/data/TestDataUtil.java +++ b/src/androidTest/java/de/dennisguse/opentracks/content/data/TestDataUtil.java @@ -125,7 +125,7 @@ public class TestDataUtil { trackPoint.setAltitude(i * ALTITUDE_INTERVAL); trackPoint.setSpeed(Speed.of(5f + (i / 10f))); - trackPoint.setHeartRate(100f + i); + trackPoint.setHeartRate(100f + i % 80); trackPoint.setCadence(300f + i); trackPoint.setPower(400f + i); trackPoint.setAltitudeGain(ALTITUDE_GAIN); diff --git a/src/androidTest/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtilsTest.java b/src/androidTest/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtilsTest.java index 7b9ceb968..69d9d87d5 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtilsTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtilsTest.java @@ -8,7 +8,6 @@ import android.util.Pair; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; -import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -19,11 +18,13 @@ import de.dennisguse.opentracks.content.data.TestDataUtil; import de.dennisguse.opentracks.data.ContentProviderUtils; import de.dennisguse.opentracks.data.TrackPointIterator; import de.dennisguse.opentracks.data.models.Distance; +import de.dennisguse.opentracks.data.models.HeartRate; import de.dennisguse.opentracks.data.models.Speed; import de.dennisguse.opentracks.data.models.Track; +import de.dennisguse.opentracks.settings.PreferencesUtils; +import de.dennisguse.opentracks.stats.SensorStatistics; import de.dennisguse.opentracks.stats.TrackStatistics; import de.dennisguse.opentracks.ui.intervals.IntervalStatistics; -import de.dennisguse.opentracks.util.StringUtils; @RunWith(AndroidJUnit4.class) public class VoiceAnnouncementUtilsTest { @@ -34,6 +35,7 @@ public class VoiceAnnouncementUtilsTest { @Before public void setUp() { contentProviderUtils = new ContentProviderUtils(context); + PreferencesUtils.setVoiceAnnounceHeartRate(false); } @Test @@ -46,10 +48,10 @@ public class VoiceAnnouncementUtilsTest { stats.setTotalAltitudeGain(6000f); // when - String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, null); + String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, null, null).toString(); // then - assertEquals("total distance 20.00 kilometers in 1 hour 5 minutes 10 seconds at 18.4 kilometers per hour", announcement); + assertEquals("Total distance 20.00 kilometers. 1 hour 5 minutes 10 seconds. Speed 18.4 kilometers per hour.", announcement); } @Test @@ -63,22 +65,15 @@ public class VoiceAnnouncementUtilsTest { try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) { assertEquals(trackPointIterator.getCount(), numberOfPoints); IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000)); + intervalStatistics.addTrackPoints(trackPointIterator); lastInterval = intervalStatistics.getIntervalList().get(intervalStatistics.getIntervalList().size() - 1); } // when - String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, lastInterval); + String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, lastInterval, null).toString(); // then - Assert.assertEquals( - "total distance " + - StringUtils.getDistanceParts(context, stats.getTotalDistance(), true).first + - " kilometers in " + buildAndGetTimeText(stats.getTotalTime(), false) + " at " + - StringUtils.getSpeedParts(context, stats.getAverageMovingSpeed(), true, true).first + - " kilometers per hour Lap speed of " + - StringUtils.getSpeedParts(context, lastInterval.getSpeed(), true, true).first + - " kilometers per hour", - announcement); + assertEquals("Total distance 14.21 kilometers. 16 minutes 39 seconds. Speed 51.2 kilometers per hour. Lap speed 51.2 kilometers per hour.", announcement); } @Test @@ -91,10 +86,10 @@ public class VoiceAnnouncementUtilsTest { stats.setTotalAltitudeGain(6000f); // when - String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, false, null); + String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, false, null, null).toString(); // then - assertEquals("total distance 20.00 kilometers in 1 hour 5 minutes 10 seconds at 3 minutes 15 seconds per kilometer", announcement); + assertEquals("Total distance 20.00 kilometers. 1 hour 5 minutes 10 seconds. Pace 3 minutes 15 seconds per kilometer.", announcement); } @Test @@ -108,22 +103,15 @@ public class VoiceAnnouncementUtilsTest { try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) { assertEquals(trackPointIterator.getCount(), numberOfPoints); IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000)); + intervalStatistics.addTrackPoints(trackPointIterator); lastInterval = intervalStatistics.getIntervalList().get(intervalStatistics.getIntervalList().size() - 1); } // when - String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, false, lastInterval); + String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, false, lastInterval, null).toString(); // then - assertEquals( - "total distance " + - StringUtils.getDistanceParts(context, stats.getTotalDistance(), true).first + - " kilometers in " + buildAndGetTimeText(stats.getTotalTime(), false) + " at " + - buildAndGetTimeText(stats.getAverageMovingSpeed().toPace(true), true) + - " per kilometer Lap time of " + - buildAndGetTimeText(lastInterval.getSpeed().toPace(true), true) + - " per kilometer", - announcement); + assertEquals("Total distance 14.21 kilometers. 16 minutes 39 seconds. Pace 1 minute 10 seconds per kilometer. Lap time 1 minute 10 seconds per kilometer.", announcement); } @Test @@ -136,10 +124,10 @@ public class VoiceAnnouncementUtilsTest { stats.setTotalAltitudeGain(6000f); // when - String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, true, null); + String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, true, null, null).toString(); // then - assertEquals("total distance 12.43 miles in 1 hour 5 minutes 10 seconds at 11.4 miles per hour", announcement); + assertEquals("Total distance 12.43 miles. 1 hour 5 minutes 10 seconds. Speed 11.4 miles per hour.", announcement); } @Test @@ -153,22 +141,15 @@ public class VoiceAnnouncementUtilsTest { try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) { assertEquals(trackPointIterator.getCount(), numberOfPoints); IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000)); + intervalStatistics.addTrackPoints(trackPointIterator); lastInterval = intervalStatistics.getIntervalList().get(intervalStatistics.getIntervalList().size() - 1); } // when - String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, true, lastInterval); + String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, true, lastInterval, null).toString(); // then - assertEquals( - "total distance " + - StringUtils.getDistanceParts(context, stats.getTotalDistance(), false).first + - " miles in " + buildAndGetTimeText(stats.getTotalTime(), false) + " at " + - StringUtils.getSpeedParts(context, stats.getAverageMovingSpeed(), false, true).first + - " miles per hour Lap speed of " + - StringUtils.getSpeedParts(context, lastInterval.getSpeed(), false, true).first + - " miles per hour", - announcement); + assertEquals("Total distance 8.83 miles. 16 minutes 39 seconds. Speed 31.8 miles per hour. Lap speed 31.8 miles per hour.", announcement); } @Test @@ -181,10 +162,10 @@ public class VoiceAnnouncementUtilsTest { stats.setTotalAltitudeGain(6000f); // when - String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, false, null); + String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, false, null, null).toString(); // then - assertEquals("total distance 12.43 miles in 1 hour 5 minutes 10 seconds at 5 minutes 15 seconds per mile", announcement); + assertEquals("Total distance 12.43 miles. 1 hour 5 minutes 10 seconds. Pace 5 minutes 15 seconds per mile.", announcement); } @Test @@ -198,51 +179,39 @@ public class VoiceAnnouncementUtilsTest { try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) { assertEquals(trackPointIterator.getCount(), numberOfPoints); IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000)); + intervalStatistics.addTrackPoints(trackPointIterator); lastInterval = intervalStatistics.getIntervalList().get(intervalStatistics.getIntervalList().size() - 1); } // when - String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, false, lastInterval); + String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, false, lastInterval, null).toString(); // then - //assertEquals("total distance 12.43 miles in 1 hour 5 minutes 10 seconds at 5 minutes 15 seconds per mile Lap time of 1 minute 53 seconds per mile", announcement); - assertEquals( - "total distance " + - StringUtils.getDistanceParts(context, stats.getTotalDistance(), false).first + - " miles in " + buildAndGetTimeText(stats.getTotalTime(), false) + " at " + - buildAndGetTimeText(stats.getAverageMovingSpeed().toPace(false), true) + - " per mile Lap time of " + - buildAndGetTimeText(lastInterval.getSpeed().toPace(false), true) + - " per mile", - announcement); + assertEquals("Total distance 8.83 miles. 16 minutes 39 seconds. Pace 1 minute 53 seconds per mile. Lap time 1 minute 53 seconds per mile.", announcement); } - /** - * Builds an returns the text representing the duration's time. - * - * @param duration Duration object. - * @param showFromMinutes show minutes tough it's 0. - * @return text representing the duratin's time. - */ - private String buildAndGetTimeText(Duration duration, boolean showFromMinutes) { - long hours = Math.abs(duration.getSeconds()) / 3600; - long minutes = (Math.abs(duration.getSeconds()) % 3600) / 60; - long seconds = Math.abs(duration.getSeconds()) % 60; - String hUnit = hours > 1 || hours == 0 ? "hours" : "hour"; - String mUnit = minutes > 1 || minutes == 0 ? "minutes" : "minute"; - String sUnit = seconds > 1 || seconds == 0 ? "seconds" : "second"; + @Test + public void getAnnouncement_heart_rate_and_sensor_statistics() { + PreferencesUtils.setVoiceAnnounceHeartRate(true); - String res = hours > 0 ? hours + " " + hUnit : ""; - if (hours > 0) { - res += minutes > 0 || showFromMinutes ? " " + minutes + " " + mUnit : ""; - } else { - res += minutes > 0 || showFromMinutes ? minutes + " " + mUnit : ""; + int numberOfPoints = 1000; + Pair trackWithStats = TestDataUtil.buildTrackWithTrackPoints(contentProviderUtils, numberOfPoints); + Track.Id trackId = trackWithStats.first; + TrackStatistics stats = trackWithStats.second; + IntervalStatistics.Interval lastInterval; + try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) { + assertEquals(trackPointIterator.getCount(), numberOfPoints); + IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000)); + intervalStatistics.addTrackPoints(trackPointIterator); + lastInterval = intervalStatistics.getIntervalList().get(intervalStatistics.getIntervalList().size() - 1); } - if (hours > 0 || minutes > 0 || showFromMinutes) { - res += " " + seconds + " " + sUnit; - } else { - res += seconds + " " + sUnit; - } - return res; + + SensorStatistics sensorStatistics = new SensorStatistics(HeartRate.of(180f), HeartRate.of(180f), null, null, null); + + // when + String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, lastInterval, sensorStatistics).toString(); + + // then + assertEquals("Total distance 14.21 kilometers. 16 minutes 39 seconds. Speed 51.2 kilometers per hour. Lap speed 51.2 kilometers per hour. Average heart rate 180 bpm. Current heart rate 133 bpm.", announcement); } -} \ No newline at end of file +} diff --git a/src/androidTest/java/de/dennisguse/opentracks/stats/TrackStatisticsTest.java b/src/androidTest/java/de/dennisguse/opentracks/stats/TrackStatisticsTest.java index 7688f107d..ba7df79e7 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/stats/TrackStatisticsTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/stats/TrackStatisticsTest.java @@ -28,6 +28,7 @@ import java.time.Duration; import java.time.Instant; import de.dennisguse.opentracks.data.models.Distance; +import de.dennisguse.opentracks.data.models.HeartRate; import de.dennisguse.opentracks.data.models.Speed; /** @@ -67,6 +68,7 @@ public class TrackStatisticsTest { assertEquals(0.0, statistics.getMaxSpeed().toMPS(), 0.0); assertEquals(0.0, statistics.getAverageSpeed().toMPS(), 0.0); assertEquals(0.0, statistics.getAverageMovingSpeed().toMPS(), 0.0); + assertNull(statistics.getAverageHeartRate()); } @Test @@ -91,6 +93,8 @@ public class TrackStatisticsTest { statistics.setMinAltitude(1200.0); // Resulting min altitude statistics2.setMaxAltitude(3575.0); // Resulting max altitude statistics2.setMinAltitude(2800.0); + statistics.setAverageHeartRate(HeartRate.of(100f)); + statistics2.setAverageHeartRate(HeartRate.of(200f)); // when statistics.merge(statistics2); @@ -105,6 +109,7 @@ public class TrackStatisticsTest { assertEquals(Speed.of(statistics.getTotalDistance(), statistics.getMovingTime()).toMPS(), statistics.getMaxSpeed().toMPS(), 0.001); assertEquals(1200.0, statistics.getMinAltitude(), 0.001); assertEquals(3575.0, statistics.getMaxAltitude(), 0.001); + assertEquals(150.0, statistics.getAverageHeartRate().getBPM(), 0.001); } @Test diff --git a/src/androidTest/java/de/dennisguse/opentracks/stats/TrackStatisticsUpdaterTest.java b/src/androidTest/java/de/dennisguse/opentracks/stats/TrackStatisticsUpdaterTest.java index 5c05e6284..2c033722a 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/stats/TrackStatisticsUpdaterTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/stats/TrackStatisticsUpdaterTest.java @@ -40,6 +40,7 @@ public class TrackStatisticsUpdaterTest { assertNull(statistics.getTotalAltitudeGain()); assertNull(statistics.getTotalAltitudeLoss()); + assertNull(statistics.getAverageHeartRate()); } @Test @@ -66,6 +67,7 @@ public class TrackStatisticsUpdaterTest { assertNull(statistics.getTotalAltitudeGain()); assertNull(statistics.getTotalAltitudeLoss()); + assertNull(statistics.getAverageHeartRate()); } @Test @@ -91,6 +93,7 @@ public class TrackStatisticsUpdaterTest { assertEquals(14.226, statistics.getMaxSpeed().toMPS(), 0.01); assertEquals(14.226, statistics.getAverageMovingSpeed().toMPS(), 0.01); assertEquals(9.84, statistics.getAverageSpeed().toMPS(), 0.01); + assertEquals(106.85f, statistics.getAverageHeartRate().getBPM(), 0.01); } @Test diff --git a/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncement.java b/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncement.java index 7240cebcf..5338200b2 100644 --- a/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncement.java +++ b/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncement.java @@ -21,6 +21,7 @@ import android.media.AudioManager; import android.media.MediaPlayer; import android.speech.tts.TextToSpeech; import android.speech.tts.UtteranceProgressListener; +import android.text.Spannable; import android.util.Log; import androidx.annotation.NonNull; @@ -34,6 +35,7 @@ import de.dennisguse.opentracks.data.TrackPointIterator; import de.dennisguse.opentracks.data.models.Distance; import de.dennisguse.opentracks.data.models.Track; import de.dennisguse.opentracks.settings.PreferencesUtils; +import de.dennisguse.opentracks.stats.SensorStatistics; import de.dennisguse.opentracks.stats.TrackStatistics; import de.dennisguse.opentracks.ui.intervals.IntervalStatistics; @@ -171,8 +173,12 @@ public class VoiceAnnouncement { IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.one(isMetricUnits)); intervalStatistics.addTrackPoints(trackPointIterator); IntervalStatistics.Interval lastInterval = intervalStatistics.getLastInterval(); + SensorStatistics sensorStatistics = null; + if (track.getId() != null) { + sensorStatistics = contentProviderUtils.getSensorStats(track.getId()); + } - String announcement = VoiceAnnouncementUtils.getAnnouncement(context, track.getTrackStatistics(), isMetricUnits, isReportSpeed, lastInterval); + Spannable announcement = VoiceAnnouncementUtils.getAnnouncement(context, track.getTrackStatistics(), isMetricUnits, isReportSpeed, lastInterval, sensorStatistics); // We don't care about the utterance id. It is supplied here to force onUtteranceCompleted to be called. tts.speak(announcement, TextToSpeech.QUEUE_FLUSH, null, "not used"); diff --git a/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtils.java b/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtils.java index d03ee1166..8c0666c96 100644 --- a/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtils.java +++ b/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtils.java @@ -1,7 +1,14 @@ package de.dennisguse.opentracks.services.announcement; -import android.content.Context; +import static android.text.Spanned.SPAN_INCLUSIVE_EXCLUSIVE; +import static de.dennisguse.opentracks.settings.PreferencesUtils.shouldVoiceAnnounceHeartRate; +import android.content.Context; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.style.TtsSpan; + +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.time.Duration; @@ -9,6 +16,7 @@ import java.time.Duration; import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.data.models.Distance; import de.dennisguse.opentracks.data.models.Speed; +import de.dennisguse.opentracks.stats.SensorStatistics; import de.dennisguse.opentracks.stats.TrackStatistics; import de.dennisguse.opentracks.ui.intervals.IntervalStatistics; @@ -17,68 +25,149 @@ class VoiceAnnouncementUtils { private VoiceAnnouncementUtils() { } - static String getAnnouncement(Context context, TrackStatistics trackStatistics, boolean isMetricUnits, boolean isReportSpeed, @Nullable IntervalStatistics.Interval currentInterval) { + static Spannable getAnnouncement(Context context, TrackStatistics trackStatistics, boolean isMetricUnits, boolean isReportSpeed, @Nullable IntervalStatistics.Interval currentInterval, @Nullable SensorStatistics sensorStatistics) { + SpannableStringBuilder builder = new SpannableStringBuilder(); Distance distance = trackStatistics.getTotalDistance(); Speed distancePerTime = trackStatistics.getAverageMovingSpeed(); Speed currentDistancePerTime = currentInterval != null ? currentInterval.getSpeed() : null; + int perUnitStringId = isMetricUnits ? R.string.voice_per_kilometer : R.string.voice_per_mile; - int totalDistanceId = isMetricUnits ? R.plurals.voiceTotalDistanceKilometers : R.plurals.voiceTotalDistanceMiles; double distanceInUnit = distance.toKM_Miles(isMetricUnits); - String totalDistance = context.getResources().getQuantityString(totalDistanceId, getQuantityCount(distanceInUnit), distanceInUnit); + int distanceId = isMetricUnits ? R.plurals.voiceDistanceKilometers : R.plurals.voiceDistanceMiles; + + builder.append(context.getString(R.string.total_distance)); + long distanceIntegerPart = (long) distanceInUnit; + // Extract the decimal part + String distanceFractionalPart = String.format("%.2f", (distanceInUnit - distanceIntegerPart)).substring(2); + // Units should always be english singular for TTS. + // See https://developer.android.com/reference/android/text/style/TtsSpan?hl=en#TYPE_MEASURE + appendDecimalUnit(builder, context.getResources().getQuantityString(distanceId, getQuantityCount(distanceInUnit), distanceInUnit), distanceIntegerPart, distanceFractionalPart, isMetricUnits ? "kilometer" : "mile"); + // Punctuation helps introduce natural pauses in TTS + builder.append("."); if (distance.isZero()) { - return totalDistance; + return builder; + } + + // Announce time + Duration movingTime = trackStatistics.getMovingTime(); + if (!movingTime.isZero()) { + appendDuration(context, builder, movingTime); + builder.append("."); } - String rate; - String currentRate; - String currentRateMsg; if (isReportSpeed) { int speedId = isMetricUnits ? R.plurals.voiceSpeedKilometersPerHour : R.plurals.voiceSpeedMilesPerHour; double speedInUnit = distancePerTime.to(isMetricUnits); - rate = context.getResources().getQuantityString(speedId, getQuantityCount(speedInUnit), speedInUnit); - double currentDistancePerTimeInUnit = currentDistancePerTime != null ? currentDistancePerTime.to(isMetricUnits) : 0; - currentRate = context.getResources().getQuantityString(speedId, getQuantityCount(currentDistancePerTimeInUnit), currentDistancePerTimeInUnit); - currentRateMsg = context.getString(R.string.voice_speed_lap, currentRate); + builder.append(" ") + .append(context.getString(R.string.speed)); + long speedIntegerPart = (long) speedInUnit; + // Extract the decimal part + String speedFractionalPart = String.format("%.1f", (speedInUnit - speedIntegerPart)).substring(2); + appendDecimalUnit(builder, context.getResources().getQuantityString(speedId, getQuantityCount(speedInUnit), speedInUnit), speedIntegerPart, speedFractionalPart, isMetricUnits ? "kilometer per hour" : "mile per hour"); + builder.append("."); + + if (currentDistancePerTime != null) { + double currentDistancePerTimeInUnit = currentDistancePerTime.to(isMetricUnits); + + if (currentDistancePerTimeInUnit > 0) { + + builder.append(" ") + .append(context.getString(R.string.lap_speed)); + long currentDistanceIntegerPart = (long) currentDistancePerTimeInUnit; + // Extract the decimal part + String currentDistanceFractionalPart = String.format("%.1f", (currentDistancePerTimeInUnit - currentDistanceIntegerPart)).substring(2); + appendDecimalUnit(builder, context.getResources().getQuantityString(speedId, getQuantityCount(currentDistancePerTimeInUnit), currentDistancePerTimeInUnit), currentDistanceIntegerPart, currentDistanceFractionalPart, isMetricUnits ? "kilometer per hour" : "mile per hour"); + builder.append("."); + } + } } else { Duration time = distancePerTime.toPace(isMetricUnits); - - int paceId = isMetricUnits ? R.string.voice_pace_per_kilometer : R.string.voice_pace_per_mile; - rate = context.getString(paceId, getAnnounceTime(context, time)); + builder.append(" ") + .append(context.getString(R.string.pace)); + appendDuration(context, builder, time); + builder.append(" ") + .append(context.getString(perUnitStringId)) + .append("."); Duration currentTime = currentDistancePerTime != null ? currentDistancePerTime.toPace(isMetricUnits) : Duration.ofMillis(0); - currentRate = context.getString(paceId, getAnnounceTime(context, currentTime)); - currentRateMsg = context.getString(R.string.voice_pace_lap, currentRate); + if (!currentTime.isZero()) { + builder.append(" ") + .append(context.getString(R.string.lap_time)); + appendDuration(context, builder, currentTime); + builder.append(" ") + .append(context.getString(perUnitStringId)) + .append("."); + } } - currentRateMsg = currentInterval == null ? "" : " " + currentRateMsg; + if (shouldVoiceAnnounceHeartRate()) { + if (sensorStatistics != null && sensorStatistics.hasHeartRate()) { + int averageHeartRate = Math.round(sensorStatistics.getAvgHeartRate().getBPM()); - return context.getString(R.string.voice_template, totalDistance, getAnnounceTime(context, trackStatistics.getMovingTime()), rate) + currentRateMsg; - } + builder.append(" ") + .append(context.getString(R.string.average_heart_rate)); + appendCardinal(builder, context.getString(R.string.sensor_state_heart_rate_value, averageHeartRate), averageHeartRate); + builder.append("."); + } - //TODO We might need to localize this using strings.xml if order is relevant. - private static String getAnnounceTime(Context context, Duration duration) { - String result = ""; + if (currentInterval != null && currentInterval.hasAverageHeartRate()) { + int currentHeartRate = Math.round(currentInterval.getAverageHeartRate().getBPM()); - int hours = (int) (duration.toHours()); - int minutes = (int) (duration.toMinutes() % 60); - int seconds = (int) (duration.getSeconds() % 60); - - if (hours != 0) { - String hoursText = context.getResources() - .getQuantityString(R.plurals.voiceHours, hours, hours); - result += hoursText + " "; + builder.append(" ") + .append(context.getString(R.string.current_heart_rate)); + appendCardinal(builder, context.getString(R.string.sensor_state_heart_rate_value, currentHeartRate), currentHeartRate); + builder.append("."); + } } - String minutesText = context.getResources() - .getQuantityString(R.plurals.voiceMinutes, minutes, minutes); - String secondsText = context.getResources() - .getQuantityString(R.plurals.voiceSeconds, seconds, seconds); - return result + minutesText + " " + secondsText; + return builder; } static int getQuantityCount(double d) { return (int) d; } + + private static void appendDuration(@NonNull Context context, @NonNull SpannableStringBuilder builder, @NonNull Duration duration) { + int hours = (int) (duration.toHours()); + int minutes = (int) (duration.toMinutes() % 60); + int seconds = (int) (duration.getSeconds() % 60); + + if (hours > 0) { + appendDecimalUnit(builder, context.getResources().getQuantityString(R.plurals.voiceHours, hours, hours), hours, null, "hour"); + } + if (minutes > 0) { + appendDecimalUnit(builder, context.getResources().getQuantityString(R.plurals.voiceMinutes, minutes, minutes), minutes, null, "minute"); + } + if (seconds > 0 || duration.isZero()) { + appendDecimalUnit(builder, context.getResources().getQuantityString(R.plurals.voiceSeconds, seconds, seconds), seconds, null, "second"); + } + } + + /** + * Speaks as: 98.14 [UNIT] - ninety eight point one four [UNIT with correct plural form] + */ + private static void appendDecimalUnit(@NonNull SpannableStringBuilder builder, @NonNull String localizedText, long integerPart, @Nullable String fractionalPart, @NonNull String unit) { + TtsSpan.MeasureBuilder measureBuilder = new TtsSpan.MeasureBuilder() + .setUnit(unit); + + if (fractionalPart == null) { + measureBuilder.setNumber(integerPart); + } else { + measureBuilder.setIntegerPart(integerPart) + .setFractionalPart(fractionalPart); + } + + builder.append(" ") + .append(localizedText, measureBuilder.build(), SPAN_INCLUSIVE_EXCLUSIVE); + } + + /** + * Speaks as: 98 - ninety eight + */ + private static void appendCardinal(@NonNull SpannableStringBuilder builder, @NonNull String localizedText, long number) { + builder.append(" ") + .append(localizedText, new TtsSpan.CardinalBuilder().setNumber(number).build(), SPAN_INCLUSIVE_EXCLUSIVE); + } } diff --git a/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java b/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java index 5a73e7cdb..ff9c9751a 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java +++ b/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java @@ -172,6 +172,13 @@ public class PreferencesUtils { setString(keyId, resources.getString(valueId)); } + @VisibleForTesting + public static void setBoolean(int keyId, Boolean value) { + Editor editor = sharedPreferences.edit(); + editor.putBoolean(getKey(keyId), value); + editor.apply(); + } + static void setInt(int keyId, int value) { Editor editor = sharedPreferences.edit(); editor.putInt(getKey(keyId), value); @@ -316,6 +323,15 @@ public class PreferencesUtils { return getFloat(R.string.voice_speed_rate_key, DEFAULT); } + public static boolean shouldVoiceAnnounceHeartRate() { + return getBoolean(R.string.voice_announce_heart_rate_key, false); + } + + @VisibleForTesting + public static void setVoiceAnnounceHeartRate(boolean value) { + setBoolean(R.string.voice_announce_heart_rate_key, value); + } + public static Distance getRecordingDistanceInterval() { return Distance.of(getInt(R.string.recording_distance_interval_key, getRecordingDistanceIntervalDefaultInternal())); } diff --git a/src/main/java/de/dennisguse/opentracks/stats/TrackStatistics.java b/src/main/java/de/dennisguse/opentracks/stats/TrackStatistics.java index cdb7b9ae1..daa81800e 100644 --- a/src/main/java/de/dennisguse/opentracks/stats/TrackStatistics.java +++ b/src/main/java/de/dennisguse/opentracks/stats/TrackStatistics.java @@ -25,6 +25,7 @@ import java.time.Instant; import de.dennisguse.opentracks.data.models.Altitude; import de.dennisguse.opentracks.data.models.Distance; +import de.dennisguse.opentracks.data.models.HeartRate; import de.dennisguse.opentracks.data.models.Speed; import de.dennisguse.opentracks.data.models.Track; import de.dennisguse.opentracks.data.models.TrackPoint; @@ -56,6 +57,8 @@ public class TrackStatistics { private Speed maxSpeed; private Float totalAltitudeGain_m = null; private Float totalAltitudeLoss_m = null; + // The average heart rate seen on this track + private HeartRate avgHeartRate = null; public TrackStatistics() { reset(); @@ -76,6 +79,7 @@ public class TrackStatistics { altitudeExtremities.set(other.altitudeExtremities.getMin(), other.altitudeExtremities.getMax()); totalAltitudeGain_m = other.totalAltitudeGain_m; totalAltitudeLoss_m = other.totalAltitudeLoss_m; + avgHeartRate = other.avgHeartRate; } @VisibleForTesting @@ -108,6 +112,19 @@ public class TrackStatistics { stopTime = stopTime.isAfter(other.stopTime) ? stopTime : other.stopTime; } + if (avgHeartRate == null) { + avgHeartRate = other.avgHeartRate; + } else { + if (other.avgHeartRate != null) { + // Using total time as weights for the averaging. + // Important to do this before total time is updated + avgHeartRate = HeartRate.of( + (totalTime.getSeconds() * avgHeartRate.getBPM() + other.totalTime.getSeconds() * other.avgHeartRate.getBPM()) + / (totalTime.getSeconds() + other.totalTime.getSeconds()) + ); + } + } + totalDistance = totalDistance.plus(other.totalDistance); totalTime = totalTime.plus(other.totalTime); movingTime = movingTime.plus(other.movingTime); @@ -230,6 +247,15 @@ public class TrackStatistics { return totalTime.minus(movingTime); } + public boolean hasAverageHeartRate() { + return avgHeartRate != null; + } + + @Nullable + public HeartRate getAverageHeartRate() { + return avgHeartRate; + } + /** * Gets the average speed. * This calculation only takes into account the displacement until the last point that was accounted for in statistics. @@ -287,6 +313,12 @@ public class TrackStatistics { } } + public void setAverageHeartRate(HeartRate heartRate) { + if (heartRate != null) { + avgHeartRate = heartRate; + } + } + public boolean hasTotalAltitudeGain() { return totalAltitudeGain_m != null; } diff --git a/src/main/java/de/dennisguse/opentracks/stats/TrackStatisticsUpdater.java b/src/main/java/de/dennisguse/opentracks/stats/TrackStatisticsUpdater.java index 0556c73df..72aa52b6d 100644 --- a/src/main/java/de/dennisguse/opentracks/stats/TrackStatisticsUpdater.java +++ b/src/main/java/de/dennisguse/opentracks/stats/TrackStatisticsUpdater.java @@ -26,6 +26,7 @@ import java.util.List; import de.dennisguse.opentracks.data.models.Altitude; import de.dennisguse.opentracks.data.models.Distance; +import de.dennisguse.opentracks.data.models.HeartRate; import de.dennisguse.opentracks.data.models.Speed; import de.dennisguse.opentracks.data.models.TrackPoint; @@ -64,6 +65,8 @@ public class TrackStatisticsUpdater { private final AltitudeRingBuffer altitudeBuffer; private final SpeedRingBuffer speedBuffer; + private float averageHeartRateBPM; + private Duration totalHeartRateDuration = Duration.ZERO; // The current segment's statistics private final TrackStatistics currentSegment; @@ -85,6 +88,7 @@ public class TrackStatisticsUpdater { altitudeBuffer = new AltitudeRingBuffer(ALTITUDE_SMOOTHING_FACTOR); speedBuffer = new SpeedRingBuffer(SPEED_SMOOTHING_FACTOR); + resetAverageHeartRate(); } public TrackStatisticsUpdater(TrackStatisticsUpdater toCopy) { @@ -95,6 +99,7 @@ public class TrackStatisticsUpdater { this.speedBuffer = new SpeedRingBuffer(toCopy.speedBuffer); this.lastTrackPoint = toCopy.lastTrackPoint; + resetAverageHeartRate(); } public TrackStatistics getTrackStatistics() { @@ -142,6 +147,17 @@ public class TrackStatisticsUpdater { currentSegment.updateAltitudeExtremities(newAverage); } + // Update heart rate + if (trackPoint.hasHeartRate() && lastTrackPoint != null) { + Duration trackPointDuration = Duration.between(lastTrackPoint.getTime(), trackPoint.getTime()); + Duration newTotalDuration = totalHeartRateDuration.plus(trackPointDuration); + + averageHeartRateBPM = (totalHeartRateDuration.toMillis() * averageHeartRateBPM + trackPointDuration.toMillis() * trackPoint.getHeartRate().getBPM()) / newTotalDuration.toMillis(); + totalHeartRateDuration = newTotalDuration; + + currentSegment.setAverageHeartRate(HeartRate.of(averageHeartRateBPM)); + } + // Update total distance if (trackPoint.hasSensorDistance()) { // Sensor-based distance/speed @@ -184,6 +200,12 @@ public class TrackStatisticsUpdater { lastTrackPoint = null; altitudeBuffer.reset(); speedBuffer.reset(); + resetAverageHeartRate(); + } + + private void resetAverageHeartRate() { + averageHeartRateBPM = 0.0f; + totalHeartRateDuration = Duration.ZERO; } /** diff --git a/src/main/java/de/dennisguse/opentracks/ui/intervals/IntervalStatistics.java b/src/main/java/de/dennisguse/opentracks/ui/intervals/IntervalStatistics.java index da564635d..5a3199354 100644 --- a/src/main/java/de/dennisguse/opentracks/ui/intervals/IntervalStatistics.java +++ b/src/main/java/de/dennisguse/opentracks/ui/intervals/IntervalStatistics.java @@ -8,6 +8,7 @@ import java.util.List; import de.dennisguse.opentracks.data.TrackPointIterator; import de.dennisguse.opentracks.data.models.Distance; +import de.dennisguse.opentracks.data.models.HeartRate; import de.dennisguse.opentracks.data.models.Speed; import de.dennisguse.opentracks.data.models.TrackPoint; import de.dennisguse.opentracks.stats.TrackStatistics; @@ -102,6 +103,7 @@ public class IntervalStatistics { private Duration time = Duration.ofSeconds(0); private Float gain_m; private Float loss_m; + private HeartRate avgHeartRate; public Interval() { } @@ -117,6 +119,7 @@ public class IntervalStatistics { time = i.time; gain_m = i.gain_m; loss_m = i.loss_m; + avgHeartRate = i.avgHeartRate; } public Interval(Interval i) { @@ -124,6 +127,7 @@ public class IntervalStatistics { time = i.time; gain_m = i.gain_m; loss_m = i.loss_m; + avgHeartRate = i.avgHeartRate; } private void adjust(double adjustFactor) { @@ -155,11 +159,20 @@ public class IntervalStatistics { return loss_m; } + public boolean hasAverageHeartRate() { + return avgHeartRate != null; + } + + public HeartRate getAverageHeartRate() { + return avgHeartRate; + } + private void add(TrackStatistics trackStatistics, @Nullable TrackPoint lastTrackPoint) { distance = distance.plus(trackStatistics.getTotalDistance()); time = time.plus(trackStatistics.getTotalTime()); gain_m = trackStatistics.hasTotalAltitudeGain() ? trackStatistics.getTotalAltitudeGain() : gain_m; loss_m = trackStatistics.hasTotalAltitudeLoss() ? trackStatistics.getTotalAltitudeLoss() : loss_m; + avgHeartRate = trackStatistics.getAverageHeartRate(); if (lastTrackPoint == null) { return; } @@ -176,6 +189,7 @@ public class IntervalStatistics { time = trackStatistics.getTotalTime(); gain_m = trackStatistics.hasTotalAltitudeGain() ? trackStatistics.getTotalAltitudeGain() : gain_m; loss_m = trackStatistics.hasTotalAltitudeLoss() ? trackStatistics.getTotalAltitudeLoss() : loss_m; + avgHeartRate = trackStatistics.getAverageHeartRate(); } } } diff --git a/src/main/res/values/settings.xml b/src/main/res/values/settings.xml index 272d28382..09efc7cc3 100644 --- a/src/main/res/values/settings.xml +++ b/src/main/res/values/settings.xml @@ -283,6 +283,7 @@ 1.2 1.3 + voiceAnnounceHeartRate exportTrackFileFormat diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 15432b622..3e15cecd7 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -405,6 +405,7 @@ limitations under the License. Announcements Time interval Distance interval + Report heart rate Reset @@ -574,6 +575,13 @@ limitations under the License. %1$s in %2$s at %3$s Lap speed of %1$s Lap time of %1$s + per kilometer + per mile + Lap time + Pace + Lap speed + Speed + Total distance total distance 1 kilometer total distance %1$.2f kilometers @@ -582,6 +590,16 @@ limitations under the License. total distance 1 mile total distance %1$.2f miles + + 1 kilometer + %1$.2f kilometers + + + 1 mile + %1$.2f miles + + Average heart rate + Current heart rate ATM bank diff --git a/src/main/res/xml/settings_announcements.xml b/src/main/res/xml/settings_announcements.xml index dbfd44c7a..b8789dbdb 100644 --- a/src/main/res/xml/settings_announcements.xml +++ b/src/main/res/xml/settings_announcements.xml @@ -26,4 +26,9 @@ android:title="@string/menu_voice_rate" app:useSimpleSummaryProvider="true" /> + + \ No newline at end of file