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:
@@ -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 }
|
||||
}
|
||||
}
|
||||
10
app/src/main/java/solutions/tretter/esunandroid/AppState.kt
Normal file
10
app/src/main/java/solutions/tretter/esunandroid/AppState.kt
Normal 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
|
||||
)
|
||||
267
app/src/main/java/solutions/tretter/esunandroid/AppUi.kt
Normal file
267
app/src/main/java/solutions/tretter/esunandroid/AppUi.kt
Normal 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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
10
app/src/main/java/solutions/tretter/esunandroid/UiHelpers.kt
Normal file
10
app/src/main/java/solutions/tretter/esunandroid/UiHelpers.kt
Normal 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() })
|
||||
}
|
||||
Reference in New Issue
Block a user