From 19249e640ec2d3cb285d3c73d04c5ac459c4d748 Mon Sep 17 00:00:00 2001 From: timklge Date: Wed, 1 Jan 2025 19:05:16 +0100 Subject: [PATCH] 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 --- README.md | 2 +- app/build.gradle.kts | 4 +- app/manifest.json | 8 +- .../de/timklge/karooreminder/Dropdown.kt | 60 ++++ .../de/timklge/karooreminder/Extensions.kt | 12 + .../karooreminder/KarooReminderExtension.kt | 269 +++++++++++++++--- .../de/timklge/karooreminder/Throttle.kt | 31 ++ .../karooreminder/screens/DetailScreen.kt | 67 ++++- .../karooreminder/screens/MainScreen.kt | 7 +- .../timklge/karooreminder/screens/Reminder.kt | 22 ++ app/src/main/res/values/colors.xml | 9 + ICON_LICENSE => icon-credits.md | 0 12 files changed, 447 insertions(+), 44 deletions(-) create mode 100644 app/src/main/kotlin/de/timklge/karooreminder/Dropdown.kt create mode 100644 app/src/main/kotlin/de/timklge/karooreminder/Throttle.kt rename ICON_LICENSE => icon-credits.md (100%) diff --git a/README.md b/README.md index 5924e23..6fff9db 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![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) -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 Detail](detail.png) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2d84b9f..1121f7f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "de.timklge.karooreminder" minSdk = 26 targetSdk = 34 - versionCode = 8 - versionName = "1.0.7" + versionCode = 9 + versionName = "1.1" } signingConfigs { diff --git a/app/manifest.json b/app/manifest.json index 198ac5a..da90d41 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -3,9 +3,9 @@ "packageName": "de.timklge.karooreminder", "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", - "latestVersion": "1.0.7", - "latestVersionCode": 8, + "latestVersion": "1.1", + "latestVersionCode": 9, "developer": "timklge", - "description": "Simple karoo extension that shows in-ride alerts every X minutes", - "releaseNotes": "Added display duration setting, bluetooth alert sound. Built via Github CD" + "description": "Shows in-ride alerts after a given time interval, distance or HR / power / speed / cadence out of range", + "releaseNotes": "Added distance, HR / power out of range trigger types" } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooreminder/Dropdown.kt b/app/src/main/kotlin/de/timklge/karooreminder/Dropdown.kt new file mode 100644 index 0000000..1880680 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooreminder/Dropdown.kt @@ -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, 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, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooreminder/Extensions.kt b/app/src/main/kotlin/de/timklge/karooreminder/Extensions.kt index 5289ad2..5d8c3a1 100644 --- a/app/src/main/kotlin/de/timklge/karooreminder/Extensions.kt +++ b/app/src/main/kotlin/de/timklge/karooreminder/Extensions.kt @@ -4,6 +4,7 @@ import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.models.OnStreamState import io.hammerhead.karooext.models.RideState import io.hammerhead.karooext.models.StreamState +import io.hammerhead.karooext.models.UserProfile import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.Flow @@ -29,4 +30,15 @@ fun KarooSystemService.streamRideState(): Flow { removeConsumer(listenerId) } } +} + +fun KarooSystemService.streamUserProfile(): Flow { + return callbackFlow { + val listenerId = addConsumer { userProfile: UserProfile -> + trySendBlocking(userProfile) + } + awaitClose { + removeConsumer(listenerId) + } + } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooreminder/KarooReminderExtension.kt b/app/src/main/kotlin/de/timklge/karooreminder/KarooReminderExtension.kt index 7d6a94b..612db93 100644 --- a/app/src/main/kotlin/de/timklge/karooreminder/KarooReminderExtension.kt +++ b/app/src/main/kotlin/de/timklge/karooreminder/KarooReminderExtension.kt @@ -14,21 +14,58 @@ import io.hammerhead.karooext.models.InRideAlert import io.hammerhead.karooext.models.PlayBeepPattern import io.hammerhead.karooext.models.StreamState import io.hammerhead.karooext.models.TurnScreenOn +import io.hammerhead.karooext.models.UserProfile import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers 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.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch 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 { const val TAG = "karoo-reminder" @@ -36,22 +73,125 @@ class KarooReminderExtension : KarooExtension("karoo-reminder", "1.0.7") { private lateinit var karooSystem: KarooSystemService - private var serviceJob: Job? = null + private var jobs: MutableSet = mutableSetOf() + + data class DisplayedReminder(val tones: ReminderBeepPattern, val trigger: ReminderTrigger, val alert: InRideAlert) + + private var reminderChannel = Channel(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() { super.onCreate() + mediaPlayer = MediaPlayer.create(this, R.raw.reminder) + 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 -> - if (connected) { - Log.i(TAG, "Connected") + karooSystem.connect { connected -> + if (connected) { + Log.i(TAG, "Connected") + } + } + + val distanceJob = CoroutineScope(Dispatchers.IO).launch { + val preferences = applicationContext.dataStore.data.map { remindersJson -> + try { + Json.decodeFromString>( + 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 -> try { Json.decodeFromString>( @@ -72,45 +212,108 @@ class KarooReminderExtension : KarooExtension("karoo-reminder", "1.0.7") { .distinctUntilChanged { old, new -> old.first == new.first } .collectLatest { (elapsedMinutes, 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){ - karooSystem.dispatch(TurnScreenOn) + Log.i(TAG, "Elapsed time reminder: ${reminder.name}") + reminderChannel.send(DisplayedReminder(reminder.tone, ReminderTrigger.ELAPSED_TIME, InRideAlert( + id = "reminder-${reminder.id}-${elapsedMinutes}", + 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(elapsedTimeJob) + } - val intent = Intent("de.timklge.HIDE_POWERBAR").apply { - putExtra("duration", (if(reminder.isAutoDismiss) reminder.autoDismissSeconds * 1000L else 15_000L) + 1000L) - putExtra("location", "top") + data class StreamData(val value: Double, val reminders: MutableList? = null, val imperial: Boolean = false) + + private fun startRangeExceededJob(triggerType: ReminderTrigger): Job { + return CoroutineScope(Dispatchers.IO).launch { + val preferences = applicationContext.dataStore.data.map { remindersJson -> + try { + Json.decodeFromString>( + 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() } - delay(1_000) - applicationContext.sendBroadcast(intent) + 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 - if (reminder.tone != ReminderBeepPattern.NO_TONES){ - karooSystem.dispatch(PlayBeepPattern(reminder.tone.tones)) - mediaPlayer.start() + 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") } - karooSystem.dispatch( - InRideAlert( - id = "reminder-${reminder.id}-${elapsedMinutes}", - detail = reminder.text, - title = reminder.name, - autoDismissMs = if(reminder.isAutoDismiss) reminder.autoDismissSeconds * 1000L else null, - icon = R.drawable.ic_launcher, - textColor = R.color.white, - backgroundColor = reminder.getResourceColor(applicationContext) - ), + + 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) + ) + ) ) - - val delayMs = if(reminder.isAutoDismiss) reminder.autoDismissSeconds * 1000L else 20000L - delay(delayMs) } } } } override fun onDestroy() { - serviceJob?.cancel() - serviceJob = null + jobs.forEach { job -> job.cancel() } + jobs.clear() + karooSystem.disconnect() super.onDestroy() } diff --git a/app/src/main/kotlin/de/timklge/karooreminder/Throttle.kt b/app/src/main/kotlin/de/timklge/karooreminder/Throttle.kt new file mode 100644 index 0000000..ded0199 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooreminder/Throttle.kt @@ -0,0 +1,31 @@ +package de.timklge.karooreminder + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +fun Flow.throttle(timeout: Long): Flow = flow { + var lastEmissionTime = 0L + + collect { value -> + val currentTime = System.currentTimeMillis() + if (currentTime - lastEmissionTime >= timeout) { + emit(value) + lastEmissionTime = currentTime + } + } +} + +fun Flow.onlyIfNValuesReceivedWithinTimeframe(n: Int, timeframe: Long): Flow = flow { + val lastValuesReceivedAt = mutableListOf() + + collect { value -> + val currentTime = System.currentTimeMillis() + + lastValuesReceivedAt.removeAll { it + timeframe < currentTime } + lastValuesReceivedAt.add(currentTime) + + if (lastValuesReceivedAt.size >= n) { + emit(value) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooreminder/screens/DetailScreen.kt b/app/src/main/kotlin/de/timklge/karooreminder/screens/DetailScreen.kt index 93ee67f..700a410 100644 --- a/app/src/main/kotlin/de/timklge/karooreminder/screens/DetailScreen.kt +++ b/app/src/main/kotlin/de/timklge/karooreminder/screens/DetailScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.maxkeppeker.sheets.core.models.base.UseCaseState import com.maxkeppeler.sheets.color.ColorDialog 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.MultipleColors 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.ReminderTrigger +import de.timklge.karooreminder.streamUserProfile import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.models.PlayBeepPattern +import io.hammerhead.karooext.models.UserProfile @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -80,9 +86,16 @@ fun DetailScreen(isCreating: Boolean, reminder: Reminder, onSubmit: (updatedRemi var toneDialogVisible by remember { mutableStateOf(false) } var selectedTone by remember { mutableStateOf(reminder.tone) } 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, - 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 .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) + 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(), onValueChange = { duration = it }, - label = { Text("Interval") }, - suffix = { Text("min") }, + label = { + 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), 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.bBlue), 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), ), ) ) diff --git a/app/src/main/kotlin/de/timklge/karooreminder/screens/MainScreen.kt b/app/src/main/kotlin/de/timklge/karooreminder/screens/MainScreen.kt index 0d3778e..0dd1186 100644 --- a/app/src/main/kotlin/de/timklge/karooreminder/screens/MainScreen.kt +++ b/app/src/main/kotlin/de/timklge/karooreminder/screens/MainScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.unit.dp import androidx.core.graphics.toColor import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -55,7 +56,9 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import de.timklge.karooreminder.KarooReminderExtension import de.timklge.karooreminder.dataStore +import de.timklge.karooreminder.streamUserProfile import io.hammerhead.karooext.KarooSystemService +import io.hammerhead.karooext.models.UserProfile import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch @@ -153,6 +156,8 @@ fun MainScreen(reminders: MutableList, onNavigateToReminder: (r: Remin val karooSystem = remember { KarooSystemService(ctx) } var showWarnings by remember { mutableStateOf(false) } + val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null) + LaunchedEffect(Unit) { delay(1000L) showWarnings = true @@ -199,7 +204,7 @@ fun MainScreen(reminders: MutableList, onNavigateToReminder: (r: Remin 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)}") } } } diff --git a/app/src/main/kotlin/de/timklge/karooreminder/screens/Reminder.kt b/app/src/main/kotlin/de/timklge/karooreminder/screens/Reminder.kt index 82bc727..52b1646 100644 --- a/app/src/main/kotlin/de/timklge/karooreminder/screens/Reminder.kt +++ b/app/src/main/kotlin/de/timklge/karooreminder/screens/Reminder.kt @@ -3,6 +3,7 @@ package de.timklge.karooreminder.screens import android.content.Context import androidx.core.content.ContextCompat import de.timklge.karooreminder.R +import de.timklge.karooreminder.ReminderTrigger import io.hammerhead.karooext.models.PlayBeepPattern import kotlinx.serialization.Serializable 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"), 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){ + 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 { return when(foregroundColor){ 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.bBlue) -> R.color.bBlue 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") } } diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 2c60d05..9360ad3 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -4,10 +4,19 @@ #2600B3 #ffffff + #000000 + #700000 #007000 #000070 #700070 #707000 #007070 + + #FF6060 + #50FF50 + #7070FF + #FF70FF + #FFFF60 + #60FFFF \ No newline at end of file diff --git a/ICON_LICENSE b/icon-credits.md similarity index 100% rename from ICON_LICENSE rename to icon-credits.md