From 7ed43fab5e890b9d01433b660e0edc112542a5c9 Mon Sep 17 00:00:00 2001 From: Tretzi Date: Mon, 4 May 2026 20:30:54 -0500 Subject: [PATCH] initial commit --- .gitignore | 17 ++ BuildTool.sh | 249 ++++++++++++++++ README.md | 52 ++++ app/build.gradle.kts | 72 +++++ app/proguard-rules.pro | 1 + app/src/main/AndroidManifest.xml | 23 ++ app/src/main/java/com/esun/AppPreferences.kt | 65 +++++ app/src/main/java/com/esun/AppState.kt | 10 + app/src/main/java/com/esun/AppUi.kt | 267 ++++++++++++++++++ app/src/main/java/com/esun/FullScreenShine.kt | 140 +++++++++ app/src/main/java/com/esun/MainActivity.kt | 19 ++ app/src/main/java/com/esun/MainViewModel.kt | 68 +++++ app/src/main/java/com/esun/NightLight.kt | 48 ++++ .../main/java/com/esun/PresetRowAndDialog.kt | 237 ++++++++++++++++ .../main/java/com/esun/TemperatureColor.kt | 30 ++ app/src/main/java/com/esun/UiHelpers.kt | 10 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 8 + .../java/com/esun/TemperatureColorTest.kt | 14 + build.gradle.kts | 6 + gradle.properties | 4 + gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 19 ++ settings.gradle.kts | 18 ++ 24 files changed, 1385 insertions(+) create mode 100644 .gitignore create mode 100755 BuildTool.sh create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/esun/AppPreferences.kt create mode 100644 app/src/main/java/com/esun/AppState.kt create mode 100644 app/src/main/java/com/esun/AppUi.kt create mode 100644 app/src/main/java/com/esun/FullScreenShine.kt create mode 100644 app/src/main/java/com/esun/MainActivity.kt create mode 100644 app/src/main/java/com/esun/MainViewModel.kt create mode 100644 app/src/main/java/com/esun/NightLight.kt create mode 100644 app/src/main/java/com/esun/PresetRowAndDialog.kt create mode 100644 app/src/main/java/com/esun/TemperatureColor.kt create mode 100644 app/src/main/java/com/esun/UiHelpers.kt create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/test/java/com/esun/TemperatureColorTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18832fe --- /dev/null +++ b/.gitignore @@ -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 diff --git a/BuildTool.sh b/BuildTool.sh new file mode 100755 index 0000000..ab0ce9b --- /dev/null +++ b/BuildTool.sh @@ -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 <&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 = not found') +if not m_name: + raise SystemExit('versionName = "" 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 "$@" diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f50544 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..6b21ceb --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..1a01c86 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1 @@ +# Intentionally minimal for first increment. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8824ea2 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/esun/AppPreferences.kt b/app/src/main/java/com/esun/AppPreferences.kt new file mode 100644 index 0000000..d3e31a2 --- /dev/null +++ b/app/src/main/java/com/esun/AppPreferences.kt @@ -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 = 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 } + } +} diff --git a/app/src/main/java/com/esun/AppState.kt b/app/src/main/java/com/esun/AppState.kt new file mode 100644 index 0000000..97dfd6f --- /dev/null +++ b/app/src/main/java/com/esun/AppState.kt @@ -0,0 +1,10 @@ +package com.esun + +data class AppState( + val temperatures: List = listOf(3000, 4500, 6000), + val timersMinutes: List = listOf(5, 10, 30), + // Require explicit user selection before enabling Shine. + val selectedTemperature: Int? = null, + val selectedTimerMinutes: Int? = null, + val brightness: Float = 0.5f +) diff --git a/app/src/main/java/com/esun/AppUi.kt b/app/src/main/java/com/esun/AppUi.kt new file mode 100644 index 0000000..786ba7d --- /dev/null +++ b/app/src/main/java/com/esun/AppUi.kt @@ -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(null) } + var editTimer by remember { mutableStateOf(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" +} diff --git a/app/src/main/java/com/esun/FullScreenShine.kt b/app/src/main/java/com/esun/FullScreenShine.kt new file mode 100644 index 0000000..3e98a30 --- /dev/null +++ b/app/src/main/java/com/esun/FullScreenShine.kt @@ -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) +} diff --git a/app/src/main/java/com/esun/MainActivity.kt b/app/src/main/java/com/esun/MainActivity.kt new file mode 100644 index 0000000..c139b20 --- /dev/null +++ b/app/src/main/java/com/esun/MainActivity.kt @@ -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) } + } +} diff --git a/app/src/main/java/com/esun/MainViewModel.kt b/app/src/main/java/com/esun/MainViewModel.kt new file mode 100644 index 0000000..db36428 --- /dev/null +++ b/app/src/main/java/com/esun/MainViewModel.kt @@ -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 = _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 create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return MainViewModel(prefs) as T + } + } +} diff --git a/app/src/main/java/com/esun/NightLight.kt b/app/src/main/java/com/esun/NightLight.kt new file mode 100644 index 0000000..c693e07 --- /dev/null +++ b/app/src/main/java/com/esun/NightLight.kt @@ -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() + } +} diff --git a/app/src/main/java/com/esun/PresetRowAndDialog.kt b/app/src/main/java/com/esun/PresetRowAndDialog.kt new file mode 100644 index 0000000..fb905cf --- /dev/null +++ b/app/src/main/java/com/esun/PresetRowAndDialog.kt @@ -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, + 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, + 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, + 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") + } + } +} diff --git a/app/src/main/java/com/esun/TemperatureColor.kt b/app/src/main/java/com/esun/TemperatureColor.kt new file mode 100644 index 0000000..e8220a6 --- /dev/null +++ b/app/src/main/java/com/esun/TemperatureColor.kt @@ -0,0 +1,30 @@ +package com.esun + +import kotlin.math.ln +import kotlin.math.pow + +fun kelvinToRgb(kelvin: Int): Triple { + 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() + ) +} diff --git a/app/src/main/java/com/esun/UiHelpers.kt b/app/src/main/java/com/esun/UiHelpers.kt new file mode 100644 index 0000000..f2aa847 --- /dev/null +++ b/app/src/main/java/com/esun/UiHelpers.kt @@ -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() }) + } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1940249 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + e-SUN + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4fbc585 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + diff --git a/app/src/test/java/com/esun/TemperatureColorTest.kt b/app/src/test/java/com/esun/TemperatureColorTest.kt new file mode 100644 index 0000000..7c2f736 --- /dev/null +++ b/app/src/test/java/com/esun/TemperatureColorTest.kt @@ -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) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a81d3ab --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..57c46fa --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +org.gradle.daemon=false diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1e2fbf0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..b6a7388 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..e6d3f0f --- /dev/null +++ b/settings.gradle.kts @@ -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")