Add distance, hr / power / speed / cadence range triggers and light color set (#12)

* Add distance, range triggers

* Update version

* Brighten colors

* Add speed, cadence range triggers, fix imperial units

* Only trigger range alerts if more than 5 values have been received within the last 10 seconds

* Update README

* Rename icon license file
This commit is contained in:
timklge 2025-01-01 19:05:16 +01:00 committed by GitHub
parent ee15466517
commit 19249e640e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 447 additions and 44 deletions

View File

@ -4,7 +4,7 @@
![GitHub Downloads (specific asset, all releases)](https://img.shields.io/github/downloads/timklge/karoo-reminder/app-release.apk) ![GitHub Downloads (specific asset, all releases)](https://img.shields.io/github/downloads/timklge/karoo-reminder/app-release.apk)
![GitHub License](https://img.shields.io/github/license/timklge/karoo-reminder) ![GitHub License](https://img.shields.io/github/license/timklge/karoo-reminder)
Basic karoo extension that shows in-ride alerts every X minutes. For Karoo 2 and Karoo 3 devices. Karoo extension that displays in-ride alerts based on custom triggers. Reminders can be set to activate after a specific time interval, distance traveled, or when a sensor value is outside a defined range (e.g., heart rate exceeds zone 2).
![Reminder List](list.png) ![Reminder List](list.png)
![Reminder Detail](detail.png) ![Reminder Detail](detail.png)

View File

@ -15,8 +15,8 @@ android {
applicationId = "de.timklge.karooreminder" applicationId = "de.timklge.karooreminder"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 8 versionCode = 9
versionName = "1.0.7" versionName = "1.1"
} }
signingConfigs { signingConfigs {

View File

@ -3,9 +3,9 @@
"packageName": "de.timklge.karooreminder", "packageName": "de.timklge.karooreminder",
"iconUrl": "https://github.com/timklge/karoo-reminder/releases/latest/download/karoo-reminder.png", "iconUrl": "https://github.com/timklge/karoo-reminder/releases/latest/download/karoo-reminder.png",
"latestApkUrl": "https://github.com/timklge/karoo-reminder/releases/latest/download/app-release.apk", "latestApkUrl": "https://github.com/timklge/karoo-reminder/releases/latest/download/app-release.apk",
"latestVersion": "1.0.7", "latestVersion": "1.1",
"latestVersionCode": 8, "latestVersionCode": 9,
"developer": "timklge", "developer": "timklge",
"description": "Simple karoo extension that shows in-ride alerts every X minutes", "description": "Shows in-ride alerts after a given time interval, distance or HR / power / speed / cadence out of range",
"releaseNotes": "Added display duration setting, bluetooth alert sound. Built via Github CD" "releaseNotes": "Added distance, HR / power out of range trigger types"
} }

View File

@ -0,0 +1,60 @@
package de.timklge.karooreminder
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
data class DropdownOption(val id: String, val name: String)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Dropdown(label: String, options: List<DropdownOption>, selected: DropdownOption, onSelect: (selectedOption: DropdownOption) -> Unit) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
OutlinedTextField(
readOnly = true,
value = selected.name,
onValueChange = { },
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable, true).fillMaxWidth(),
label = { Text(label) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = expanded
)
},
colors = ExposedDropdownMenuDefaults.textFieldColors()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option.name, style = MaterialTheme.typography.bodyLarge) },
onClick = {
expanded = false
onSelect(option)
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
)
}
}
}
}

View File

@ -4,6 +4,7 @@ import io.hammerhead.karooext.KarooSystemService
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
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -30,3 +31,14 @@ fun KarooSystemService.streamRideState(): Flow<RideState> {
} }
} }
} }
fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
return callbackFlow {
val listenerId = addConsumer { userProfile: UserProfile ->
trySendBlocking(userProfile)
}
awaitClose {
removeConsumer(listenerId)
}
}
}

View File

@ -14,21 +14,58 @@ import io.hammerhead.karooext.models.InRideAlert
import io.hammerhead.karooext.models.PlayBeepPattern import io.hammerhead.karooext.models.PlayBeepPattern
import io.hammerhead.karooext.models.StreamState import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.TurnScreenOn import io.hammerhead.karooext.models.TurnScreenOn
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
class KarooReminderExtension : KarooExtension("karoo-reminder", "1.0.7") { enum class ReminderTrigger(val id: String, val label: String) {
ELAPSED_TIME("elapsed_time", "Elapsed Time"),
DISTANCE("distance", "Distance"),
HR_LIMIT_MAXIMUM_EXCEEDED("hr_limit_max", "HR above value"),
HR_LIMIT_MINIMUM_EXCEEDED("hr_limit_min", "HR below value"),
POWER_LIMIT_MAXIMUM_EXCEEDED("power_limit_min", "Power above value"),
POWER_LIMIT_MINIMUM_EXCEEDED("power_limit_min", "Power below value"),
SPEED_LIMIT_MAXIMUM_EXCEEDED("speed_limit_max", "Speed above value"),
SPEED_LIMIT_MINIMUM_EXCEEDED("speed_limit_min", "Speed below value"),
CADENCE_LIMIT_MAXIMUM_EXCEEDED("cadence_limit_max", "Cadence above value"),
CADENCE_LIMIT_MINIMUM_EXCEEDED("cadence_limit_min", "Cadence below value");
fun getPrefix(): String {
return when (this) {
HR_LIMIT_MINIMUM_EXCEEDED, POWER_LIMIT_MINIMUM_EXCEEDED, SPEED_LIMIT_MINIMUM_EXCEEDED, CADENCE_LIMIT_MINIMUM_EXCEEDED -> "<"
HR_LIMIT_MAXIMUM_EXCEEDED, POWER_LIMIT_MAXIMUM_EXCEEDED, SPEED_LIMIT_MAXIMUM_EXCEEDED, CADENCE_LIMIT_MAXIMUM_EXCEEDED -> ">"
ELAPSED_TIME, DISTANCE -> ""
}
}
fun getSuffix(imperial: Boolean): String {
return when (this) {
ELAPSED_TIME -> "min"
DISTANCE -> if(imperial) "mi" else "km"
HR_LIMIT_MAXIMUM_EXCEEDED, HR_LIMIT_MINIMUM_EXCEEDED -> "bpm"
POWER_LIMIT_MAXIMUM_EXCEEDED, POWER_LIMIT_MINIMUM_EXCEEDED -> "W"
SPEED_LIMIT_MAXIMUM_EXCEEDED, SPEED_LIMIT_MINIMUM_EXCEEDED -> if(imperial) "mph" else "km/h"
CADENCE_LIMIT_MAXIMUM_EXCEEDED, CADENCE_LIMIT_MINIMUM_EXCEEDED -> "rpm"
}
}
}
class KarooReminderExtension : KarooExtension("karoo-reminder", "1.1") {
companion object { companion object {
const val TAG = "karoo-reminder" const val TAG = "karoo-reminder"
@ -36,22 +73,125 @@ class KarooReminderExtension : KarooExtension("karoo-reminder", "1.0.7") {
private lateinit var karooSystem: KarooSystemService private lateinit var karooSystem: KarooSystemService
private var serviceJob: Job? = null private var jobs: MutableSet<Job> = mutableSetOf()
data class DisplayedReminder(val tones: ReminderBeepPattern, val trigger: ReminderTrigger, val alert: InRideAlert)
private var reminderChannel = Channel<DisplayedReminder>(2, BufferOverflow.DROP_OLDEST)
private var mediaPlayer: MediaPlayer? = null
private suspend fun receiverWorker() {
for(displayedReminder in reminderChannel) {
Log.i(TAG, "Dispatching reminder: ${displayedReminder.alert.title}")
try {
karooSystem.dispatch(TurnScreenOn)
val isAutoDismiss = displayedReminder.alert.autoDismissMs != null
val autoDismissMs = (displayedReminder.alert.autoDismissMs ?: 0L)
val intent = Intent("de.timklge.HIDE_POWERBAR").apply {
putExtra("duration", (if (isAutoDismiss) autoDismissMs else 15_000L) + 1000L)
putExtra("location", "top")
}
delay(1_000)
applicationContext.sendBroadcast(intent)
if (displayedReminder.tones != ReminderBeepPattern.NO_TONES) {
karooSystem.dispatch(PlayBeepPattern(displayedReminder.tones.tones))
mediaPlayer?.start()
}
karooSystem.dispatch(displayedReminder.alert)
val delayMs = if (isAutoDismiss) autoDismissMs else 20000L
delay(delayMs)
} catch(e: ClosedReceiveChannelException){
Log.w(TAG, "Dispatch channel closed, exiting")
return
} catch(e: Exception){
Log.e(TAG, "Failed to dispatch reminder", e)
}
}
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
mediaPlayer = MediaPlayer.create(this, R.raw.reminder)
karooSystem = KarooSystemService(applicationContext) karooSystem = KarooSystemService(applicationContext)
val mediaPlayer = MediaPlayer.create(this, R.raw.reminder) val receiveJob = CoroutineScope(Dispatchers.IO).launch {
receiverWorker()
}
jobs.add(receiveJob)
serviceJob = CoroutineScope(Dispatchers.IO).launch {
karooSystem.connect { connected -> karooSystem.connect { connected ->
if (connected) { if (connected) {
Log.i(TAG, "Connected") Log.i(TAG, "Connected")
} }
} }
val distanceJob = CoroutineScope(Dispatchers.IO).launch {
val preferences = applicationContext.dataStore.data.map { remindersJson ->
try {
Json.decodeFromString<MutableList<Reminder>>(
remindersJson[preferencesKey] ?: defaultReminders
)
} catch(e: Throwable){
Log.e(TAG,"Failed to read preferences", e)
mutableListOf()
}
}
karooSystem.streamDataFlow(DataType.Type.DISTANCE)
.mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.combine(karooSystem.streamUserProfile()) { distance, profile -> distance to profile }
.map { (distance, profile) ->
if (profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL){
(distance / 1609.344).toInt()
} else {
(distance / 1000.0).toInt()
}
}
.distinctUntilChanged()
.filterNot { it == 0 }
.combine(preferences) { distance, reminders -> distance to reminders}
.distinctUntilChanged { old, new -> old.first == new.first }
.collectLatest { (distance, reminders) ->
val rs = reminders
.filter { reminder -> reminder.trigger == ReminderTrigger.DISTANCE && reminder.isActive && distance % reminder.interval == 0 }
for (reminder in rs){
Log.i(TAG, "Distance reminder: ${reminder.name}")
reminderChannel.send(DisplayedReminder(reminder.tone, ReminderTrigger.DISTANCE, InRideAlert(
id = "reminder-${reminder.id}-${distance}",
detail = reminder.text,
title = reminder.name,
autoDismissMs = if(reminder.isAutoDismiss) reminder.autoDismissSeconds * 1000L else null,
icon = R.drawable.ic_launcher,
textColor = reminder.getTextColor(applicationContext),
backgroundColor = reminder.getResourceColor(applicationContext)
)))
}
}
}
jobs.add(distanceJob)
jobs.addAll(listOf(
startRangeExceededJob(ReminderTrigger.POWER_LIMIT_MAXIMUM_EXCEEDED),
startRangeExceededJob(ReminderTrigger.HR_LIMIT_MAXIMUM_EXCEEDED),
startRangeExceededJob(ReminderTrigger.POWER_LIMIT_MINIMUM_EXCEEDED),
startRangeExceededJob(ReminderTrigger.HR_LIMIT_MINIMUM_EXCEEDED),
startRangeExceededJob(ReminderTrigger.SPEED_LIMIT_MAXIMUM_EXCEEDED),
startRangeExceededJob(ReminderTrigger.SPEED_LIMIT_MINIMUM_EXCEEDED),
startRangeExceededJob(ReminderTrigger.CADENCE_LIMIT_MAXIMUM_EXCEEDED),
startRangeExceededJob(ReminderTrigger.CADENCE_LIMIT_MINIMUM_EXCEEDED)
))
val elapsedTimeJob = CoroutineScope(Dispatchers.IO).launch {
val preferences = applicationContext.dataStore.data.map { remindersJson -> val preferences = applicationContext.dataStore.data.map { remindersJson ->
try { try {
Json.decodeFromString<MutableList<Reminder>>( Json.decodeFromString<MutableList<Reminder>>(
@ -72,45 +212,108 @@ class KarooReminderExtension : KarooExtension("karoo-reminder", "1.0.7") {
.distinctUntilChanged { old, new -> old.first == new.first } .distinctUntilChanged { old, new -> old.first == new.first }
.collectLatest { (elapsedMinutes, reminders) -> .collectLatest { (elapsedMinutes, reminders) ->
val rs = reminders val rs = reminders
.filter { reminder -> reminder.isActive && elapsedMinutes % reminder.interval == 0 } .filter { reminder -> reminder.trigger == ReminderTrigger.ELAPSED_TIME && reminder.isActive && elapsedMinutes % reminder.interval == 0 }
for (reminder in rs){ for (reminder in rs){
karooSystem.dispatch(TurnScreenOn) Log.i(TAG, "Elapsed time reminder: ${reminder.name}")
reminderChannel.send(DisplayedReminder(reminder.tone, ReminderTrigger.ELAPSED_TIME, InRideAlert(
val intent = Intent("de.timklge.HIDE_POWERBAR").apply {
putExtra("duration", (if(reminder.isAutoDismiss) reminder.autoDismissSeconds * 1000L else 15_000L) + 1000L)
putExtra("location", "top")
}
delay(1_000)
applicationContext.sendBroadcast(intent)
if (reminder.tone != ReminderBeepPattern.NO_TONES){
karooSystem.dispatch(PlayBeepPattern(reminder.tone.tones))
mediaPlayer.start()
}
karooSystem.dispatch(
InRideAlert(
id = "reminder-${reminder.id}-${elapsedMinutes}", id = "reminder-${reminder.id}-${elapsedMinutes}",
detail = reminder.text, detail = reminder.text,
title = reminder.name, title = reminder.name,
autoDismissMs = if(reminder.isAutoDismiss) reminder.autoDismissSeconds * 1000L else null, autoDismissMs = if(reminder.isAutoDismiss) reminder.autoDismissSeconds * 1000L else null,
icon = R.drawable.ic_launcher, icon = R.drawable.ic_launcher,
textColor = R.color.white, textColor = reminder.getTextColor(applicationContext),
backgroundColor = reminder.getResourceColor(applicationContext) backgroundColor = reminder.getResourceColor(applicationContext)
), )))
) }
}
}
jobs.add(elapsedTimeJob)
}
val delayMs = if(reminder.isAutoDismiss) reminder.autoDismissSeconds * 1000L else 20000L data class StreamData(val value: Double, val reminders: MutableList<Reminder>? = null, val imperial: Boolean = false)
delay(delayMs)
private fun startRangeExceededJob(triggerType: ReminderTrigger): Job {
return CoroutineScope(Dispatchers.IO).launch {
val preferences = applicationContext.dataStore.data.map { remindersJson ->
try {
Json.decodeFromString<MutableList<Reminder>>(
remindersJson[preferencesKey] ?: defaultReminders
)
} catch (e: Throwable) {
Log.e(TAG, "Failed to read preferences", e)
mutableListOf()
}
}
val dataType = when (triggerType) {
ReminderTrigger.HR_LIMIT_MAXIMUM_EXCEEDED, ReminderTrigger.HR_LIMIT_MINIMUM_EXCEEDED -> DataType.Type.HEART_RATE
ReminderTrigger.POWER_LIMIT_MAXIMUM_EXCEEDED, ReminderTrigger.POWER_LIMIT_MINIMUM_EXCEEDED -> DataType.Type.SMOOTHED_3S_AVERAGE_POWER
ReminderTrigger.SPEED_LIMIT_MAXIMUM_EXCEEDED, ReminderTrigger.SPEED_LIMIT_MINIMUM_EXCEEDED -> DataType.Type.SMOOTHED_3S_AVERAGE_SPEED
ReminderTrigger.CADENCE_LIMIT_MAXIMUM_EXCEEDED, ReminderTrigger.CADENCE_LIMIT_MINIMUM_EXCEEDED -> DataType.Type.SMOOTHED_3S_AVERAGE_CADENCE
ReminderTrigger.DISTANCE, ReminderTrigger.ELAPSED_TIME -> error("Unsupported trigger type: $triggerType")
}
karooSystem.streamDataFlow(dataType)
.mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.filter { it > 0.0 }
.combine(preferences) { value, reminders -> StreamData(value, reminders) }
.combine(karooSystem.streamUserProfile()) { streamData, profile -> streamData.copy(imperial = profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL) }
.onlyIfNValuesReceivedWithinTimeframe(5, 1000 * 10) // At least 5 values have been received over the last 10 seconds
.map { (value, reminders, imperial) ->
val triggered = reminders?.filter { reminder ->
val isSpeedTrigger = triggerType == ReminderTrigger.SPEED_LIMIT_MAXIMUM_EXCEEDED || triggerType == ReminderTrigger.SPEED_LIMIT_MINIMUM_EXCEEDED
val reminderValue = if (isSpeedTrigger){
// Convert m/s speed to km/h or mph
if (imperial) reminder.interval * 0.44704 else reminder.interval * 0.277778
} else {
reminder.interval.toDouble()
}
val triggerIsMet = when (triggerType){
ReminderTrigger.HR_LIMIT_MAXIMUM_EXCEEDED, ReminderTrigger.POWER_LIMIT_MAXIMUM_EXCEEDED,
ReminderTrigger.CADENCE_LIMIT_MAXIMUM_EXCEEDED, ReminderTrigger.SPEED_LIMIT_MAXIMUM_EXCEEDED -> value > reminderValue
ReminderTrigger.HR_LIMIT_MINIMUM_EXCEEDED, ReminderTrigger.POWER_LIMIT_MINIMUM_EXCEEDED,
ReminderTrigger.CADENCE_LIMIT_MINIMUM_EXCEEDED, ReminderTrigger.SPEED_LIMIT_MINIMUM_EXCEEDED -> value < reminderValue
ReminderTrigger.DISTANCE, ReminderTrigger.ELAPSED_TIME -> error("Unsupported trigger type: $triggerType")
}
reminder.isActive && reminder.trigger == triggerType && triggerIsMet
}
triggered
}
.filterNotNull()
.filter { it.isNotEmpty() }
.throttle(1_000 * 60) // At most once every minute
.collectLatest { reminders ->
Log.i(TAG, "Triggered range reminder: ${reminders.size} reminders")
reminders.forEach { reminder ->
reminderChannel.send(
DisplayedReminder(
reminder.tone, triggerType, InRideAlert(
id = "reminder-${reminder.id}-${System.currentTimeMillis()}",
detail = reminder.text,
title = reminder.name,
autoDismissMs = if (reminder.isAutoDismiss) reminder.autoDismissSeconds * 1000L else null,
icon = R.drawable.ic_launcher,
textColor = reminder.getTextColor(applicationContext),
backgroundColor = reminder.getResourceColor(applicationContext)
)
)
)
} }
} }
} }
} }
override fun onDestroy() { override fun onDestroy() {
serviceJob?.cancel() jobs.forEach { job -> job.cancel() }
serviceJob = null jobs.clear()
karooSystem.disconnect() karooSystem.disconnect()
super.onDestroy() super.onDestroy()
} }

View File

@ -0,0 +1,31 @@
package de.timklge.karooreminder
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
fun<T> Flow<T>.throttle(timeout: Long): Flow<T> = flow {
var lastEmissionTime = 0L
collect { value ->
val currentTime = System.currentTimeMillis()
if (currentTime - lastEmissionTime >= timeout) {
emit(value)
lastEmissionTime = currentTime
}
}
}
fun<T> Flow<T>.onlyIfNValuesReceivedWithinTimeframe(n: Int, timeframe: Long): Flow<T> = flow {
val lastValuesReceivedAt = mutableListOf<Long>()
collect { value ->
val currentTime = System.currentTimeMillis()
lastValuesReceivedAt.removeAll { it + timeframe < currentTime }
lastValuesReceivedAt.add(currentTime)
if (lastValuesReceivedAt.size >= n) {
emit(value)
}
}
}

View File

@ -50,6 +50,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.maxkeppeker.sheets.core.models.base.UseCaseState import com.maxkeppeker.sheets.core.models.base.UseCaseState
import com.maxkeppeler.sheets.color.ColorDialog import com.maxkeppeler.sheets.color.ColorDialog
import com.maxkeppeler.sheets.color.models.ColorConfig import com.maxkeppeler.sheets.color.models.ColorConfig
@ -57,9 +58,14 @@ import com.maxkeppeler.sheets.color.models.ColorSelection
import com.maxkeppeler.sheets.color.models.ColorSelectionMode import com.maxkeppeler.sheets.color.models.ColorSelectionMode
import com.maxkeppeler.sheets.color.models.MultipleColors import com.maxkeppeler.sheets.color.models.MultipleColors
import com.maxkeppeler.sheets.color.models.SingleColor import com.maxkeppeler.sheets.color.models.SingleColor
import de.timklge.karooreminder.Dropdown
import de.timklge.karooreminder.DropdownOption
import de.timklge.karooreminder.R import de.timklge.karooreminder.R
import de.timklge.karooreminder.ReminderTrigger
import de.timklge.karooreminder.streamUserProfile
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.PlayBeepPattern import io.hammerhead.karooext.models.PlayBeepPattern
import io.hammerhead.karooext.models.UserProfile
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -80,9 +86,16 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
var toneDialogVisible by remember { mutableStateOf(false) } var toneDialogVisible by remember { mutableStateOf(false) }
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) }
val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null)
fun getUpdatedReminder(): Reminder = Reminder(reminder.id, title, duration.toIntOrNull() ?: 1, fun getUpdatedReminder(): Reminder = Reminder(reminder.id, title, duration.toIntOrNull() ?: 1,
text, selectedColor, isActive, isAutoDismiss = autoDismiss, tone = selectedTone, autoDismissSeconds = autoDismissSeconds.toIntOrNull() ?: 15) text = text,
foregroundColor = selectedColor,
isActive = isActive,
trigger = selectedTrigger,
isAutoDismiss = autoDismiss, tone = selectedTone, autoDismissSeconds = autoDismissSeconds.toIntOrNull() ?: 15)
Column(modifier = Modifier Column(modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -96,10 +109,51 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
OutlinedTextField(value = text, onValueChange = { text = it }, label = { Text("Text") }, modifier = Modifier.fillMaxWidth(), singleLine = true) OutlinedTextField(value = text, onValueChange = { text = it }, label = { Text("Text") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
apply {
val dropdownOptions = ReminderTrigger.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) }
val dropdownInitialSelection by remember(selectedTrigger) {
mutableStateOf(dropdownOptions.find { option -> option.id == selectedTrigger.id }!!)
}
Dropdown(label = "Trigger", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption ->
val previousTrigger = selectedTrigger
selectedTrigger = ReminderTrigger.entries.find { entry -> entry.id == selectedOption.id }!!
if (selectedTrigger != previousTrigger) {
duration = when (selectedTrigger) {
ReminderTrigger.ELAPSED_TIME -> 30.toString()
ReminderTrigger.DISTANCE -> 10.toString()
ReminderTrigger.HR_LIMIT_MAXIMUM_EXCEEDED -> 160.toString()
ReminderTrigger.POWER_LIMIT_MAXIMUM_EXCEEDED -> 200.toString()
ReminderTrigger.HR_LIMIT_MINIMUM_EXCEEDED -> 60.toString()
ReminderTrigger.POWER_LIMIT_MINIMUM_EXCEEDED -> 100.toString()
ReminderTrigger.SPEED_LIMIT_MAXIMUM_EXCEEDED -> 40.toString()
ReminderTrigger.SPEED_LIMIT_MINIMUM_EXCEEDED -> 20.toString()
ReminderTrigger.CADENCE_LIMIT_MAXIMUM_EXCEEDED -> 120.toString()
ReminderTrigger.CADENCE_LIMIT_MINIMUM_EXCEEDED -> 60.toString()
}
}
}
}
OutlinedTextField(value = duration, modifier = Modifier.fillMaxWidth(), OutlinedTextField(value = duration, modifier = Modifier.fillMaxWidth(),
onValueChange = { duration = it }, onValueChange = { duration = it },
label = { Text("Interval") }, label = {
suffix = { Text("min") }, when(selectedTrigger){
ReminderTrigger.ELAPSED_TIME -> Text("Interval")
ReminderTrigger.DISTANCE -> Text("Distance")
ReminderTrigger.HR_LIMIT_MAXIMUM_EXCEEDED -> Text("Maximum heart rate")
ReminderTrigger.POWER_LIMIT_MAXIMUM_EXCEEDED -> Text("Maximum power")
ReminderTrigger.HR_LIMIT_MINIMUM_EXCEEDED -> Text("Minimum heart rate")
ReminderTrigger.POWER_LIMIT_MINIMUM_EXCEEDED -> Text("Minimum power")
ReminderTrigger.SPEED_LIMIT_MAXIMUM_EXCEEDED -> Text("Maximum speed")
ReminderTrigger.SPEED_LIMIT_MINIMUM_EXCEEDED -> Text("Minimum speed")
ReminderTrigger.CADENCE_LIMIT_MAXIMUM_EXCEEDED -> Text("Maximum cadence")
ReminderTrigger.CADENCE_LIMIT_MINIMUM_EXCEEDED -> Text("Minimum cadence")
}
},
suffix = {
Text(selectedTrigger.getSuffix(profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL))
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true singleLine = true
) )
@ -120,6 +174,13 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi
ContextCompat.getColor(LocalContext.current, R.color.bGreen), ContextCompat.getColor(LocalContext.current, R.color.bGreen),
ContextCompat.getColor(LocalContext.current, R.color.bBlue), ContextCompat.getColor(LocalContext.current, R.color.bBlue),
ContextCompat.getColor(LocalContext.current, R.color.bCyan), ContextCompat.getColor(LocalContext.current, R.color.bCyan),
ContextCompat.getColor(LocalContext.current, R.color.hRed),
ContextCompat.getColor(LocalContext.current, R.color.hPurple),
ContextCompat.getColor(LocalContext.current, R.color.hYellow),
ContextCompat.getColor(LocalContext.current, R.color.hGreen),
ContextCompat.getColor(LocalContext.current, R.color.hBlue),
ContextCompat.getColor(LocalContext.current, R.color.hCyan),
), ),
) )
) )

View File

@ -47,6 +47,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColor import androidx.core.graphics.toColor
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@ -55,7 +56,9 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import de.timklge.karooreminder.KarooReminderExtension import de.timklge.karooreminder.KarooReminderExtension
import de.timklge.karooreminder.dataStore import de.timklge.karooreminder.dataStore
import de.timklge.karooreminder.streamUserProfile
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -153,6 +156,8 @@ fun MainScreen(reminders: MutableList<Reminder>, onNavigateToReminder: (r: Remin
val karooSystem = remember { KarooSystemService(ctx) } val karooSystem = remember { KarooSystemService(ctx) }
var showWarnings by remember { mutableStateOf(false) } var showWarnings by remember { mutableStateOf(false) }
val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(1000L) delay(1000L)
showWarnings = true showWarnings = true
@ -199,7 +204,7 @@ fun MainScreen(reminders: MutableList<Reminder>, onNavigateToReminder: (r: Remin
Spacer(Modifier.weight(1.0f)) Spacer(Modifier.weight(1.0f))
Text("${reminder.interval}min") Text("${reminder.trigger.getPrefix()}${reminder.interval}${reminder.trigger.getSuffix(profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL)}")
} }
} }
} }

View File

@ -3,6 +3,7 @@ package de.timklge.karooreminder.screens
import android.content.Context import android.content.Context
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import de.timklge.karooreminder.R import de.timklge.karooreminder.R
import de.timklge.karooreminder.ReminderTrigger
import io.hammerhead.karooext.models.PlayBeepPattern import io.hammerhead.karooext.models.PlayBeepPattern
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@ -29,8 +30,21 @@ class Reminder(val id: Int, var name: String, var interval: Int, var text: Strin
var foregroundColor: Int = android.graphics.Color.parseColor("#700000"), var foregroundColor: Int = android.graphics.Color.parseColor("#700000"),
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,
val autoDismissSeconds: Int = 15){ val autoDismissSeconds: Int = 15){
fun getTextColor(context: Context): Int {
return when(foregroundColor){
ContextCompat.getColor(context, R.color.bRed),
ContextCompat.getColor(context, R.color.bPurple),
ContextCompat.getColor(context, R.color.bYellow),
ContextCompat.getColor(context, R.color.bGreen),
ContextCompat.getColor(context, R.color.bBlue),
ContextCompat.getColor(context, R.color.bCyan) -> R.color.white
else -> R.color.black
}
}
fun getResourceColor(context: Context): Int { fun getResourceColor(context: Context): Int {
return when(foregroundColor){ return when(foregroundColor){
ContextCompat.getColor(context, R.color.bRed) -> R.color.bRed ContextCompat.getColor(context, R.color.bRed) -> R.color.bRed
@ -39,6 +53,14 @@ class Reminder(val id: Int, var name: String, var interval: Int, var text: Strin
ContextCompat.getColor(context, R.color.bGreen) -> R.color.bGreen ContextCompat.getColor(context, R.color.bGreen) -> R.color.bGreen
ContextCompat.getColor(context, R.color.bBlue) -> R.color.bBlue ContextCompat.getColor(context, R.color.bBlue) -> R.color.bBlue
ContextCompat.getColor(context, R.color.bCyan) -> R.color.bCyan ContextCompat.getColor(context, R.color.bCyan) -> R.color.bCyan
ContextCompat.getColor(context, R.color.hRed) -> R.color.hRed
ContextCompat.getColor(context, R.color.hPurple) -> R.color.hPurple
ContextCompat.getColor(context, R.color.hYellow) -> R.color.hYellow
ContextCompat.getColor(context, R.color.hGreen) -> R.color.hGreen
ContextCompat.getColor(context, R.color.hBlue) -> R.color.hBlue
ContextCompat.getColor(context, R.color.hCyan) -> R.color.hCyan
else -> error("Unknown color") else -> error("Unknown color")
} }
} }

View File

@ -4,10 +4,19 @@
<color name="colorPrimaryDark">#2600B3</color> <color name="colorPrimaryDark">#2600B3</color>
<color name="white">#ffffff</color> <color name="white">#ffffff</color>
<color name="black">#000000</color>
<color name="bRed">#700000</color> <color name="bRed">#700000</color>
<color name="bGreen">#007000</color> <color name="bGreen">#007000</color>
<color name="bBlue">#000070</color> <color name="bBlue">#000070</color>
<color name="bPurple">#700070</color> <color name="bPurple">#700070</color>
<color name="bYellow">#707000</color> <color name="bYellow">#707000</color>
<color name="bCyan">#007070</color> <color name="bCyan">#007070</color>
<color name="hRed">#FF6060</color>
<color name="hGreen">#50FF50</color>
<color name="hBlue">#7070FF</color>
<color name="hPurple">#FF70FF</color>
<color name="hYellow">#FFFF60</color>
<color name="hCyan">#60FFFF</color>
</resources> </resources>