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:
parent
ee15466517
commit
19249e640e
@ -4,7 +4,7 @@
|
||||

|
||||

|
||||
|
||||
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).
|
||||
|
||||

|
||||

|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
60
app/src/main/kotlin/de/timklge/karooreminder/Dropdown.kt
Normal file
60
app/src/main/kotlin/de/timklge/karooreminder/Dropdown.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<RideState> {
|
||||
removeConsumer(listenerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
|
||||
return callbackFlow {
|
||||
val listenerId = addConsumer { userProfile: UserProfile ->
|
||||
trySendBlocking(userProfile)
|
||||
}
|
||||
awaitClose {
|
||||
removeConsumer(listenerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<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() {
|
||||
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<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 ->
|
||||
try {
|
||||
Json.decodeFromString<MutableList<Reminder>>(
|
||||
@ -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<Reminder>? = 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<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()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
31
app/src/main/kotlin/de/timklge/karooreminder/Throttle.kt
Normal file
31
app/src/main/kotlin/de/timklge/karooreminder/Throttle.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@ -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<Reminder>, 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<Reminder>, 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)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,10 +4,19 @@
|
||||
<color name="colorPrimaryDark">#2600B3</color>
|
||||
|
||||
<color name="white">#ffffff</color>
|
||||
<color name="black">#000000</color>
|
||||
|
||||
<color name="bRed">#700000</color>
|
||||
<color name="bGreen">#007000</color>
|
||||
<color name="bBlue">#000070</color>
|
||||
<color name="bPurple">#700070</color>
|
||||
<color name="bYellow">#707000</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>
|
||||
Loading…
x
Reference in New Issue
Block a user