Add profile selection dialog

This commit is contained in:
Tim Kluge 2025-05-29 13:15:24 +02:00
parent e68d4fabca
commit bf500596fb
6 changed files with 155 additions and 34 deletions

View File

@ -7,6 +7,7 @@ import de.timklge.karooreminder.screens.Reminder
import de.timklge.karooreminder.screens.defaultReminders import de.timklge.karooreminder.screens.defaultReminders
import de.timklge.karooreminder.screens.preferencesKey import de.timklge.karooreminder.screens.preferencesKey
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.ActiveRideProfile
import io.hammerhead.karooext.models.OnStreamState import io.hammerhead.karooext.models.OnStreamState
import io.hammerhead.karooext.models.RideState import io.hammerhead.karooext.models.RideState
import io.hammerhead.karooext.models.StreamState 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> { fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
return callbackFlow { return callbackFlow {
val listenerId = addConsumer { userProfile: UserProfile -> val listenerId = addConsumer { userProfile: UserProfile ->

View File

@ -5,8 +5,10 @@ import android.media.MediaPlayer
import android.util.Log import android.util.Log
import de.timklge.karooreminder.screens.Reminder import de.timklge.karooreminder.screens.Reminder
import de.timklge.karooreminder.screens.ReminderBeepPattern import de.timklge.karooreminder.screens.ReminderBeepPattern
import de.timklge.karooreminder.screens.reminderIsActive
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.KarooExtension import io.hammerhead.karooext.extension.KarooExtension
import io.hammerhead.karooext.models.ActiveRideProfile
import io.hammerhead.karooext.models.DataType import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.HardwareType import io.hammerhead.karooext.models.HardwareType
import io.hammerhead.karooext.models.InRideAlert import io.hammerhead.karooext.models.InRideAlert
@ -231,13 +233,19 @@ class KarooReminderExtension : KarooExtension("karoo-reminder", BuildConfig.VERS
val triggerJobs = mutableSetOf<Job>() 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 { triggerStreamJob = CoroutineScope(Dispatchers.IO).launch {
streamPreferences().collect { reminders -> streamDataFlow.collect { (reminders, activeRideProfile) ->
triggerJobs.forEach { it.cancel() } triggerJobs.forEach { it.cancel() }
triggerJobs.clear() triggerJobs.clear()
if (reminders.any { it.trigger == ReminderTrigger.DISTANCE }){ if (reminders.any { it.trigger == ReminderTrigger.DISTANCE }){
val distanceJob = startIntervalJob(ReminderTrigger.DISTANCE) { val distanceJob = startIntervalJob(reminders, activeRideProfile, ReminderTrigger.DISTANCE) {
karooSystem.streamDataFlow(DataType.Type.DISTANCE) karooSystem.streamDataFlow(DataType.Type.DISTANCE)
.mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.singleValue } .mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.combine(karooSystem.streamUserProfile()) { distance, profile -> distance to profile } .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 }){ 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) karooSystem.streamDataFlow(DataType.Type.ELAPSED_TIME)
.mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.singleValue } .mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.map { (it / 1000 / 60).toInt() } .map { (it / 1000 / 60).toInt() }
@ -266,7 +274,7 @@ class KarooReminderExtension : KarooExtension("karoo-reminder", BuildConfig.VERS
} }
if (reminders.any { it.trigger == ReminderTrigger.ENERGY_OUTPUT }){ 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) karooSystem.streamDataFlow(DataType.Type.ENERGY_OUTPUT)
.mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.singleValue } .mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.map { it.toInt() } .map { it.toInt() }
@ -301,7 +309,7 @@ class KarooReminderExtension : KarooExtension("karoo-reminder", BuildConfig.VERS
intervalTriggers.forEach { trigger -> intervalTriggers.forEach { trigger ->
SmoothSetting.entries.forEach { smoothSetting -> SmoothSetting.entries.forEach { smoothSetting ->
if (reminders.any { it.trigger == trigger && it.smoothSetting == 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) 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 { return CoroutineScope(Dispatchers.IO).launch {
val preferences = streamPreferences()
flow() flow()
.filterNot { it == 0 } .filterNot { it == 0 }
.combine(preferences) { elapsedMinutes, reminders -> elapsedMinutes to reminders} .distinctUntilChanged()
.distinctUntilChanged { old, new -> old.first == new.first } .collectLatest { elapsedMinutes ->
.collectLatest { (elapsedMinutes, reminders) -> val rs = preferences
val rs = reminders
.filter { reminder -> .filter { reminder ->
val interval = reminder.interval 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}") Log.i(TAG, "$trigger reminder: ${reminder.name}")
reminderChannel.send(DisplayedReminder(reminder.tone, trigger, InRideAlert( reminderChannel.send(
id = "reminder-${reminder.id}-${elapsedMinutes}", DisplayedReminder(
detail = reminder.text, reminder.tone, trigger, InRideAlert(
title = reminder.name, id = "reminder-${reminder.id}-${elapsedMinutes}",
autoDismissMs = if(reminder.isAutoDismiss) reminder.autoDismissSeconds * 1000L else null, detail = reminder.text,
icon = R.drawable.timer, title = reminder.name,
textColor = reminder.displayForegroundColor?.getTextColor() ?: R.color.black, autoDismissMs = if (reminder.isAutoDismiss) reminder.autoDismissSeconds * 1000L else null,
backgroundColor = reminder.displayForegroundColor?.colorRes ?: R.color.hRed 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 { return CoroutineScope(Dispatchers.IO).launch {
val preferences = streamPreferences()
Log.i(TAG, "Starting range exceeded job for trigger $triggerType with smooth setting $smoothSetting") Log.i(TAG, "Starting range exceeded job for trigger $triggerType with smooth setting $smoothSetting")
val valueStream = karooSystem.streamDataFlow(triggerType.getSmoothedDataType(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) 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, 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 { }.filter {
it.rideState is RideState.Recording it.rideState is RideState.Recording
}.let { }.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") 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 triggered

View File

@ -103,6 +103,8 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
var selectedTone by remember { mutableStateOf(reminder.tone) } var selectedTone by remember { mutableStateOf(reminder.tone) }
var autoDismissSeconds by remember { mutableStateOf(reminder.autoDismissSeconds.toString()) } var autoDismissSeconds by remember { mutableStateOf(reminder.autoDismissSeconds.toString()) }
var selectedTrigger by remember { mutableStateOf(reminder.trigger) } 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) val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null)
@ -116,7 +118,8 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
isActive = isActive, isActive = isActive,
smoothSetting = smoothSetting, smoothSetting = smoothSetting,
trigger = selectedTrigger, trigger = selectedTrigger,
isAutoDismiss = autoDismiss, tone = selectedTone, autoDismissSeconds = autoDismissSeconds.toIntOrNull() ?: 15) isAutoDismiss = autoDismiss, tone = selectedTone, autoDismissSeconds = autoDismissSeconds.toIntOrNull() ?: 15,
enabledRideProfiles = enabledRideProfiles.toSet())
} }
Column(modifier = Modifier Column(modifier = Modifier
@ -265,6 +268,16 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
Text("Is Active") Text("Is Active")
} }
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(60.dp), onClick = {
rideProfileDialogVisible = true
}) {
Icon(Icons.Default.Build, contentDescription = "Change Ride Profiles", modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp))
Text("Ride Profiles: ${if (enabledRideProfiles.isEmpty()) "All" else enabledRideProfiles.joinToString(", ")}")
}
FilledTonalButton(modifier = Modifier FilledTonalButton(modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(50.dp), onClick = { .height(50.dp), onClick = {
@ -310,6 +323,79 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
title = { Text("Delete reminder") }, text = { Text("Really delete this reminder?") }) 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),
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (enabledRideProfiles.isEmpty()) {
Text("All profiles enabled")
} else {
Column(modifier = Modifier.fillMaxWidth()) {
enabledRideProfiles.forEach { profileName ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(profileName)
Button(onClick = {
enabledRideProfiles = enabledRideProfiles.toMutableSet().apply { remove(profileName) }
}) {
Icon(Icons.Default.Delete, contentDescription = "Delete profile")
}
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = newProfileName,
onValueChange = { newProfileName = it },
label = { Text("New profile name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Button(
onClick = {
if (newProfileName.isNotBlank()) {
enabledRideProfiles = enabledRideProfiles.toMutableSet().apply { add(newProfileName) }
newProfileName = "" // Clear the text field
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Add Profile")
}
Spacer(modifier = Modifier.height(10.dp))
Button(
onClick = { rideProfileDialogVisible = false },
modifier = Modifier.fillMaxWidth()
) {
Text("Close")
}
}
}
}
}
if (triggerDialogVisible){ if (triggerDialogVisible){
Dialog(onDismissRequest = { triggerDialogVisible = false }) { Dialog(onDismissRequest = { triggerDialogVisible = false }) {
Card( Card(
@ -429,4 +515,5 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
} }
} }
} }
} }

View File

@ -63,6 +63,8 @@ import androidx.navigation.navArgument
import de.timklge.karooreminder.KarooReminderExtension import de.timklge.karooreminder.KarooReminderExtension
import de.timklge.karooreminder.R import de.timklge.karooreminder.R
import de.timklge.karooreminder.dataStore import de.timklge.karooreminder.dataStore
import de.timklge.karooreminder.streamActiveRideProfile
import de.timklge.karooreminder.streamRideState
import de.timklge.karooreminder.streamUserProfile import de.timklge.karooreminder.streamUserProfile
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile import io.hammerhead.karooext.models.UserProfile
@ -165,6 +167,7 @@ fun MainScreen(reminders: MutableList<Reminder>, onNavigateToReminder: (r: Remin
var showWarnings by remember { mutableStateOf(false) } var showWarnings by remember { mutableStateOf(false) }
val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null) val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null)
val currentRideProfile by karooSystem.streamActiveRideProfile().collectAsStateWithLifecycle(null)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(1000L) delay(1000L)
@ -197,7 +200,7 @@ fun MainScreen(reminders: MutableList<Reminder>, onNavigateToReminder: (r: Remin
Card(Modifier Card(Modifier
.fillMaxWidth() .fillMaxWidth()
.height(60.dp) .height(60.dp)
.alpha(if (reminder.isActive) 1f else 0.6f) .alpha(if (reminderIsActive(reminder, currentRideProfile?.profile)) 1f else 0.6f)
.clickable { onNavigateToReminder(reminder) } .clickable { onNavigateToReminder(reminder) }
.padding(5.dp), shape = RoundedCornerShape(corner = CornerSize(10.dp)) .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.ReminderTrigger
import de.timklge.karooreminder.SmoothSetting import de.timklge.karooreminder.SmoothSetting
import io.hammerhead.karooext.models.PlayBeepPattern import io.hammerhead.karooext.models.PlayBeepPattern
import io.hammerhead.karooext.models.RideProfile
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json 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 isActive: Boolean = true, val isAutoDismiss: Boolean = true,
val tone: ReminderBeepPattern = ReminderBeepPattern.THREE_TONES_UP, val tone: ReminderBeepPattern = ReminderBeepPattern.THREE_TONES_UP,
var trigger: ReminderTrigger = ReminderTrigger.ELAPSED_TIME, 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!"))) 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 || reminder.enabledRideProfiles.isEmpty() || enabledRideProfiles.contains(currentProfileName))
}

View File

@ -22,7 +22,7 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "
[libraries] [libraries]
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
color = { module = "com.maxkeppeler.sheets-compose-dialogs:color", version.ref = "color" } 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" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }