Compare commits

...

8 Commits

Author SHA1 Message Date
2527553bff Update button colors
All checks were successful
Build / build (push) Successful in 4m46s
2025-05-29 15:14:49 +02:00
8fe7a8996b Only use profile selection dialog to input new profiles
All checks were successful
Build / build (push) Successful in 4m36s
2025-05-29 15:03:52 +02:00
50556dec31 Update button styling
All checks were successful
Build / build (push) Successful in 4m43s
2025-05-29 14:09:24 +02:00
a08e4a9355 Add readme info
All checks were successful
Build / build (push) Successful in 5m17s
2025-05-29 13:28:08 +02:00
e9f6e2c7a3 Fix BASE_URL
Some checks failed
Build / build (push) Has been cancelled
2025-05-29 13:24:42 +02:00
373c5be7fc Enable pipeline for all branches
Some checks failed
Build / build (push) Has been cancelled
2025-05-29 13:21:26 +02:00
8a055b7fcb Update changelog 2025-05-29 13:20:01 +02:00
bf500596fb Add profile selection dialog 2025-05-29 13:15:24 +02:00
9 changed files with 173 additions and 46 deletions

View File

@ -3,10 +3,10 @@ name: Build
on:
workflow_dispatch:
push:
branches: [ "master" ]
branches: [ "**" ]
tags: [ "*" ]
pull_request:
branches: [ "master" ]
branches: [ "**" ]
jobs:
build:
@ -26,7 +26,7 @@ jobs:
echo "KEYSTORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }}" >> $GITHUB_ENV
echo "KEYSTORE_BASE64=${{ secrets.KEYSTORE_BASE64 }}" >> $GITHUB_ENV
echo "BUILD_NUMBER=${{ github.run_number }}" >> $GITHUB_ENV
echo "BASE_URL=${{ secrets.BASE_URL || '"https://github.com/timklge/karoo-powerbar/releases/latest/download"' }}" >> $GITHUB_ENV
echo "BASE_URL=${{ secrets.BASE_URL || 'https://github.com/timklge/karoo-reminder/releases/latest/download' }}" >> $GITHUB_ENV
- uses: actions/checkout@v4
- name: set up JDK 17
uses: actions/setup-java@v4
@ -61,3 +61,4 @@ jobs:
list.png
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -6,6 +6,8 @@
Karoo extension that displays in-ride alerts based on custom triggers. Reminders can be set to activate after a specific time interval, distance traveled, energy output or when a sensor value is outside a defined range (e.g., heart rate exceeds zone 2).
By default, created reminders are active for all ride profiles. If you want to limit reminders to specific ride profiles (e. g. your "Gravel" profile), you can do so in the reminder settings.
Compatible with Karoo 2 and Karoo 3 devices.
![Reminder List](list.png)

View File

@ -73,7 +73,7 @@ tasks.register("generateManifest") {
"latestVersionCode" to android.defaultConfig.versionCode,
"developer" to "github.com/timklge",
"description" to "Open-source extension that shows in-ride alerts after a given time interval has passed, distance has been traveled or HR / power / speed / cadence range is exceeded",
"releaseNotes" to "* Add rolling average setting for power triggers",
"releaseNotes" to "* Add profile selection dialog per reminder\n* Add rolling average setting for power triggers",
"screenshotUrls" to listOf(
"$baseUrl/reminder.png",
"$baseUrl/list.png",

View File

@ -7,6 +7,7 @@ import de.timklge.karooreminder.screens.Reminder
import de.timklge.karooreminder.screens.defaultReminders
import de.timklge.karooreminder.screens.preferencesKey
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.ActiveRideProfile
import io.hammerhead.karooext.models.OnStreamState
import io.hammerhead.karooext.models.RideState
import io.hammerhead.karooext.models.StreamState
@ -40,6 +41,17 @@ fun KarooSystemService.streamRideState(): Flow<RideState> {
}
}
fun KarooSystemService.streamActiveRideProfile(): Flow<ActiveRideProfile> {
return callbackFlow {
val listenerId = addConsumer { activeProfile: ActiveRideProfile ->
trySendBlocking(activeProfile)
}
awaitClose {
removeConsumer(listenerId)
}
}
}
fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
return callbackFlow {
val listenerId = addConsumer { userProfile: UserProfile ->

View File

@ -5,8 +5,10 @@ import android.media.MediaPlayer
import android.util.Log
import de.timklge.karooreminder.screens.Reminder
import de.timklge.karooreminder.screens.ReminderBeepPattern
import de.timklge.karooreminder.screens.reminderIsActive
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.KarooExtension
import io.hammerhead.karooext.models.ActiveRideProfile
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.HardwareType
import io.hammerhead.karooext.models.InRideAlert
@ -231,13 +233,19 @@ class KarooReminderExtension : KarooExtension("karoo-reminder", BuildConfig.VERS
val triggerJobs = mutableSetOf<Job>()
data class StreamData(val preferences: MutableList<Reminder>, val activeProfile: ActiveRideProfile)
val streamDataFlow = combine(streamPreferences(), karooSystem.streamActiveRideProfile()) { reminders, activeProfile ->
StreamData(preferences = reminders, activeProfile = activeProfile)
}
triggerStreamJob = CoroutineScope(Dispatchers.IO).launch {
streamPreferences().collect { reminders ->
streamDataFlow.collect { (reminders, activeRideProfile) ->
triggerJobs.forEach { it.cancel() }
triggerJobs.clear()
if (reminders.any { it.trigger == ReminderTrigger.DISTANCE }){
val distanceJob = startIntervalJob(ReminderTrigger.DISTANCE) {
val distanceJob = startIntervalJob(reminders, activeRideProfile, ReminderTrigger.DISTANCE) {
karooSystem.streamDataFlow(DataType.Type.DISTANCE)
.mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.combine(karooSystem.streamUserProfile()) { distance, profile -> distance to profile }
@ -255,7 +263,7 @@ class KarooReminderExtension : KarooExtension("karoo-reminder", BuildConfig.VERS
}
if (reminders.any { it.trigger == ReminderTrigger.ELAPSED_TIME }){
val elapsedTimeJob = startIntervalJob(ReminderTrigger.ELAPSED_TIME) {
val elapsedTimeJob = startIntervalJob(reminders, activeRideProfile, ReminderTrigger.ELAPSED_TIME) {
karooSystem.streamDataFlow(DataType.Type.ELAPSED_TIME)
.mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.map { (it / 1000 / 60).toInt() }
@ -266,7 +274,7 @@ class KarooReminderExtension : KarooExtension("karoo-reminder", BuildConfig.VERS
}
if (reminders.any { it.trigger == ReminderTrigger.ENERGY_OUTPUT }){
val energyOutputJob = startIntervalJob(ReminderTrigger.ENERGY_OUTPUT) {
val energyOutputJob = startIntervalJob(reminders, activeRideProfile, ReminderTrigger.ENERGY_OUTPUT) {
karooSystem.streamDataFlow(DataType.Type.ENERGY_OUTPUT)
.mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.map { it.toInt() }
@ -301,7 +309,7 @@ class KarooReminderExtension : KarooExtension("karoo-reminder", BuildConfig.VERS
intervalTriggers.forEach { trigger ->
SmoothSetting.entries.forEach { smoothSetting ->
if (reminders.any { it.trigger == trigger && it.smoothSetting == smoothSetting }){
val job = startRangeExceededJob(trigger, smoothSetting)
val job = startRangeExceededJob(reminders, activeRideProfile, trigger, smoothSetting)
triggerJobs.add(job)
}
}
@ -310,41 +318,43 @@ class KarooReminderExtension : KarooExtension("karoo-reminder", BuildConfig.VERS
}
}
private fun startIntervalJob(trigger: ReminderTrigger, flow: () -> Flow<Int>): Job {
private fun startIntervalJob(preferences: List<Reminder>, activeRideProfile: ActiveRideProfile, trigger: ReminderTrigger, flow: () -> Flow<Int>): Job {
return CoroutineScope(Dispatchers.IO).launch {
val preferences = streamPreferences()
flow()
.filterNot { it == 0 }
.combine(preferences) { elapsedMinutes, reminders -> elapsedMinutes to reminders}
.distinctUntilChanged { old, new -> old.first == new.first }
.collectLatest { (elapsedMinutes, reminders) ->
val rs = reminders
.distinctUntilChanged()
.collectLatest { elapsedMinutes ->
val rs = preferences
.filter { reminder ->
val interval = reminder.interval
reminder.trigger == trigger && reminder.isActive && interval != null && elapsedMinutes % interval == 0
reminder.trigger == trigger && reminderIsActive(reminder, activeRideProfile.profile) && interval != null && elapsedMinutes % interval == 0
}
for (reminder in rs){
for (reminder in rs) {
Log.i(TAG, "$trigger reminder: ${reminder.name}")
reminderChannel.send(DisplayedReminder(reminder.tone, trigger, InRideAlert(
id = "reminder-${reminder.id}-${elapsedMinutes}",
detail = reminder.text,
title = reminder.name,
autoDismissMs = if(reminder.isAutoDismiss) reminder.autoDismissSeconds * 1000L else null,
icon = R.drawable.timer,
textColor = reminder.displayForegroundColor?.getTextColor() ?: R.color.black,
backgroundColor = reminder.displayForegroundColor?.colorRes ?: R.color.hRed
)))
reminderChannel.send(
DisplayedReminder(
reminder.tone, trigger, InRideAlert(
id = "reminder-${reminder.id}-${elapsedMinutes}",
detail = reminder.text,
title = reminder.name,
autoDismissMs = if (reminder.isAutoDismiss) reminder.autoDismissSeconds * 1000L else null,
icon = R.drawable.timer,
textColor = reminder.displayForegroundColor?.getTextColor()
?: R.color.black,
backgroundColor = reminder.displayForegroundColor?.colorRes
?: R.color.hRed
)
)
)
}
}
}
}
private fun startRangeExceededJob(triggerType: ReminderTrigger, smoothSetting: SmoothSetting): Job {
private fun startRangeExceededJob(preferences: MutableList<Reminder>, activeRideProfile: ActiveRideProfile, triggerType: ReminderTrigger, smoothSetting: SmoothSetting): Job {
return CoroutineScope(Dispatchers.IO).launch {
val preferences = streamPreferences()
Log.i(TAG, "Starting range exceeded job for trigger $triggerType with smooth setting $smoothSetting")
val valueStream = karooSystem.streamDataFlow(triggerType.getSmoothedDataType(smoothSetting))
@ -370,9 +380,9 @@ class KarooReminderExtension : KarooExtension("karoo-reminder", BuildConfig.VERS
data class StreamData(val value: Double, val reminders: MutableList<Reminder>, val distanceImperial: Boolean, val temperatureImperial: Boolean, val rideState: RideState)
combine(valueStream, preferences, karooSystem.streamUserProfile(), karooSystem.streamRideState()) { value, reminders, profile, rideState ->
combine(valueStream, karooSystem.streamUserProfile(), karooSystem.streamRideState()) { value, profile, rideState ->
StreamData(distanceImperial = profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL, temperatureImperial = profile.preferredUnit.temperature == UserProfile.PreferredUnit.UnitType.IMPERIAL,
value = value, reminders = reminders, rideState = rideState)
value = value, reminders = preferences, rideState = rideState)
}.filter {
it.rideState is RideState.Recording
}.let {
@ -432,7 +442,7 @@ class KarooReminderExtension : KarooExtension("karoo-reminder", BuildConfig.VERS
ReminderTrigger.ELAPSED_TIME, ReminderTrigger.DISTANCE, ReminderTrigger.ENERGY_OUTPUT -> error("Unsupported trigger type: $triggerType")
}
reminder.isActive && reminder.trigger == triggerType && triggerIsMet
reminderIsActive(reminder, activeRideProfile.profile) && reminder.trigger == triggerType && triggerIsMet
}
triggered

View File

@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
@ -25,6 +26,7 @@ import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
@ -103,6 +105,8 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
var selectedTone by remember { mutableStateOf(reminder.tone) }
var autoDismissSeconds by remember { mutableStateOf(reminder.autoDismissSeconds.toString()) }
var selectedTrigger by remember { mutableStateOf(reminder.trigger) }
var rideProfileDialogVisible by remember { mutableStateOf(false) }
var enabledRideProfiles by remember { mutableStateOf(reminder.enabledRideProfiles.toMutableSet()) }
val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null)
@ -116,7 +120,8 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
isActive = isActive,
smoothSetting = smoothSetting,
trigger = selectedTrigger,
isAutoDismiss = autoDismiss, tone = selectedTone, autoDismissSeconds = autoDismissSeconds.toIntOrNull() ?: 15)
isAutoDismiss = autoDismiss, tone = selectedTone, autoDismissSeconds = autoDismissSeconds.toIntOrNull() ?: 15,
enabledRideProfiles = enabledRideProfiles.toSet())
}
Column(modifier = Modifier
@ -259,6 +264,41 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
)
}
Column(modifier = Modifier.fillMaxWidth()) {
if (enabledRideProfiles.isEmpty()) {
Text("Enabled for all profiles")
} else {
Text("Enabled for profiles:")
enabledRideProfiles.forEach { profileName ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(profileName)
FilledTonalButton(onClick = {
enabledRideProfiles = enabledRideProfiles.toMutableSet().apply { remove(profileName) }
}) {
Icon(Icons.Default.Delete, contentDescription = "Delete profile")
}
}
}
}
}
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(60.dp), onClick = {
rideProfileDialogVisible = true
}) {
Icon(Icons.Default.Build, contentDescription = "Change Profiles", modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp))
Text("Limit to Profile")
}
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = isActive, onCheckedChange = { isActive = it})
Spacer(modifier = Modifier.width(10.dp))
@ -310,6 +350,59 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
title = { Text("Delete reminder") }, text = { Text("Really delete this reminder?") })
}
if (rideProfileDialogVisible) {
var newProfileName by remember { mutableStateOf("") }
Dialog(onDismissRequest = { rideProfileDialogVisible = false }) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
OutlinedTextField(
value = newProfileName,
onValueChange = { newProfileName = it },
label = { Text("New profile name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
FilledTonalButton(
onClick = {
if (newProfileName.isNotBlank()) {
enabledRideProfiles = enabledRideProfiles.toMutableSet().apply { add(newProfileName) }
newProfileName = ""
rideProfileDialogVisible = false
}
},
modifier = Modifier.fillMaxWidth().height(60.dp)
) {
Icon(Icons.Default.Add, contentDescription = "Add Profile")
Spacer(modifier = Modifier.width(5.dp))
Text("Add Profile")
}
FilledTonalButton(
onClick = { rideProfileDialogVisible = false },
modifier = Modifier.fillMaxWidth().height(60.dp)
) {
Icon(Icons.Default.Close, contentDescription = "Cancel Editing")
Spacer(modifier = Modifier.width(5.dp))
Text("Close")
}
}
}
}
}
if (triggerDialogVisible){
Dialog(onDismissRequest = { triggerDialogVisible = false }) {
Card(
@ -317,6 +410,7 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
.fillMaxWidth()
.padding(10.dp),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier
.padding(5.dp)
@ -361,6 +455,7 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
.fillMaxWidth()
.padding(10.dp),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier
.padding(5.dp)
@ -396,6 +491,7 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
.fillMaxWidth()
.padding(10.dp),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier
.padding(5.dp)
@ -430,3 +526,4 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
}
}
}

View File

@ -20,13 +20,8 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
@ -63,6 +58,7 @@ import androidx.navigation.navArgument
import de.timklge.karooreminder.KarooReminderExtension
import de.timklge.karooreminder.R
import de.timklge.karooreminder.dataStore
import de.timklge.karooreminder.streamActiveRideProfile
import de.timklge.karooreminder.streamUserProfile
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
@ -72,7 +68,6 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
val preferencesKey = stringPreferencesKey("reminders")
suspend fun saveReminders(context: Context, reminders: MutableList<Reminder>) {
@ -165,6 +160,7 @@ fun MainScreen(reminders: MutableList<Reminder>, onNavigateToReminder: (r: Remin
var showWarnings by remember { mutableStateOf(false) }
val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null)
val currentRideProfile by karooSystem.streamActiveRideProfile().collectAsStateWithLifecycle(null)
LaunchedEffect(Unit) {
delay(1000L)
@ -197,7 +193,7 @@ fun MainScreen(reminders: MutableList<Reminder>, onNavigateToReminder: (r: Remin
Card(Modifier
.fillMaxWidth()
.height(60.dp)
.alpha(if (reminder.isActive) 1f else 0.6f)
.alpha(if (reminderIsActive(reminder, currentRideProfile?.profile)) 1f else 0.6f)
.clickable { onNavigateToReminder(reminder) }
.padding(5.dp), shape = RoundedCornerShape(corner = CornerSize(10.dp))
) {

View File

@ -7,6 +7,7 @@ import de.timklge.karooreminder.R
import de.timklge.karooreminder.ReminderTrigger
import de.timklge.karooreminder.SmoothSetting
import io.hammerhead.karooext.models.PlayBeepPattern
import io.hammerhead.karooext.models.RideProfile
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@ -141,6 +142,14 @@ class Reminder(val id: Int, var name: String,
val isActive: Boolean = true, val isAutoDismiss: Boolean = true,
val tone: ReminderBeepPattern = ReminderBeepPattern.THREE_TONES_UP,
var trigger: ReminderTrigger = ReminderTrigger.ELAPSED_TIME,
val autoDismissSeconds: Int = 15)
val autoDismissSeconds: Int = 15,
val enabledRideProfiles: Set<String> = emptySet())
val defaultReminders = Json.encodeToString(listOf(Reminder(0, "Drink", 30, text = "Take a sip!")))
fun reminderIsActive(reminder: Reminder, currentRideProfile: RideProfile?): Boolean {
val enabledRideProfiles = reminder.enabledRideProfiles.map { it.lowercase().trim() }
val currentProfileName = currentRideProfile?.name?.lowercase()?.trim()
return reminder.isActive && (currentRideProfile == null || enabledRideProfiles.isEmpty() || enabledRideProfiles.contains(currentProfileName))
}

View File

@ -22,7 +22,7 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "
[libraries]
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
color = { module = "com.maxkeppeler.sheets-compose-dialogs:color", version.ref = "color" }
hammerhead-karoo-ext = { group = "io.hammerhead", name = "karoo-ext", version = "1.1.1" }
hammerhead-karoo-ext = { group = "io.hammerhead", name = "karoo-ext", version = "1.1.5" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }