Rename app package to solutions.tretter.esunandroid

- Update Gradle namespace + applicationId
- Move Kotlin sources + tests to new package path
- Update package declarations across app
- Build debug APK v17
This commit is contained in:
Tretzi
2026-05-04 20:34:59 -05:00
parent 7ed43fab5e
commit e4610b3cd2
12 changed files with 15 additions and 15 deletions

View File

@@ -0,0 +1,65 @@
package solutions.tretter.esunandroid
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore(name = "esun_prefs")
class AppPreferences(private val context: Context) {
private val tempKeys = listOf(
intPreferencesKey("temp_1"),
intPreferencesKey("temp_2"),
intPreferencesKey("temp_3")
)
private val timerKeys = listOf(
intPreferencesKey("timer_1"),
intPreferencesKey("timer_2"),
intPreferencesKey("timer_3")
)
private val brightnessKey = floatPreferencesKey("brightness")
private val selectedTemperatureKey = intPreferencesKey("selected_temperature")
private val selectedTimerMinutesKey = intPreferencesKey("selected_timer_minutes")
val state: Flow<AppState> = context.dataStore.data.map { prefs ->
val defaults = AppState()
val temps = tempKeys.mapIndexed { i, key -> prefs[key] ?: defaults.temperatures[i] }
val timers = timerKeys.mapIndexed { i, key -> prefs[key] ?: defaults.timersMinutes[i] }
val savedTemp = prefs[selectedTemperatureKey]
val savedTimer = prefs[selectedTimerMinutesKey]
AppState(
temperatures = temps,
timersMinutes = timers,
selectedTemperature = savedTemp?.takeIf { it in temps },
selectedTimerMinutes = savedTimer?.takeIf { it in timers },
brightness = prefs[brightnessKey] ?: defaults.brightness
)
}
suspend fun saveTemperature(index: Int, value: Int) {
context.dataStore.edit { it[tempKeys[index]] = value }
}
suspend fun saveTimer(index: Int, value: Int) {
context.dataStore.edit { it[timerKeys[index]] = value }
}
suspend fun saveBrightness(value: Float) {
context.dataStore.edit { it[brightnessKey] = value }
}
suspend fun saveSelectedTemperature(value: Int) {
context.dataStore.edit { it[selectedTemperatureKey] = value }
}
suspend fun saveSelectedTimerMinutes(value: Int) {
context.dataStore.edit { it[selectedTimerMinutesKey] = value }
}
}

View File

@@ -0,0 +1,10 @@
package solutions.tretter.esunandroid
data class AppState(
val temperatures: List<Int> = listOf(3000, 4500, 6000),
val timersMinutes: List<Int> = listOf(5, 10, 30),
// Require explicit user selection before enabling Shine.
val selectedTemperature: Int? = null,
val selectedTimerMinutes: Int? = null,
val brightness: Float = 0.5f
)

View File

@@ -0,0 +1,267 @@
package solutions.tretter.esunandroid
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import android.content.ActivityNotFoundException
import android.content.Intent
import android.database.ContentObserver
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import android.provider.Settings
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun ESunApp(vm: MainViewModel) {
val ui by vm.ui.collectAsState()
val context = LocalContext.current
var editTemp by remember { mutableStateOf<Int?>(null) }
var editTimer by remember { mutableStateOf<Int?>(null) }
var isShining by remember { mutableStateOf(false) }
if (isShining) {
FullScreenShine(
kelvin = ui.selectedTemperature ?: ui.temperatures.firstOrNull() ?: 4500,
brightness = ui.brightness,
timerMinutes = ui.selectedTimerMinutes ?: ui.timersMinutes.firstOrNull() ?: 10,
onBrightnessChange = vm::setBrightness,
onClose = { isShining = false }
)
return
}
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Header()
SectionLabel("Temperature")
PresetRow(
labels = ui.temperatures.map { "${it}K" },
selectedIndex = ui.selectedTemperature?.let { sel -> ui.temperatures.indexOf(sel) } ?: -1,
onClick = { vm.selectTemperature(ui.temperatures[it]) },
onLongClick = { editTemp = it }
)
SectionLabel("Timer")
PresetRow(
labels = ui.timersMinutes.map { formatMinutes(it) },
selectedIndex = ui.selectedTimerMinutes?.let { sel -> ui.timersMinutes.indexOf(sel) } ?: -1,
onClick = { vm.selectTimer(ui.timersMinutes[it]) },
onLongClick = { editTimer = it }
)
// Place warning right before the Start button.
val nightLightOn = rememberNightLightState()
if (nightLightOn) {
NightLightWarning(
onOpenSettings = {
val pm = context.packageManager
fun startSafe(base: Intent): Boolean {
val intent = Intent(base).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return try {
if (intent.resolveActivity(pm) == null) return false
context.startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
false
} catch (_: SecurityException) {
false
}
}
val opened = startSafe(NightLight.nightLightSettingsIntent()) ||
startSafe(NightLight.displaySettingsIntent())
if (!opened) {
Toast
.makeText(context, "Couldn't open display settings on this device.", Toast.LENGTH_LONG)
.show()
}
}
)
}
Button(
onClick = { isShining = true },
enabled = ui.selectedTemperature != null && ui.selectedTimerMinutes != null,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFFEB3B),
contentColor = Color.Black
),
modifier = Modifier.fillMaxWidth()
) {
Text("Shine", fontWeight = FontWeight.Bold)
}
Column(
modifier = Modifier
.fillMaxWidth()
.height(72.dp)
.border(1.dp, Color.Gray)
.background(Color(0xFFF5F5F5)),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("AD HERE", style = MaterialTheme.typography.titleMedium)
}
}
editTemp?.let { idx ->
SliderDialog(
title = "Edit Temperature",
value = ui.temperatures[idx].toFloat(),
valueRange = 2000f..8000f,
steps = ((8000 - 2000) / 100) - 1,
valueLabel = { v -> "${((v / 100).toInt() * 100)}K" },
onDismiss = { editTemp = null },
onSave = {
vm.updatePresetTemperature(idx, (it / 100).toInt() * 100)
editTemp = null
}
)
}
editTimer?.let { idx ->
TimerPickerDialog(
title = "Edit Timer",
initialTotalMinutes = ui.timersMinutes[idx],
onDismiss = { editTimer = null },
onSave = {
vm.updatePresetTimer(idx, it)
editTimer = null
}
)
}
}
@Composable
private fun rememberNightLightState(): Boolean {
val context = LocalContext.current
var enabled by remember { mutableStateOf(NightLight.isAndroidNightLightEnabled(context)) }
DisposableEffect(context) {
val cr = context.contentResolver
val handler = Handler(Looper.getMainLooper())
val observer = object : ContentObserver(handler) {
override fun onChange(selfChange: Boolean) {
enabled = NightLight.isAndroidNightLightEnabled(context)
}
}
val uris = listOf(
Settings.Secure.getUriFor("night_display_activated"),
Settings.System.getUriFor("blue_light_filter"),
Settings.System.getUriFor("blue_light_filter_enabled")
)
uris.forEach { uri -> cr.registerContentObserver(uri, false, observer) }
onDispose {
cr.unregisterContentObserver(observer)
}
}
return enabled
}
@Composable
private fun NightLightWarning(onOpenSettings: () -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF3E0)),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = "Blue light filter (Night Light / Eye comfort shield) is ON",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF5D4037)
)
Text(
text = "This can shift the lamp colors. For accurate Kelvin, turn it off while using Shine.",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF5D4037)
)
Text(
text = "Open Night Light settings",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF0D47A1),
modifier = Modifier.clickable { onOpenSettings() }
)
}
}
}
@Composable
private fun Header() {
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.Black)
.padding(vertical = 12.dp, horizontal = 12.dp)
) {
Text(
text = "☀️",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.align(Alignment.CenterStart),
color = Color(0xFFFFEB3B)
)
Text(
text = "e-SUN",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic,
color = Color(0xFFFFEB3B)
)
}
}
@Composable
private fun SectionLabel(text: String) {
Text(text = text, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
private fun formatMinutes(minutes: Int): String {
val h = minutes / 60
val m = minutes % 60
return if (h == 0) "${m} Min" else "${h}h ${m}m"
}

View File

@@ -0,0 +1,140 @@
package solutions.tretter.esunandroid
import android.app.Activity
import android.util.Log
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import kotlinx.coroutines.delay
@Composable
fun FullScreenShine(
kelvin: Int,
brightness: Float,
timerMinutes: Int,
onBrightnessChange: (Float) -> Unit,
onClose: () -> Unit
) {
val color = kelvinToColor(kelvin)
val activity = LocalContext.current as Activity
var controlsVisible by remember { mutableStateOf(true) }
// Keep local state so UI + window brightness update immediately while persisting via VM.
var localBrightness by remember(brightness) { mutableStateOf(brightness) }
val logTag = "ESunShine"
BackHandler { onClose() }
DisposableEffect(Unit) {
val window = activity.window
val originalBrightness = window.attributes.screenBrightness
// Fullscreen (hide status/navigation bars)
WindowCompat.setDecorFitsSystemWindows(window, false)
val controller = WindowInsetsControllerCompat(window, window.decorView)
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
// Keep awake + set brightness ONLY for shine mode.
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setWindowBrightness(activity, localBrightness)
Log.i(logTag, "Shine mode start kelvin=$kelvin timer=$timerMinutes")
onDispose {
// Restore UI + brightness
controller.show(WindowInsetsCompat.Type.systemBars())
WindowCompat.setDecorFitsSystemWindows(window, true)
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.attributes = window.attributes.apply { screenBrightness = originalBrightness }
Log.i(logTag, "Shine mode end")
}
}
// Apply brightness immediately on every slider change.
LaunchedEffect(localBrightness) {
setWindowBrightness(activity, localBrightness)
}
LaunchedEffect(timerMinutes) {
delay(timerMinutes * 60_000L)
onClose()
}
LaunchedEffect(controlsVisible) {
if (controlsVisible) {
delay(5000)
controlsVisible = false
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(color)
.noRippleTap { controlsVisible = true }
) {
if (controlsVisible) {
Column(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.End
) {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.Black)
}
Text(text = "Brightness", color = Color.Black)
Slider(
value = localBrightness,
onValueChange = {
localBrightness = it
onBrightnessChange(it)
},
valueRange = 0.05f..1f,
modifier = Modifier.padding(horizontal = 8.dp)
)
}
}
}
}
private fun setWindowBrightness(activity: Activity, value: Float) {
val lp = activity.window.attributes
lp.screenBrightness = value.coerceIn(0.01f, 1f)
activity.window.attributes = lp
}
private fun kelvinToColor(kelvin: Int): Color {
val (r, g, b) = kelvinToRgb(kelvin)
return Color(red = r, green = g, blue = b)
}

View File

@@ -0,0 +1,19 @@
package solutions.tretter.esunandroid
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
class MainActivity : ComponentActivity() {
private val vm: MainViewModel by viewModels {
MainViewModel.Factory(AppPreferences(applicationContext))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { ESunApp(vm) }
}
}

View File

@@ -0,0 +1,68 @@
package solutions.tretter.esunandroid
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class MainViewModel(private val prefs: AppPreferences) : ViewModel() {
private val tag = "ESunMainViewModel"
private val _ui = MutableStateFlow(AppState())
val ui: StateFlow<AppState> = _ui.asStateFlow()
init {
viewModelScope.launch {
prefs.state.collect {
_ui.update { current ->
current.copy(
temperatures = it.temperatures,
timersMinutes = it.timersMinutes,
brightness = it.brightness
)
}
}
}
}
fun selectTemperature(value: Int) {
Log.i(tag, "selectTemperature=$value")
_ui.update { it.copy(selectedTemperature = value) }
viewModelScope.launch { prefs.saveSelectedTemperature(value) }
}
fun selectTimer(value: Int) {
Log.i(tag, "selectTimer=$value")
_ui.update { it.copy(selectedTimerMinutes = value) }
viewModelScope.launch { prefs.saveSelectedTimerMinutes(value) }
}
fun updatePresetTemperature(index: Int, value: Int) {
val v = value.coerceIn(2000, 8000)
Log.i(tag, "updatePresetTemperature index=$index value=$v")
viewModelScope.launch { prefs.saveTemperature(index, v) }
}
fun updatePresetTimer(index: Int, value: Int) {
val v = value.coerceIn(1, 99 * 60)
Log.i(tag, "updatePresetTimer index=$index value=$v")
viewModelScope.launch { prefs.saveTimer(index, v) }
}
fun setBrightness(value: Float) {
val v = value.coerceIn(0.05f, 1f)
_ui.update { it.copy(brightness = v) }
viewModelScope.launch { prefs.saveBrightness(v) }
}
class Factory(private val prefs: AppPreferences) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return MainViewModel(prefs) as T
}
}
}

View File

@@ -0,0 +1,48 @@
package solutions.tretter.esunandroid
import android.content.Context
import android.content.Intent
import android.provider.Settings
/**
* Best-effort detection of Android's built-in Night Light ("Night display").
*
* Note: OEM-specific blue light filters may not be detectable.
*/
object NightLight {
fun isAndroidNightLightEnabled(context: Context): Boolean {
val cr = context.contentResolver
// AOSP Night Light
if (readSecureInt(cr, "night_display_activated") == 1) return true
// Samsung "Eye comfort shield" / blue light filter (best-effort; OEM keys may vary)
// Common keys observed on Samsung builds:
// - blue_light_filter (0/1)
// NOTE: We intentionally do NOT use *_opacity as a signal, because devices may keep the
// last-used opacity value even when the feature is turned off.
if (readSystemInt(cr, "blue_light_filter") == 1) return true
// Some builds use a different boolean key name.
if (readSystemInt(cr, "blue_light_filter_enabled") == 1) return true
return false
}
fun nightLightSettingsIntent(): Intent {
// Night Light settings screen (may not exist on all OEMs)
return Intent(Settings.ACTION_NIGHT_DISPLAY_SETTINGS)
}
fun displaySettingsIntent(): Intent {
return Intent(Settings.ACTION_DISPLAY_SETTINGS)
}
private fun readSecureInt(cr: android.content.ContentResolver, key: String): Int? {
return runCatching { Settings.Secure.getInt(cr, key) }.getOrNull()
}
private fun readSystemInt(cr: android.content.ContentResolver, key: String): Int? {
return runCatching { Settings.System.getInt(cr, key) }.getOrNull()
}
}

View File

@@ -0,0 +1,237 @@
package solutions.tretter.esunandroid
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PresetRow(
labels: List<String>,
selectedIndex: Int,
onClick: (Int) -> Unit,
onLongClick: (Int) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
labels.forEachIndexed { index, label ->
val selected = index == selectedIndex
val bg = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent
val fg = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
val border = if (selected) Color.Transparent else MaterialTheme.colorScheme.outline
Box(
modifier = Modifier
.weight(1f)
.border(1.dp, border, shape = MaterialTheme.shapes.medium)
.background(bg, shape = MaterialTheme.shapes.medium)
.combinedClickable(
onClick = { onClick(index) },
onLongClick = { onLongClick(index) }
)
// Keep chips compact so labels like "3000K" don't get clipped on narrow screens.
.padding(vertical = 8.dp, horizontal = 4.dp),
contentAlignment = Alignment.Center
) {
Text(
text = label,
color = fg,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Clip,
softWrap = false
)
}
}
}
}
@Composable
fun SliderDialog(
title: String,
value: Float,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int,
valueLabel: (Float) -> String,
onDismiss: () -> Unit,
onSave: (Float) -> Unit
) {
var current by remember(value) { mutableFloatStateOf(value) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
Column {
Text(valueLabel(current))
Slider(
value = current,
onValueChange = { current = it },
valueRange = valueRange,
steps = steps
)
}
}
},
confirmButton = {
TextButton(onClick = { onSave(current) }) { Text("Save") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@Composable
fun TimerPickerDialog(
title: String,
initialTotalMinutes: Int,
onDismiss: () -> Unit,
onSave: (totalMinutes: Int) -> Unit
) {
val initialHours = (initialTotalMinutes / 60).coerceIn(0, 99)
val initialMinutes = (initialTotalMinutes % 60).coerceIn(0, 59)
var hTens by remember(initialTotalMinutes) { mutableIntStateOf(initialHours / 10) }
var hOnes by remember(initialTotalMinutes) { mutableIntStateOf(initialHours % 10) }
var mTens by remember(initialTotalMinutes) { mutableIntStateOf(initialMinutes / 10) }
var mOnes by remember(initialTotalMinutes) { mutableIntStateOf(initialMinutes % 10) }
val hours = (hTens * 10 + hOnes).coerceIn(0, 99)
val minutes = (mTens * 10 + mOnes).coerceIn(0, 59)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
verticalAlignment = Alignment.CenterVertically
) {
DigitStepper(
digit = hTens,
allowed = (0..9).toList(),
onDigitChange = { hTens = it },
contentDescription = "Hours tens"
)
DigitStepper(
digit = hOnes,
allowed = (0..9).toList(),
onDigitChange = { hOnes = it },
contentDescription = "Hours ones"
)
Text(":", style = MaterialTheme.typography.headlineMedium)
DigitStepper(
digit = mTens,
allowed = (0..5).toList(),
onDigitChange = { mTens = it },
contentDescription = "Minutes tens"
)
DigitStepper(
digit = mOnes,
allowed = (0..9).toList(),
onDigitChange = { mOnes = it },
contentDescription = "Minutes ones"
)
}
},
confirmButton = {
TextButton(onClick = { onSave(hours * 60 + minutes) }) { Text("Save") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@Composable
private fun DigitStepper(
digit: Int,
allowed: List<Int>,
onDigitChange: (Int) -> Unit,
contentDescription: String
) {
var expanded by remember { mutableStateOf(false) }
val idx = allowed.indexOf(digit).let { if (it >= 0) it else 0 }
val upDigit = allowed[(idx + 1) % allowed.size]
val downDigit = allowed[(idx - 1 + allowed.size) % allowed.size]
Column(horizontalAlignment = Alignment.CenterHorizontally) {
IconButton(onClick = { onDigitChange(upDigit) }) {
Icon(Icons.Filled.KeyboardArrowUp, contentDescription = "Increase $contentDescription")
}
Box(contentAlignment = Alignment.Center) {
// Fixed-size digit hit-target so digits never get clipped.
Box(
modifier = Modifier
.size(width = 40.dp, height = 52.dp)
.clickable { expanded = true },
contentAlignment = Alignment.Center
) {
Text(
digit.toString(),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Clip,
softWrap = false
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
allowed.forEach { d ->
DropdownMenuItem(
text = { Text(d.toString()) },
onClick = {
onDigitChange(d)
expanded = false
}
)
}
}
}
IconButton(onClick = { onDigitChange(downDigit) }) {
Icon(Icons.Filled.KeyboardArrowDown, contentDescription = "Decrease $contentDescription")
}
}
}

View File

@@ -0,0 +1,30 @@
package solutions.tretter.esunandroid
import kotlin.math.ln
import kotlin.math.pow
fun kelvinToRgb(kelvin: Int): Triple<Float, Float, Float> {
val temp = (kelvin.coerceIn(1000, 40000) / 100.0)
val red = when {
temp <= 66 -> 255.0
else -> 329.698727446 * (temp - 60).pow(-0.1332047592)
}
val green = when {
temp <= 66 -> 99.4708025861 * ln(temp) - 161.1195681661
else -> 288.1221695283 * (temp - 60).pow(-0.0755148492)
}
val blue = when {
temp >= 66 -> 255.0
temp <= 19 -> 0.0
else -> 138.5177312231 * ln(temp - 10) - 305.0447927307
}
return Triple(
(red.coerceIn(0.0, 255.0) / 255.0).toFloat(),
(green.coerceIn(0.0, 255.0) / 255.0).toFloat(),
(blue.coerceIn(0.0, 255.0) / 255.0).toFloat()
)
}

View File

@@ -0,0 +1,10 @@
package solutions.tretter.esunandroid
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
fun Modifier.noRippleTap(onTap: () -> Unit): Modifier =
pointerInput(Unit) {
detectTapGestures(onTap = { onTap() })
}