initial commit

This commit is contained in:
Tretzi
2026-05-04 20:30:54 -05:00
commit 7ed43fab5e
24 changed files with 1385 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# OpenClaw build helper
commit-message.txt
# Local SDK + Gradle caches
.android-sdk/
.gradle/
# Android/Gradle outputs
**/build/
dist/
# Local config
local.properties
# IDE
.idea/
*.iml

249
BuildTool.sh Executable file
View File

@@ -0,0 +1,249 @@
#!/usr/bin/env bash
set -Eeuo pipefail
APP_NAME="e-SUN"
MODULE="app"
API_LEVEL="35"
BUILD_TOOLS_VERSION="35.0.0"
DIST_DIR="dist"
COMMIT_MSG_FILE="commit-message.txt"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
export GRADLE_OPTS="${GRADLE_OPTS:-} -Dorg.gradle.daemon=false"
usage() {
cat <<EOF
Usage: ./BuildTool.sh [options]
Options:
--setup-only Check/install build environment (best-effort) and exit
--no-commit Build successfully but do not auto-commit
--apk-only Build debug APK only (default)
--with-aab Also build release AAB (only use when you explicitly want it)
--help Show this help
Behavior:
- Disables Gradle daemon
- Increments versionCode and versionName on every build
- Copies artifacts to $DIST_DIR/ with app name + versionCode
- Commits successful build changes using $COMMIT_MSG_FILE (clears after commit; fallback: "No Details")
EOF
}
SETUP_ONLY=false
DO_COMMIT=true
WITH_AAB=false
for arg in "$@"; do
case "$arg" in
--setup-only) SETUP_ONLY=true ;;
--no-commit) DO_COMMIT=false ;;
--apk-only) WITH_AAB=false ;;
--with-aab) WITH_AAB=true ;;
--help|-h) usage; exit 0 ;;
*) echo "Unknown option: $arg" >&2; usage; exit 1 ;;
esac
done
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "Missing required command: $1" >&2
exit 1
}
}
ensure_java() {
need_cmd java
local major
major="$(java -version 2>&1 | awk -F[\".] '/version/ {print $2}')"
if [[ "${major:-0}" -lt 17 ]]; then
echo "Java 17+ is required. Found Java ${major:-unknown}." >&2
exit 1
fi
}
ensure_android_sdk() {
if [[ -z "${ANDROID_HOME:-}" ]]; then
export ANDROID_HOME="$ROOT_DIR/.android-sdk"
fi
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"
if [[ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]]; then
need_cmd curl
need_cmd unzip
mkdir -p "$ANDROID_HOME/cmdline-tools"
local zip_path="$ANDROID_HOME/cmdline-tools/commandlinetools.zip"
echo "Downloading Android command line tools..."
curl -L -o "$zip_path" \
"https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
rm -rf "$ANDROID_HOME/cmdline-tools/latest" "$ANDROID_HOME/cmdline-tools/cmdline-tools"
unzip -q "$zip_path" -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
rm "$zip_path"
fi
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-$API_LEVEL" \
"build-tools;$BUILD_TOOLS_VERSION" >/dev/null
}
ensure_gradle_wrapper() {
if [[ ! -x "./gradlew" ]]; then
if [[ -f "./gradlew" ]]; then
chmod +x ./gradlew
else
echo "Missing Gradle wrapper ./gradlew. Please add/commit it first." >&2
exit 1
fi
fi
}
gradle_no_daemon() {
./gradlew --no-daemon -Dorg.gradle.daemon=false "$@"
}
ensure_gitignore_entries() {
touch .gitignore
for entry in \
"$COMMIT_MSG_FILE" \
".android-sdk/" \
".gradle/" \
"$DIST_DIR/" \
"local.properties" \
"**/build/" \
"*.iml" \
".idea/" \
; do
grep -qxF "$entry" .gitignore || echo "$entry" >> .gitignore
done
}
increment_versions() {
local gradle_file="$MODULE/build.gradle.kts"
if [[ ! -f "$gradle_file" ]]; then
echo "Cannot find $gradle_file" >&2
exit 1
fi
python3 - "$gradle_file" <<'PY'
import re
import sys
from pathlib import Path
path = Path(sys.argv[1])
text = path.read_text()
m_code = re.search(r'\bversionCode\s*=\s*(\d+)', text)
m_name = re.search(r'\bversionName\s*=\s*"(\d+(?:\.\d+)*)"', text)
if not m_code:
raise SystemExit('versionCode = <number> not found')
if not m_name:
raise SystemExit('versionName = "<number>" not found (must be numeric, e.g. "1" or "1.0")')
old_code = int(m_code.group(1))
new_code = old_code + 1
old_name = m_name.group(1)
parts = old_name.split('.')
parts[-1] = str(int(parts[-1]) + 1)
new_name = '.'.join(parts)
text = re.sub(r'(\bversionCode\s*=\s*)\d+', r'\g<1>%d' % new_code, text, count=1)
text = re.sub(r'(\bversionName\s*=\s*)"\d+(?:\.\d+)*"', r'\g<1>"%s"' % new_name, text, count=1)
path.write_text(text)
print(new_code)
PY
}
build_artifacts() {
gradle_no_daemon clean testDebugUnitTest assembleDebug
if [[ "$WITH_AAB" == true ]]; then
gradle_no_daemon bundleRelease
fi
}
copy_artifacts() {
local version_code="$1"
mkdir -p "$DIST_DIR"
local debug_apk
debug_apk="$(find "$MODULE/build/outputs/apk/debug" -name "*.apk" | head -n 1 || true)"
if [[ -z "$debug_apk" || ! -f "$debug_apk" ]]; then
echo "Debug APK not found." >&2
exit 1
fi
cp "$debug_apk" "$DIST_DIR/${APP_NAME}-v${version_code}-debug.apk"
echo "Created: $DIST_DIR/${APP_NAME}-v${version_code}-debug.apk"
if [[ "$WITH_AAB" == true ]]; then
local release_aab
release_aab="$(find "$MODULE/build/outputs/bundle/release" -name "*.aab" | head -n 1 || true)"
if [[ -z "$release_aab" || ! -f "$release_aab" ]]; then
echo "Release AAB not found." >&2
exit 1
fi
cp "$release_aab" "$DIST_DIR/${APP_NAME}-v${version_code}-release.aab"
echo "Created: $DIST_DIR/${APP_NAME}-v${version_code}-release.aab"
fi
}
commit_successful_build() {
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
echo "Not a git repo; skipping commit." >&2
return
}
local message
if [[ -s "$COMMIT_MSG_FILE" ]]; then
message="$(cat "$COMMIT_MSG_FILE")"
else
message="No Details"
fi
git add -A
if git diff --cached --quiet; then
echo "No changes to commit."
else
git commit -m "$message"
fi
: > "$COMMIT_MSG_FILE"
}
main() {
need_cmd python3
ensure_java
ensure_android_sdk
ensure_gradle_wrapper
ensure_gitignore_entries
if [[ "$SETUP_ONLY" == true ]]; then
echo "Build environment setup complete."
exit 0
fi
local version_code
version_code="$(increment_versions)"
build_artifacts
copy_artifacts "$version_code"
if [[ "$DO_COMMIT" == true ]]; then
commit_successful_build
fi
echo "Build completed successfully for versionCode $version_code."
}
main "$@"

52
README.md Normal file
View File

@@ -0,0 +1,52 @@
# e-SUN Android App
Native Kotlin + Jetpack Compose app (API 35) that provides a light-therapy style full-screen white display based on Kelvin presets.
## First Increment Scope
- 3 editable temperature presets (default 3000K / 4500K / 6000K)
- 3 editable timer presets (default 5 / 10 / 30 min)
- Kelvin-to-RGB approximation for screen color
- Full-screen Shine mode with:
- Keep-screen-on
- Temporary in-app brightness override
- Controls shown on touch and auto-hidden after 5s
- Timer end returns to main screen
- Brightness persistence (default 50% on first launch)
- Placeholder ad section (`AD HERE`)
- Unit test coverage for Kelvin conversion output range
## BuildTool.sh
The project intentionally runs Gradle with daemon disabled (`--no-daemon` and `org.gradle.daemon=false`).
### Options
```bash
./BuildTool.sh --setup-only
./BuildTool.sh --debug
./BuildTool.sh --release
./BuildTool.sh --all
```
### Behavior
- `--setup-only`: prepares wrapper/dependencies without building release artifacts.
- `--debug`: increments version code/name, builds debug APK, renames output to include app name and version code.
- `--release`: increments version code/name, builds release AAB, renames output similarly.
- `--all`: runs debug + release flow in one run.
- After successful build tasks, script commits all changes with message from `commit-message.txt` (fallback: `No Details`) and then clears that file.
## Upload Key (Release Signing)
For this first increment, release uses default debug signing unless custom signing is added.
Recommended future setup:
1. Generate upload key:
```bash
keytool -genkeypair -v -keystore upload-keystore.jks -alias upload -keyalg RSA -keysize 2048 -validity 10000
```
2. Store credentials in local, untracked file (e.g. `keystore.properties`).
3. Reference it from `app/build.gradle.kts` signingConfigs.
4. Extend `BuildTool.sh` to validate presence before release builds.
## Logging
App logs key interactions and state updates through tags:
- `ESunMainViewModel`
- `ESunShine`
This keeps debugging actionable without excessive noise.

72
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,72 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.esun"
compileSdk = 35
defaultConfig {
applicationId = "com.esun"
minSdk = 26
targetSdk = 35
versionCode = 16
versionName = "16"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.activity:activity-compose:1.9.3")
implementation(platform("androidx.compose:compose-bom:2024.11.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
// Needed for Theme.Material3.* resources referenced from themes.xml
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.datastore:datastore-preferences:1.1.1")
testImplementation("junit:junit:4.13.2")
testImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2024.11.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

1
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1 @@
# Intentionally minimal for first increment.

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@android:drawable/sym_def_app_icon"
android:label="e-SUN"
android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true"
android:theme="@style/Theme.ESun">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,65 @@
package com.esun
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 com.esun
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 com.esun
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 com.esun
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 com.esun
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 com.esun
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 com.esun
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 com.esun
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 com.esun
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 com.esun
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() })
}

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">e-SUN</string>
</resources>

View File

@@ -0,0 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.ESun" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
<item name="android:windowLightNavigationBar" tools:targetApi="o_mr1">false</item>
</style>
</resources>

View File

@@ -0,0 +1,14 @@
package com.esun
import org.junit.Assert.assertTrue
import org.junit.Test
class TemperatureColorTest {
@Test
fun rgbRangeIsValid() {
val (r, g, b) = kelvinToRgb(4500)
assertTrue(r in 0f..1f)
assertTrue(g in 0f..1f)
assertTrue(b in 0f..1f)
}
}

6
build.gradle.kts Normal file
View File

@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.7.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
// Required for Kotlin 2.0+ when Compose is enabled
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
}

4
gradle.properties Normal file
View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
org.gradle.daemon=false

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

19
gradlew vendored Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
PROPS="$ROOT_DIR/gradle/wrapper/gradle-wrapper.properties"
DIST_URL=$(grep '^distributionUrl=' "$PROPS" | cut -d= -f2- | sed 's#\\:#:#g')
TMP_ZIP="/tmp/gradle-dist.zip"
TMP_DIR="/tmp/gradle-dist"
rm -rf "$TMP_DIR" "$TMP_ZIP"
mkdir -p "$TMP_DIR"
curl -fsSL "$DIST_URL" -o "$TMP_ZIP"
unzip -q "$TMP_ZIP" -d "$TMP_DIR"
GRADLE_BIN=$(find "$TMP_DIR" -type f -path '*/bin/gradle' | head -1)
if [ -z "$GRADLE_BIN" ]; then
echo "Could not locate gradle binary in distribution" >&2
exit 1
fi
exec "$GRADLE_BIN" "$@"

18
settings.gradle.kts Normal file
View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "e-SUN"
include(":app")