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