initial commit
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal 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
249
BuildTool.sh
Executable 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
52
README.md
Normal 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
72
app/build.gradle.kts
Normal 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
1
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1 @@
|
||||
# Intentionally minimal for first increment.
|
||||
23
app/src/main/AndroidManifest.xml
Normal file
23
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
65
app/src/main/java/com/esun/AppPreferences.kt
Normal file
65
app/src/main/java/com/esun/AppPreferences.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
10
app/src/main/java/com/esun/AppState.kt
Normal file
10
app/src/main/java/com/esun/AppState.kt
Normal 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
|
||||
)
|
||||
267
app/src/main/java/com/esun/AppUi.kt
Normal file
267
app/src/main/java/com/esun/AppUi.kt
Normal 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"
|
||||
}
|
||||
140
app/src/main/java/com/esun/FullScreenShine.kt
Normal file
140
app/src/main/java/com/esun/FullScreenShine.kt
Normal 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)
|
||||
}
|
||||
19
app/src/main/java/com/esun/MainActivity.kt
Normal file
19
app/src/main/java/com/esun/MainActivity.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
68
app/src/main/java/com/esun/MainViewModel.kt
Normal file
68
app/src/main/java/com/esun/MainViewModel.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/src/main/java/com/esun/NightLight.kt
Normal file
48
app/src/main/java/com/esun/NightLight.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
237
app/src/main/java/com/esun/PresetRowAndDialog.kt
Normal file
237
app/src/main/java/com/esun/PresetRowAndDialog.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
30
app/src/main/java/com/esun/TemperatureColor.kt
Normal file
30
app/src/main/java/com/esun/TemperatureColor.kt
Normal 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()
|
||||
)
|
||||
}
|
||||
10
app/src/main/java/com/esun/UiHelpers.kt
Normal file
10
app/src/main/java/com/esun/UiHelpers.kt
Normal 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() })
|
||||
}
|
||||
3
app/src/main/res/values/strings.xml
Normal file
3
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">e-SUN</string>
|
||||
</resources>
|
||||
8
app/src/main/res/values/themes.xml
Normal file
8
app/src/main/res/values/themes.xml
Normal 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>
|
||||
14
app/src/test/java/com/esun/TemperatureColorTest.kt
Normal file
14
app/src/test/java/com/esun/TemperatureColorTest.kt
Normal 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
6
build.gradle.kts
Normal 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
4
gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
org.gradle.daemon=false
|
||||
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
19
gradlew
vendored
Executable 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
18
settings.gradle.kts
Normal 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")
|
||||
Reference in New Issue
Block a user