diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8c29ce1..c0dfd5e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,21 +1,36 @@
name: SonarCloud
on:
- push:
- branches:
- - main
- - develop
+ push: { }
pull_request:
types: [ opened, synchronize, reopened ]
jobs:
build:
name: Build and analyze
+ permissions:
+ contents: read
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
+
+ - name: Create google-services.json from secret
+ run: |
+ set -e
+ mkdir -p app
+ printf '%s' "$GOOGLE_SERVICES_JSON" > google-services.json
+ printf '%s' "$GOOGLE_SERVICES_JSON" > app/google-services.json
+ env:
+ GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
+ - name: Create mock DarkThemeHandler
+ run: |
+ set -e
+ mkdir -p app/src/main/java/dev/lexip/hecate/util || true
+ printf '%s' "$MOCK_DARK_THEME_HANDLER" > app/src/main/java/dev/lexip/hecate/util/DarkThemeHandler.kt
+ env:
+ MOCK_DARK_THEME_HANDLER: ${{ secrets.MOCK_DARK_THEME_HANDLER }}
- name: Set up JDK 23
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
java-version: 23
distribution: 'zulu'
diff --git a/.gitignore b/.gitignore
index 2c94f50..30793d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,7 +30,13 @@ render.experimental.xml
# Google Services (e.g. APIs or Firebase)
google-services.json
+# Trade secret
+app/src/main/java/dev/lexip/hecate/util/DarkThemeHandler.kt
+
# Android Profiling
*.hprof
-app/release/*
+# Builds
+app/release/
+app/debug/
+app/beta/
\ No newline at end of file
diff --git a/FUNDING.yml b/FUNDING.yml
new file mode 100644
index 0000000..6df9a3a
--- /dev/null
+++ b/FUNDING.yml
@@ -0,0 +1 @@
+buy_me_a_coffee: lexip
\ No newline at end of file
diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md
new file mode 100644
index 0000000..5d2aacf
--- /dev/null
+++ b/PRIVACY_POLICY.md
@@ -0,0 +1,87 @@
+# Privacy Policy — Adaptive Theme (aka. Hecate)
+
+**Last updated:** 2025-10-31
+
+## 1. Overview
+
+Adaptive Theme (`dev.lexip.hecate`) (the “App”). This Privacy Policy explains what
+information the App collects, how it is used and shared, and your choices. This policy
+applies to the App distributed via Google Play and any in-app disclosures.
+
+- App name: Adaptive Theme
+- Package name: `dev.lexip.hecate`
+- Publisher: X. Lexip — x@lexip.dev
+
+## 2. Core principles
+
+We follow a minimal-data principle. The App is designed to work without collecting personal
+information. When we do collect technical or diagnostic data it is strictly limited to what is
+necessary to run, diagnose, and improve the App.
+
+## 3. Data we may collect and why
+
+### 3.1 No personal information
+
+The App does not collect personal information (name, email, account ID, payment information) as part
+of normal usage. The only time personal information may be provided is if you voluntarily send it to
+us (for example, an email when contacting support).
+
+### 3.2 Device, diagnostics, and usage data
+
+We use Firebase services (Firebase Analytics and Firebase Crashlytics) to collect aggregated and
+diagnostic usage information. Examples of data sent to Firebase include:
+
+- Crash stacks and related device metadata (Android version, device model, app version)
+- Anonymous analytics events for app usage patterns
+
+This data helps us fix crashes, improve stability, and improve user experience. Firebase is operated
+by Google; data processing by Firebase is subject to Google’s privacy policies. You can review
+Firebase’s privacy docs here: https://firebase.google.com/support/privacy
+
+By using the App you explicitly consent to the collection, processing, and transfer of the
+analytics and diagnostic data described above by Firebase Analytics and Firebase Crashlytics. If you
+do not agree to this processing, please do not use the App; alternatively contact us at
+x@lexip.dev and we'll advise on available options.
+
+### 3.3 Sensitive capability: WRITE_SECURE_SETTINGS
+
+The App implements features that require the Android capability `WRITE_SECURE_SETTINGS`. Important
+points:
+
+- `WRITE_SECURE_SETTINGS` cannot be granted by a normal runtime permission prompt. It must be
+ granted externally (for example, with ADB or via an enterprise device-management policy).
+- The App does not request or obtain this capability silently. If the capability is not granted the
+ App will disable the related features and show an explanatory message to the user.
+- We use this capability only to perform the specific feature described in the UI: modify the system
+ theme (adaptive theme behavior). We do not use it to collect or transmit personal data.
+
+## 4. Analytics, crash reporting, and third parties
+
+We use Firebase Analytics and Firebase Crashlytics. These services process data on our behalf and
+are contractually limited to that purpose. They may collect aggregated and device-level diagnostic
+information. We do not sell user data or share it with other third parties for their own independent
+use.
+
+## 5. Data retention and deletion
+
+- Crash reports and analytics data are retained by Firebase according to Firebase retention
+ policies. Aggregated or anonymized analytics data may be kept indefinitely for product
+ improvement.
+- If you wish to request deletion of data associated with you, contact us at x@lexip.dev with a
+ clear description of the request. We'll respond and take reasonable steps to comply, subject to
+ any legal obligations to retain certain records.
+
+## 6. Security
+
+We implement reasonable technical and organizational measures to protect data we process. However,
+no method of transmission or storage is completely secure. If you believe your data has been
+compromised, contact us immediately at x@lexip.dev.
+
+## 7. International data transfers
+
+Firebase (Google) and other service providers may process data in countries outside your own. By
+using the App you consent to the transfer and processing described in this policy.
+
+## 8. Changes to this privacy policy
+
+We may update this Privacy Policy. When we do, we will update the “Last updated” date above.
\ No newline at end of file
diff --git a/README.md b/README.md
index c118dbe..2fdccb2 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,42 @@
-[](https://sonarcloud.io/summary/new_code?id=xLexip_Hecate)
\ No newline at end of file
+[](https://lexip.dev/hecate/play)
+
+
+
+## Adaptive Theme
+
+Adaptive Theme intelligently switches your device between Light and Dark mode based on your
+environment.
+
+Get the readability of Light mode in bright daylight and the comfort of Dark mode in low light —
+going easy on your eyes and your battery.
+
+---
+
+### Highlights
+
+🌤️ **Smart Detection**: Uses your ambient light sensor to switch themes automatically.
+
+⚙️ **Full Control**: Fully customizable brightness threshold and a Quick Settings tile to
+pause/resume the service.
+
+🔒 **Free & Open**: Free to use, no ads and open source.
+
+🚀 **Native Design**: Modern architecture, built with Jetpack Compose and Material You for a seamless
+Android experience.
+
+🚫 **No Flickering**: The theme only changes when you turn on screen and the device is uncovered.
+
+---
+
+### One-Time Setup
+
+To toggle the system theme, Android requires the permission `WRITE_SECURE_SETTINGS`. This is safe,
+transparent and fully reversible. The app will guide you through the setup process.
+
+---
+
+That’s it! Set your preference, and never worry about your light/dark mode again.
+
+🇩🇪 Made with 🥨 🍺 in Germany.
+
+[](https://sonarcloud.io/summary/new_code?id=xLexip_Hecate)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index cf24241..d0e6742 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,21 +1,22 @@
-import org.gradle.api.JavaVersion.VERSION_23
-
plugins {
+ id("com.google.gms.google-services")
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.google.firebase.crashlytics)
}
android {
namespace = "dev.lexip.hecate"
compileSdk = 36
+ buildToolsVersion = "36.0.0"
defaultConfig {
applicationId = "dev.lexip.hecate"
minSdk = 31
targetSdk = 36
- versionCode = 1
- versionName = "0.1.0"
+ versionCode = 36
+ versionName = "0.7.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -27,19 +28,48 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
+ ndk {
+ debugSymbolLevel = "FULL"
+ }
+ }
+ debug {
+ versionNameSuffix = "-debug"
+ isDebuggable = true
+ ndk {
+ debugSymbolLevel = "FULL"
+ }
}
+ create("beta") {
+ initWith(getByName("release"))
+ versionNameSuffix = "-beta"
+ isDebuggable = false
+ }
+
}
+
compileOptions {
- sourceCompatibility = VERSION_23
- targetCompatibility = VERSION_23
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
- kotlinOptions {
- jvmTarget = "23"
+
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
}
+
+ bundle {
+ language {
+ @Suppress("UnstableApiUsage")
+ enableSplit = false
+ }
+ }
+
buildFeatures {
compose = true
+ buildConfig = true
}
- buildToolsVersion = "35.0.0"
+
sourceSets {
getByName("main") {
resources {
@@ -50,6 +80,9 @@ android {
}
dependencies {
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.analytics)
+ implementation(libs.firebase.crashlytics)
implementation(libs.androidx.localbroadcastmanager)
implementation(libs.androidx.core.splashscreen.v100)
implementation(libs.androidx.activity.compose)
@@ -65,6 +98,8 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.material)
implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.compose.material.icons.extended)
+ implementation(libs.app.update.ktx)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/app/src/androidTest/java/dev/lexip/hecate/ExampleInstrumentedTest.kt b/app/src/androidTest/java/dev/lexip/hecate/ExampleInstrumentedTest.kt
deleted file mode 100644
index afba040..0000000
--- a/app/src/androidTest/java/dev/lexip/hecate/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package dev.lexip.hecate
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import org.junit.Assert.*
-import org.junit.Test
-import org.junit.runner.RunWith
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("dev.lexip.hecate", appContext.packageName)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a94fca8..489edef 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -14,24 +14,42 @@
xmlns:android="http://schemas.android.com/apk/res/android"
tools:ignore="ProtectedPermissions">
-
+
+
+
+
+ android:windowSoftInputMode="adjustResize"
+ android:localeConfig="@xml/locales_config"
+ tools:targetApi="33">
+
+
+
+
+
+
+
-
-
@@ -55,6 +73,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
index 4139987..c5cae40 100644
Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/dev/lexip/hecate/HecateApplication.kt b/app/src/main/java/dev/lexip/hecate/HecateApplication.kt
index 9953443..6b27ccc 100644
--- a/app/src/main/java/dev/lexip/hecate/HecateApplication.kt
+++ b/app/src/main/java/dev/lexip/hecate/HecateApplication.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2024 xLexip
+ * Copyright (C) 2024-2025 xLexip
*
* Licensed under the GNU General Public License, Version 3.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
+import dev.lexip.hecate.analytics.AnalyticsGate
const val USER_PREFERENCES_NAME = "user_preferences"
private val Context.dataStore by preferencesDataStore(USER_PREFERENCES_NAME)
@@ -28,4 +29,9 @@ class HecateApplication : Application() {
*/
val userPreferencesDataStore: DataStore
get() = this.dataStore
+
+ override fun onCreate() {
+ super.onCreate()
+ AnalyticsGate.init(this)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsGate.kt b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsGate.kt
new file mode 100644
index 0000000..f79b7d4
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsGate.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2025 xLexip
+ *
+ * Licensed under the GNU General Public License, Version 3.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.gnu.org/licenses/gpl-3.0
+ *
+ * Please see the License for specific terms regarding permissions and limitations.
+ */
+
+package dev.lexip.hecate.analytics
+
+import android.content.Context
+import com.google.firebase.analytics.FirebaseAnalytics
+
+/**
+ * Controls whether analytics collection is enabled.
+ * - App is not debuggable (release-like)
+ * - App is installed from Play Store
+ */
+object AnalyticsGate {
+ @Volatile
+ private var enabled = false
+
+ fun init(context: Context) {
+ val pm = context.packageManager
+ val installer = try {
+ pm.getInstallSourceInfo(context.packageName).installingPackageName
+ ?: pm.getInstallSourceInfo(context.packageName).initiatingPackageName
+ } catch (_: Exception) {
+ null
+ }
+ val isGooglePlayInstall = installer == "com.android.vending"
+ enabled = isGooglePlayInstall
+ FirebaseAnalytics.getInstance(context).setAnalyticsCollectionEnabled(enabled)
+ }
+
+ fun allowed(): Boolean = enabled
+}
diff --git a/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt
new file mode 100644
index 0000000..5f16fe8
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2025 xLexip
+ *
+ * Licensed under the GNU General Public License, Version 3.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.gnu.org/licenses/gpl-3.0
+ *
+ * Please see the License for specific terms regarding permissions and limitations.
+ */
+
+package dev.lexip.hecate.analytics
+
+import android.content.Context
+import com.google.firebase.analytics.FirebaseAnalytics
+import com.google.firebase.analytics.logEvent
+
+object AnalyticsLogger {
+
+ private fun analytics(context: Context): FirebaseAnalytics =
+ FirebaseAnalytics.getInstance(context)
+
+ private inline fun ifAllowed(block: () -> Unit) {
+ if (AnalyticsGate.allowed()) block()
+ }
+
+ fun logPermissionErrorShown(context: Context, reason: String, attemptedAction: String) {
+ ifAllowed {
+ analytics(context).logEvent("permission_error_shown") {
+ param("reason", reason)
+ param("attempted_action", attemptedAction)
+ }
+ }
+ }
+
+ fun logServiceEnabled(context: Context, source: String) {
+ ifAllowed {
+ analytics(context).logEvent("adaptive_service_enabled") {
+ param("source", source)
+ }
+ }
+ }
+
+ fun logServiceDisabled(context: Context, source: String) {
+ ifAllowed {
+ analytics(context).logEvent("adaptive_service_disabled") {
+ param("source", source)
+ }
+ }
+ }
+
+ fun logBrightnessThresholdChanged(
+ context: Context,
+ oldLux: Float,
+ newLux: Float
+ ) {
+ if (oldLux == newLux) return
+ ifAllowed {
+ analytics(context).logEvent("brightness_threshold_changed") {
+ param("old_lux", oldLux.toLong())
+ param("new_lux", newLux.toLong())
+ }
+ }
+ }
+
+ fun logQuickSettingsTileAdded(context: Context) {
+ ifAllowed {
+ analytics(context).logEvent("qs_tile_added") { }
+ }
+ }
+
+ fun logThemeSwitched(
+ context: Context,
+ targetMode: Int,
+ succeeded: Boolean
+ ) {
+ ifAllowed {
+ analytics(context).logEvent("theme_switched") {
+ param("target_mode", targetMode.toLong())
+ param("succeeded", if (succeeded) 1L else 0L)
+ }
+ }
+ }
+
+ fun logOverflowMenuItemClicked(context: Context, menuItem: String) {
+ ifAllowed {
+ analytics(context).logEvent("overflow_menu_item_clicked") {
+ param("menu_item", menuItem)
+ }
+ }
+ }
+
+ fun logViewWebsiteClicked(context: Context, source: String) {
+ ifAllowed {
+ analytics(context).logEvent("view_website_clicked") {
+ param("source", source)
+ }
+ }
+ }
+
+ fun logShareLinkClicked(context: Context, source: String) {
+ ifAllowed {
+ analytics(context).logEvent("share_link_clicked") {
+ param("source", source)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/lexip/hecate/broadcasts/BootCompletedReceiver.kt b/app/src/main/java/dev/lexip/hecate/broadcasts/BootCompletedReceiver.kt
index 2862e8c..8731c21 100644
--- a/app/src/main/java/dev/lexip/hecate/broadcasts/BootCompletedReceiver.kt
+++ b/app/src/main/java/dev/lexip/hecate/broadcasts/BootCompletedReceiver.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2024 xLexip
+ * Copyright (C) 2024-2025 xLexip
*
* Licensed under the GNU General Public License, Version 3.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
+import androidx.core.content.ContextCompat
import dev.lexip.hecate.services.BroadcastReceiverService
private const val TAG = "BootCompletedReceiver"
@@ -26,7 +27,7 @@ class BootCompletedReceiver : BroadcastReceiver() {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
Log.i(TAG, "Boot completed, starting broadcast receiver service...")
val serviceIntent = Intent(context, BroadcastReceiverService::class.java)
- context.startService(serviceIntent)
+ ContextCompat.startForegroundService(context, serviceIntent)
}
}
diff --git a/app/src/main/java/dev/lexip/hecate/broadcasts/ScreenOnReceiver.kt b/app/src/main/java/dev/lexip/hecate/broadcasts/ScreenOnReceiver.kt
index c523353..47583ff 100644
--- a/app/src/main/java/dev/lexip/hecate/broadcasts/ScreenOnReceiver.kt
+++ b/app/src/main/java/dev/lexip/hecate/broadcasts/ScreenOnReceiver.kt
@@ -30,7 +30,7 @@ class ScreenOnReceiver(
private val proximitySensorManager: ProximitySensorManager,
private val lightSensorManager: LightSensorManager,
private val darkThemeHandler: DarkThemeHandler,
- private val adaptiveThemeThresholdLux: Float
+ var adaptiveThemeThresholdLux: Float
) : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
diff --git a/app/src/main/java/dev/lexip/hecate/data/AdaptiveThreshold.kt b/app/src/main/java/dev/lexip/hecate/data/AdaptiveThreshold.kt
new file mode 100644
index 0000000..bea10f2
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/data/AdaptiveThreshold.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2025 xLexip
+ *
+ * Licensed under the GNU General Public License, Version 3.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.gnu.org/licenses/gpl-3.0
+ *
+ * Please see the License for specific terms regarding permissions and limitations.
+ */
+
+package dev.lexip.hecate.data
+
+import dev.lexip.hecate.R
+
+enum class AdaptiveThreshold(val labelRes: Int, val lux: Float) {
+ DARK(R.string.adaptive_threshold_dark, 0f),
+ DIM(R.string.adaptive_threshold_dim, 1f),
+ SOFT(R.string.adaptive_threshold_soft, 10f),
+ BRIGHT(R.string.adaptive_threshold_bright, 100f),
+ DAYLIGHT(R.string.adaptive_threshold_daylight, 1_000f),
+ SUNLIGHT(R.string.adaptive_threshold_sunlight, 10_000f);
+
+ companion object {
+ fun fromIndex(index: Int): AdaptiveThreshold {
+ val i = index.coerceIn(0, entries.size - 1)
+ return entries[i]
+ }
+
+ fun fromLux(lux: Float): AdaptiveThreshold {
+ val exact = entries.firstOrNull { it.lux == lux }
+ if (exact != null) return exact
+ return entries.minByOrNull { kotlin.math.abs(it.lux - lux) } ?: SOFT
+ }
+ }
+}
diff --git a/app/src/main/java/dev/lexip/hecate/data/UserPreferencesRepository.kt b/app/src/main/java/dev/lexip/hecate/data/UserPreferencesRepository.kt
index 12a0265..e0b6a99 100644
--- a/app/src/main/java/dev/lexip/hecate/data/UserPreferencesRepository.kt
+++ b/app/src/main/java/dev/lexip/hecate/data/UserPreferencesRepository.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2024 xLexip
+ * Copyright (C) 2024-2025 xLexip
*
* Licensed under the GNU General Public License, Version 3.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -29,7 +29,9 @@ private const val TAG = "UserPreferencesRepository"
data class UserPreferences(
val adaptiveThemeEnabled: Boolean,
- val adaptiveThemeThresholdLux: Float
+ val adaptiveThemeThresholdLux: Float,
+ val customAdaptiveThemeThresholdLux: Float? = null,
+ val permissionWizardCompleted: Boolean = false
)
class UserPreferencesRepository(private val dataStore: DataStore) {
@@ -37,6 +39,9 @@ class UserPreferencesRepository(private val dataStore: DataStore) {
private object PreferencesKeys {
val ADAPTIVE_THEME_ENABLED = booleanPreferencesKey("adaptive_theme_enabled")
val ADAPTIVE_THEME_THRESHOLD_LUX = floatPreferencesKey("adaptive_theme_threshold_lux")
+ val CUSTOM_ADAPTIVE_THEME_THRESHOLD_LUX =
+ floatPreferencesKey("custom_adaptive_theme_threshold_lux")
+ val PERMISSION_WIZARD_COMPLETED = booleanPreferencesKey("permission_wizard_completed")
}
val userPreferencesFlow: Flow = dataStore.data
@@ -55,13 +60,30 @@ class UserPreferencesRepository(private val dataStore: DataStore) {
suspend fun fetchInitialPreferences() =
mapUserPreferences(dataStore.data.first().toPreferences())
+
+ suspend fun ensureAdaptiveThemeThresholdDefault(default: Float = AdaptiveThreshold.DAYLIGHT.lux) {
+ dataStore.edit { preferences ->
+ if (preferences[PreferencesKeys.ADAPTIVE_THEME_THRESHOLD_LUX] == null) {
+ preferences[PreferencesKeys.ADAPTIVE_THEME_THRESHOLD_LUX] = default
+ }
+ }
+ }
+
private fun mapUserPreferences(preferences: Preferences): UserPreferences {
- // Get our show completed value, defaulting to false if not set:
- preferences[PreferencesKeys.ADAPTIVE_THEME_ENABLED] == true
val adaptiveThemeEnabled = preferences[PreferencesKeys.ADAPTIVE_THEME_ENABLED] == true
val adaptiveThemeThresholdLux =
- preferences[PreferencesKeys.ADAPTIVE_THEME_THRESHOLD_LUX] ?: 100f
- return UserPreferences(adaptiveThemeEnabled, adaptiveThemeThresholdLux)
+ preferences[PreferencesKeys.ADAPTIVE_THEME_THRESHOLD_LUX]
+ ?: AdaptiveThreshold.DAYLIGHT.lux
+ val customAdaptiveThemeThresholdLux =
+ preferences[PreferencesKeys.CUSTOM_ADAPTIVE_THEME_THRESHOLD_LUX]
+ val permissionWizardCompleted =
+ preferences[PreferencesKeys.PERMISSION_WIZARD_COMPLETED] == true
+ return UserPreferences(
+ adaptiveThemeEnabled = adaptiveThemeEnabled,
+ adaptiveThemeThresholdLux = adaptiveThemeThresholdLux,
+ customAdaptiveThemeThresholdLux = customAdaptiveThemeThresholdLux,
+ permissionWizardCompleted = permissionWizardCompleted
+ )
}
suspend fun updateAdaptiveThemeEnabled(enabled: Boolean) {
@@ -73,6 +95,26 @@ class UserPreferencesRepository(private val dataStore: DataStore) {
suspend fun updateAdaptiveThemeThresholdLux(lux: Float) {
dataStore.edit { preferences ->
preferences[PreferencesKeys.ADAPTIVE_THEME_THRESHOLD_LUX] = lux
+ preferences.remove(PreferencesKeys.CUSTOM_ADAPTIVE_THEME_THRESHOLD_LUX)
+ }
+ }
+
+ suspend fun updateCustomAdaptiveThemeThresholdLux(lux: Float) {
+ dataStore.edit { preferences ->
+ preferences[PreferencesKeys.ADAPTIVE_THEME_THRESHOLD_LUX] = lux
+ preferences[PreferencesKeys.CUSTOM_ADAPTIVE_THEME_THRESHOLD_LUX] = lux
+ }
+ }
+
+ suspend fun clearCustomAdaptiveThemeThreshold() {
+ dataStore.edit { preferences ->
+ preferences.remove(PreferencesKeys.CUSTOM_ADAPTIVE_THEME_THRESHOLD_LUX)
+ }
+ }
+
+ suspend fun updatePermissionWizardCompleted(completed: Boolean) {
+ dataStore.edit { preferences ->
+ preferences[PreferencesKeys.PERMISSION_WIZARD_COMPLETED] = completed
}
}
diff --git a/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt b/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt
index 65f9685..5438d9f 100644
--- a/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt
+++ b/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt
@@ -25,6 +25,7 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import dev.lexip.hecate.HecateApplication
import dev.lexip.hecate.R
+import dev.lexip.hecate.analytics.AnalyticsLogger
import dev.lexip.hecate.broadcasts.ScreenOnReceiver
import dev.lexip.hecate.data.UserPreferencesRepository
import dev.lexip.hecate.util.DarkThemeHandler
@@ -33,15 +34,15 @@ import dev.lexip.hecate.util.ProximitySensorManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
private const val TAG = "BroadcastReceiverService"
private const val NOTIFICATION_CHANNEL_ID = "ForegroundServiceChannel"
+private const val ACTION_STOP_SERVICE = "dev.lexip.hecate.action.STOP_SERVICE"
private var screenOnReceiver: ScreenOnReceiver? = null
-private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
-
class BroadcastReceiverService : Service() {
// Utils
@@ -49,13 +50,56 @@ class BroadcastReceiverService : Service() {
private lateinit var lightSensorManager: LightSensorManager
private lateinit var proximitySensorManager: ProximitySensorManager
- override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
+ // Service-bound scope
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ super.onStartCommand(intent, flags, startId)
+
+ if (intent == null) {
+ Log.w(
+ TAG,
+ "onStartCommand called with null intent; likely system restart. Proceeding safely."
+ )
+ }
+
+ // Initialize data store
+ val dataStore = (this.applicationContext as HecateApplication).userPreferencesDataStore
+
+ // Handle stop action from notification
+ if (intent?.action == ACTION_STOP_SERVICE) {
+ Log.i(
+ TAG,
+ "Disable action received from notification - disabling adaptive theme and stopping service..."
+ )
+ serviceScope.launch {
+ try {
+ val userPreferencesRepository = UserPreferencesRepository(dataStore)
+ userPreferencesRepository.updateAdaptiveThemeEnabled(false)
+ Log.i(TAG, "Adaptive theme disabled via notification action.")
+ AnalyticsLogger.logServiceDisabled(
+ applicationContext,
+ source = "notification_action"
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to update adaptive theme preference", e)
+ }
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ return START_NOT_STICKY
+ }
+
Log.i(TAG, "Service starting...")
initializeUtils()
+ // Start foreground immediately to comply with O+ requirements
+ createNotificationChannel()
+ val initialNotification = buildNotification()
+ startForeground(1, initialNotification)
+
// Load user preferences from data store
- val dataStore = (this.applicationContext as HecateApplication).userPreferencesDataStore
- applicationScope.launch {
+ serviceScope.launch {
val userPreferencesRepository = UserPreferencesRepository(dataStore)
val userPreferences = userPreferencesRepository.fetchInitialPreferences()
@@ -66,15 +110,17 @@ class BroadcastReceiverService : Service() {
// Abort service start when there is no receiver to handle
if (screenOnReceiver == null) {
- Log.d(TAG, "No receiver to handle, service start aborted.")
+ Log.d(TAG, "No receiver to handle, stopping foreground and self.")
+ stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
- } else {
- // Create service notification and channel
- createNotificationChannel()
- val notification = buildNotification()
+ }
+ }
- // Start the service in the foreground
- startForeground(1, notification)
+ // Collect preference updates while service runs
+ serviceScope.launch {
+ val userPreferencesRepository = UserPreferencesRepository(dataStore)
+ userPreferencesRepository.userPreferencesFlow.collect { prefs ->
+ screenOnReceiver?.adaptiveThemeThresholdLux = prefs.adaptiveThemeThresholdLux
}
}
@@ -86,8 +132,14 @@ class BroadcastReceiverService : Service() {
Log.i(TAG, "Service is being destroyed...")
screenOnReceiver?.let {
Log.d(TAG, "Unregistering screen-on receiver...")
- unregisterReceiver(it)
+ try {
+ unregisterReceiver(it)
+ } catch (e: IllegalArgumentException) {
+ Log.w(TAG, "Receiver was not registered or already unregistered.", e)
+ }
}
+ screenOnReceiver = null
+ serviceScope.cancel()
}
override fun onBind(intent: Intent?): IBinder? {
@@ -109,23 +161,51 @@ class BroadcastReceiverService : Service() {
pendingIntent
).build()
+ // Create action to stop the service
+ val stopIntent = Intent(this, BroadcastReceiverService::class.java).apply {
+ action = ACTION_STOP_SERVICE
+ }
+ val stopPendingIntent = PendingIntent.getService(
+ this,
+ 0,
+ stopIntent,
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ val stopAction = NotificationCompat.Action.Builder(
+ 0,
+ getString(R.string.action_stop_service),
+ stopPendingIntent
+ ).build()
+
// Build notification
- return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
- .setContentTitle(getString(R.string.notification_service_running))
+ val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
+ .setContentTitle(getString(R.string.app_name))
+ .setContentText(getString(R.string.description_notification_service_running))
.setCategory(Notification.CATEGORY_SERVICE)
- .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setSmallIcon(R.drawable.ic_app)
+ .setOnlyAlertOnce(true)
.setContentIntent(pendingIntent)
.addAction(disableAction)
- .build()
+ .addAction(stopAction)
+ .setOngoing(true)
+
+
+ val notification = builder.build()
+ notification.flags =
+ notification.flags or Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR or Notification.FLAG_FOREGROUND_SERVICE
+
+ return notification
}
private fun createNotificationChannel() {
val serviceChannel = NotificationChannel(
- "ForegroundServiceChannel",
- getString(R.string.notification_channel_service),
- NotificationManager.IMPORTANCE_DEFAULT,
+ NOTIFICATION_CHANNEL_ID,
+ getString(R.string.title_notification_channel_service),
+ NotificationManager.IMPORTANCE_DEFAULT
+ )
+
+ serviceChannel.setSound(null, null) // Silent
- )
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(serviceChannel)
}
diff --git a/app/src/main/java/dev/lexip/hecate/services/QuickSettingsTileService.kt b/app/src/main/java/dev/lexip/hecate/services/QuickSettingsTileService.kt
new file mode 100644
index 0000000..21d6ad1
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/services/QuickSettingsTileService.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2025 xLexip
+ *
+ * Licensed under the GNU General Public License, Version 3.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.gnu.org/licenses/gpl-3.0
+ *
+ * Please see the License for specific terms regarding permissions and limitations.
+ */
+
+package dev.lexip.hecate.services
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import androidx.core.content.ContextCompat
+import dev.lexip.hecate.HecateApplication
+import dev.lexip.hecate.analytics.AnalyticsLogger
+import dev.lexip.hecate.data.UserPreferencesRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+
+private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+
+class QuickSettingsTileService : TileService() {
+
+ private fun hasWriteSecureSettingsPermission(): Boolean {
+ return packageManager.checkPermission(
+ Manifest.permission.WRITE_SECURE_SETTINGS,
+ packageName
+ ) == PackageManager.PERMISSION_GRANTED
+ }
+
+ override fun onTileAdded() {
+ super.onTileAdded()
+ AnalyticsLogger.logQuickSettingsTileAdded(applicationContext)
+ }
+
+ override fun onStartListening() {
+ super.onStartListening()
+ val tile = qsTile ?: return
+
+ // No permission => tile unavailable
+ if (!hasWriteSecureSettingsPermission()) {
+ tile.state = Tile.STATE_UNAVAILABLE
+ tile.updateTile()
+ return
+ }
+
+ // Load user preference and set tile state
+ val dataStore = (applicationContext as HecateApplication).userPreferencesDataStore
+ val repo = UserPreferencesRepository(dataStore)
+
+ serviceScope.launch {
+ val prefs = repo.fetchInitialPreferences()
+ tile.state = if (prefs.adaptiveThemeEnabled) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
+ tile.updateTile()
+ }
+ }
+
+ override fun onClick() {
+ super.onClick()
+ val tile = qsTile ?: return
+
+ // No permission => tile unavailable
+ if (!hasWriteSecureSettingsPermission()) {
+ tile.state = Tile.STATE_UNAVAILABLE
+ tile.updateTile()
+ return
+ }
+
+ val dataStore = (applicationContext as HecateApplication).userPreferencesDataStore
+ val repo = UserPreferencesRepository(dataStore)
+
+ // Toggle adaptive theme
+ serviceScope.launch {
+ val prefs = repo.fetchInitialPreferences()
+ val newEnabled = !prefs.adaptiveThemeEnabled
+
+ repo.updateAdaptiveThemeEnabled(newEnabled)
+
+ // Start/stop the service
+ val intent = Intent(applicationContext, BroadcastReceiverService::class.java)
+ if (newEnabled) {
+ repo.ensureAdaptiveThemeThresholdDefault()
+ ContextCompat.startForegroundService(applicationContext, intent)
+ AnalyticsLogger.logServiceEnabled(
+ applicationContext,
+ source = "quick_settings_tile"
+ )
+ } else {
+ applicationContext.stopService(intent)
+ AnalyticsLogger.logServiceDisabled(
+ applicationContext,
+ source = "quick_settings_tile"
+ )
+ }
+
+ // Update tile UI
+ tile.state = if (newEnabled) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
+ tile.updateTile()
+ }
+ }
+
+}
diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt
index 1fec2c3..38c7b60 100644
--- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt
+++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt
@@ -12,18 +12,27 @@
package dev.lexip.hecate.ui
+import android.Manifest
+import android.content.ActivityNotFoundException
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.provider.Settings
+import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.systemGestures
-import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -31,66 +40,529 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.Modifier
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.core.content.ContextCompat
+import androidx.core.net.toUri
+import androidx.lifecycle.viewmodel.compose.viewModel
+import dev.lexip.hecate.BuildConfig
import dev.lexip.hecate.R
-import dev.lexip.hecate.ui.components.SwitchPreferenceCard
+import dev.lexip.hecate.analytics.AnalyticsLogger
+import dev.lexip.hecate.data.AdaptiveThreshold
+import dev.lexip.hecate.ui.components.MainSwitchPreferenceCard
+import dev.lexip.hecate.ui.components.preferences.CustomThresholdDialog
+import dev.lexip.hecate.ui.components.preferences.ProgressDetailCard
+import dev.lexip.hecate.ui.components.preferences.SliderDetailCard
+import dev.lexip.hecate.ui.setup.PermissionSetupWizardScreen
+import dev.lexip.hecate.ui.setup.PermissionWizardStep
import dev.lexip.hecate.ui.theme.hecateTopAppBarColors
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+
+// Helper to share via Android Sharesheet
+private fun android.content.Context.shareSetupUrl(url: String) {
+ if (url.isBlank()) return
+
+ val sendIntent = Intent().apply {
+ action = Intent.ACTION_SEND
+ putExtra(Intent.EXTRA_TEXT, url)
+ putExtra(Intent.EXTRA_TITLE, "Setup - Adaptive Theme")
+ type = "text/plain"
+ }
+
+ val shareIntent = Intent.createChooser(sendIntent, null)
+ startActivity(shareIntent)
+
+}
+
+private val ScreenHorizontalMargin = 20.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdaptiveThemeScreen(
uiState: AdaptiveThemeUiState,
- updateAdaptiveThemeEnabled: (Boolean) -> Unit
+ onAboutClick: () -> Unit = {}
) {
- val scrollBehavior =
- TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
- val horizontalOffsetPadding = 4.dp
+ // Enable top-app-bar collapsing on small devices
+ val windowInfo = LocalWindowInfo.current
+ val density = androidx.compose.ui.platform.LocalDensity.current
+ val screenHeightDp = with(density) { windowInfo.containerSize.height.toDp().value }
+ val enableCollapsing = screenHeightDp < 700f
+ val scrollBehavior = if (enableCollapsing) {
+ TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
+ } else null
+
+ val horizontalOffsetPadding = 8.dp
+
+ val context = LocalContext.current
+ val haptic = LocalHapticFeedback.current
+ val packageName = context.packageName
+
+ val adaptiveThemeViewModel: AdaptiveThemeViewModel = viewModel(
+ factory = AdaptiveThemeViewModelFactory(
+ context.applicationContext as dev.lexip.hecate.HecateApplication,
+ dev.lexip.hecate.data.UserPreferencesRepository((context.applicationContext as dev.lexip.hecate.HecateApplication).userPreferencesDataStore),
+ dev.lexip.hecate.util.DarkThemeHandler(context)
+ )
+ )
+
+ val internalUiState by adaptiveThemeViewModel.uiState.collectAsState()
+
+ val showCustomDialog = remember { mutableStateOf(false) }
+
+ LaunchedEffect(adaptiveThemeViewModel) {
+ adaptiveThemeViewModel.uiEvents.collect { event ->
+ when (event) {
+ is UiEvent.CopyToClipboard -> {
+ val clipboard = context.getSystemService(ClipboardManager::class.java)
+ val clip = ClipData.newPlainText("ADB Command", event.text)
+ clipboard?.setPrimaryClip(clip)
+ }
+ }
+ }
+ }
+
Scaffold(
modifier = Modifier
- .nestedScroll(scrollBehavior.nestedScrollConnection),
+ .fillMaxSize()
+ .then(
+ if (scrollBehavior != null) {
+ Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
+ } else {
+ Modifier
+ }
+ ),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
topBar = {
+ val collapsedFraction = scrollBehavior?.state?.collapsedFraction ?: 0f
LargeTopAppBar(
modifier = Modifier
- .padding(horizontal = horizontalOffsetPadding)
+ .padding(start = ScreenHorizontalMargin - 8.dp)
.padding(top = 22.dp, bottom = 12.dp),
colors = hecateTopAppBarColors(),
title = {
Text(
text = stringResource(id = R.string.app_name),
- style = MaterialTheme.typography.displaySmall
+ style = if (collapsedFraction > 0.4f) {
+ MaterialTheme.typography.titleLarge
+ } else {
+ MaterialTheme.typography.displaySmall
+ }
)
},
+ actions = {
+ stringResource(id = R.string.error_no_email_client)
+ var menuExpanded by remember { mutableStateOf(false) }
+ androidx.compose.foundation.layout.Box {
+ IconButton(onClick = { menuExpanded = true }) {
+ Icon(
+ imageVector = Icons.Filled.MoreVert,
+ contentDescription = stringResource(id = R.string.title_more)
+ )
+ }
+ DropdownMenu(
+ expanded = menuExpanded,
+ onDismissRequest = { menuExpanded = false }
+ ) {
+ val feedbackSubject =
+ "Adaptive Theme Feedback (v${BuildConfig.VERSION_NAME})"
+
+ // 1) Custom Threshold
+ DropdownMenuItem(
+ text = { Text(text = stringResource(id = R.string.title_custom_threshold)) },
+ enabled = uiState.adaptiveThemeEnabled,
+ onClick = {
+ menuExpanded = false
+ AnalyticsLogger.logOverflowMenuItemClicked(
+ context,
+ "custom_threshold"
+ )
+ if (uiState.adaptiveThemeEnabled) {
+ showCustomDialog.value = true
+ }
+ }
+ )
+
+ // 2) Change Language (Android 13+)
+ if (android.os.Build.VERSION.SDK_INT >= 33) {
+ DropdownMenuItem(
+ text = { Text(text = stringResource(id = R.string.title_change_language)) },
+ onClick = {
+ menuExpanded = false
+ AnalyticsLogger.logOverflowMenuItemClicked(
+ context,
+ "change_language"
+ )
+ val intent =
+ Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply {
+ data = "package:$packageName".toUri()
+ }
+ context.startActivity(intent)
+ }
+ )
+ }
+
+ // 3) Send Feedback
+ DropdownMenuItem(
+ text = { Text(text = stringResource(id = R.string.title_send_feedback)) },
+ onClick = {
+ menuExpanded = false
+ AnalyticsLogger.logOverflowMenuItemClicked(
+ context,
+ "send_feedback"
+ )
+ val encodedSubject = URLEncoder.encode(
+ feedbackSubject,
+ StandardCharsets.UTF_8.toString()
+ )
+ val feedbackUri =
+ "https://lexip.dev/hecate/feedback?subject=$encodedSubject".toUri()
+ val feedbackIntent = Intent(Intent.ACTION_VIEW, feedbackUri)
+ context.startActivity(feedbackIntent)
+
+ }
+ )
+
+ // 3) Beta Feedback (only on beta builds)
+ if (BuildConfig.VERSION_NAME.contains("-beta")) {
+ DropdownMenuItem(
+ text = { Text(text = "Beta Feedback") },
+ onClick = {
+ menuExpanded = false
+ AnalyticsLogger.logOverflowMenuItemClicked(
+ context,
+ "beta_feedback"
+ )
+ val betaUri =
+ "https://play.google.com/store/apps/details?id=dev.lexip.hecate".toUri()
+ val betaIntent = Intent(Intent.ACTION_VIEW, betaUri)
+ context.startActivity(betaIntent)
+ }
+ )
+ }
+
+ // 4) About
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.title_about)) },
+ onClick = {
+ menuExpanded = false
+ AnalyticsLogger.logOverflowMenuItemClicked(context, "about")
+ val aboutUri = "https://lexip.dev/hecate/about".toUri()
+ val aboutIntent = Intent(Intent.ACTION_VIEW, aboutUri)
+ Toast.makeText(
+ context,
+ "v${BuildConfig.VERSION_NAME}",
+ Toast.LENGTH_SHORT
+ ).show()
+ try {
+ context.startActivity(aboutIntent)
+ } catch (_: ActivityNotFoundException) {
+ context.startActivity(Intent(Intent.ACTION_VIEW, aboutUri))
+ }
+ onAboutClick()
+ }
+ )
+ }
+ }
+ },
scrollBehavior = scrollBehavior
)
}
) { innerPadding ->
Column(
modifier = Modifier
- .windowInsetsPadding(WindowInsets.systemGestures.only(WindowInsetsSides.Horizontal))
.fillMaxSize()
.padding(innerPadding)
- .padding(horizontal = horizontalOffsetPadding)
+ .padding(horizontal = ScreenHorizontalMargin)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(32.dp)
) {
Text(
+ modifier = Modifier.padding(horizontal = horizontalOffsetPadding),
text = stringResource(id = R.string.description_adaptive_theme),
- style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 22.sp)
+ style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 21.sp)
)
- SwitchPreferenceCard(
- text = stringResource(
- id = R.string.action_use_adaptive_theme
- ),
+ MainSwitchPreferenceCard(
+ text = stringResource(id = R.string.action_use_adaptive_theme),
isChecked = uiState.adaptiveThemeEnabled,
- onCheckedChange = { checked -> updateAdaptiveThemeEnabled(checked) }
+ onCheckedChange = { checked ->
+ val hasPermission = ContextCompat.checkSelfPermission(
+ context, Manifest.permission.WRITE_SECURE_SETTINGS
+ ) == PackageManager.PERMISSION_GRANTED
+
+ adaptiveThemeViewModel.onServiceToggleRequested(
+ checked,
+ hasPermission,
+ packageName
+ ).also { wasToggled ->
+ if (wasToggled)
+ haptic.performHapticFeedback(
+ if (checked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff
+ )
+ else
+ haptic.performHapticFeedback(HapticFeedbackType.Reject)
+ }
+
+ }
+ )
+ val customLabel = stringResource(id = R.string.adaptive_threshold_custom)
+ val labels = adaptiveThemeViewModel.getDisplayLabels(
+ AdaptiveThreshold.entries.map { stringResource(id = it.labelRes) },
+ customLabel
)
+ val baseLux = AdaptiveThreshold.entries.map { it.lux }
+ val lux = adaptiveThemeViewModel.getDisplayLuxSteps(baseLux)
+ val currentLux by adaptiveThemeViewModel.currentSensorLuxFlow.collectAsState(initial = adaptiveThemeViewModel.currentSensorLux)
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ SliderDetailCard(
+ title = stringResource(id = R.string.title_brightness_threshold),
+ valueIndex = adaptiveThemeViewModel.getIndexForCurrentLux(),
+ steps = labels.size,
+ labels = labels,
+ lux = lux,
+ onValueChange = { index ->
+ adaptiveThemeViewModel.setPendingCustomSliderLux(lux[index])
+ adaptiveThemeViewModel.onSliderValueCommitted(index)
+ },
+ enabled = uiState.adaptiveThemeEnabled,
+ firstCard = true,
+ lastCard = false
+ )
+
+ ProgressDetailCard(
+ title = stringResource(id = R.string.title_current_brightness),
+ currentLux = currentLux,
+ luxSteps = lux,
+ enabled = uiState.adaptiveThemeEnabled,
+ firstCard = false,
+ lastCard = true
+ )
+
+ }
+ }
+ }
+
+ // Show permission wizard if needed
+ if (internalUiState.showPermissionWizard) {
+ var isDeveloperOptionsEnabled by remember { mutableStateOf(false) }
+ var isUsbDebuggingEnabled by remember { mutableStateOf(false) }
+ var isUsbConnected by remember { mutableStateOf(false) }
+ var hasPermission by remember { mutableStateOf(false) }
+
+ // Periodically check developer settings and permission status
+ LaunchedEffect(Unit) {
+ var previousDevOptionsState = try {
+ Settings.Global.getInt(
+ context.contentResolver,
+ Settings.Global.DEVELOPMENT_SETTINGS_ENABLED,
+ 0
+ ) == 1
+ } catch (_: Exception) {
+ false
+ }
+
+ var previousUsbDebuggingState = try {
+ Settings.Global.getInt(
+ context.contentResolver,
+ Settings.Global.ADB_ENABLED,
+ 0
+ ) == 1
+ } catch (_: Exception) {
+ false
+ }
+
+
+ // Observe USB state via sticky broadcast and runtime receiver
+ val usbFilter =
+ android.content.IntentFilter("android.hardware.usb.action.USB_STATE")
+ val sticky = context.registerReceiver(null, usbFilter)
+ fun parseUsbIntent(intent: Intent?): Boolean {
+ if (intent == null) return false
+ val extras = intent.extras ?: return false
+ val connected = extras.getBoolean("connected", false)
+ val configured = extras.getBoolean("configured", false)
+ val dataConnected = extras.getBoolean("data_connected", false)
+ val adb = extras.getBoolean("adb", false)
+ val hostConnected = extras.getBoolean("host_connected", false)
+ return connected && (configured || dataConnected || adb || hostConnected)
+ }
+ isUsbConnected = parseUsbIntent(sticky)
+ var previousUsbConnected = isUsbConnected
+
+ val runtimeReceiver = object : android.content.BroadcastReceiver() {
+ override fun onReceive(
+ ctx: android.content.Context?,
+ intent: Intent?
+ ) {
+ val nowConnected = parseUsbIntent(intent)
+ if (!previousUsbConnected && nowConnected) {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ }
+ isUsbConnected = nowConnected
+ previousUsbConnected = nowConnected
+ }
+ }
+ context.registerReceiver(runtimeReceiver, usbFilter)
+
+ try {
+ // Fallback: check attached USB devices via UsbManager
+ val usbManager =
+ context.getSystemService(android.content.Context.USB_SERVICE) as? android.hardware.usb.UsbManager
+ val nowConnected = (usbManager?.deviceList?.isNotEmpty() == true)
+ if (!previousUsbConnected && nowConnected) {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ }
+ isUsbConnected = isUsbConnected || nowConnected
+ previousUsbConnected = isUsbConnected
+ } catch (_: Exception) { /* ignore */
+ }
+
+ try {
+ while (true) {
+ isDeveloperOptionsEnabled = try {
+ Settings.Global.getInt(
+ context.contentResolver,
+ Settings.Global.DEVELOPMENT_SETTINGS_ENABLED,
+ 0
+ ) == 1
+ } catch (_: Exception) {
+ false
+ }
+
+ isUsbDebuggingEnabled = try {
+ Settings.Global.getInt(
+ context.contentResolver,
+ Settings.Global.ADB_ENABLED,
+ 0
+ ) == 1
+ } catch (_: Exception) {
+ false
+ }
+
+ hasPermission = ContextCompat.checkSelfPermission(
+ context, Manifest.permission.WRITE_SECURE_SETTINGS
+ ) == PackageManager.PERMISSION_GRANTED
+
+ if (!previousDevOptionsState && isDeveloperOptionsEnabled) {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ }
+
+ if (!previousUsbDebuggingState && isUsbDebuggingEnabled) {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ }
+
+ previousDevOptionsState = isDeveloperOptionsEnabled
+ previousUsbDebuggingState = isUsbDebuggingEnabled
+
+ // Fallback refresh: if sticky broadcast wasn’t conclusive, re-check UsbManager
+ if (!isUsbConnected) {
+ val usbManager =
+ context.getSystemService(android.content.Context.USB_SERVICE) as? android.hardware.usb.UsbManager
+ val nowConnected = usbManager?.deviceList?.isNotEmpty() == true
+ if (!previousUsbConnected && nowConnected) {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ }
+ isUsbConnected = nowConnected
+ previousUsbConnected = nowConnected
+ }
+
+ // If permission becomes granted, auto-complete wizard and enable service
+ if (hasPermission) {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ adaptiveThemeViewModel.completePermissionWizardAndEnableService()
+ break
+ }
+
+ // Check every second
+ kotlinx.coroutines.delay(1000)
+ }
+ } finally {
+ context.unregisterReceiver(runtimeReceiver)
+ }
}
+
+ val adbCommand by adaptiveThemeViewModel.pendingAdbCommand.collectAsState()
+
+ PermissionSetupWizardScreen(
+ step = internalUiState.permissionWizardStep,
+ adbCommand = adbCommand,
+ isUsbConnected = isUsbConnected,
+ hasWriteSecureSettings = hasPermission,
+ isDeveloperOptionsEnabled = isDeveloperOptionsEnabled,
+ isUsbDebuggingEnabled = isUsbDebuggingEnabled,
+ onNext = {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ if (internalUiState.permissionWizardStep == PermissionWizardStep.GRANT_PERMISSION && hasPermission) {
+ adaptiveThemeViewModel.completePermissionWizardAndEnableService()
+ } else {
+ adaptiveThemeViewModel.goToNextPermissionWizardStep()
+ }
+ },
+ onExit = { adaptiveThemeViewModel.dismissPermissionWizard() },
+ onOpenSettings = {
+ val intent = Intent(Settings.ACTION_DEVICE_INFO_SETTINGS)
+ try {
+ context.startActivity(intent)
+ } catch (_: Exception) {
+ context.startActivity(Intent(Settings.ACTION_SETTINGS))
+ }
+ },
+ onOpenDeveloperSettings = {
+ val intent = Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS)
+ try {
+ context.startActivity(intent)
+ } catch (_: Exception) {
+ context.startActivity(Intent(Settings.ACTION_SETTINGS))
+ }
+ },
+ onShareSetupUrl = {
+ AnalyticsLogger.logShareLinkClicked(context, "permission_wizard")
+ context.shareSetupUrl("https://lexip.dev/setup")
+ },
+ onCopyAdbCommand = { adaptiveThemeViewModel.requestCopyAdbCommand() },
+ onShareExpertCommand = {
+ context.shareSetupUrl(adbCommand)
+ },
+ onCheckPermission = {
+ val nowGranted =
+ ContextCompat.checkSelfPermission(
+ context, Manifest.permission.WRITE_SECURE_SETTINGS
+ ) == PackageManager.PERMISSION_GRANTED
+ adaptiveThemeViewModel.recheckWriteSecureSettingsPermission(nowGranted)
+ if (nowGranted) {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ adaptiveThemeViewModel.completePermissionWizardAndEnableService()
+ }
+ }
+ )
+ return
}
+
+ CustomThresholdDialog(
+ show = showCustomDialog.value,
+ currentLux = uiState.customAdaptiveThemeThresholdLux ?: uiState.adaptiveThemeThresholdLux,
+ onConfirm = { luxValue: Float ->
+ adaptiveThemeViewModel.setCustomAdaptiveThemeThreshold(luxValue)
+ showCustomDialog.value = false
+ },
+ onDismiss = { showCustomDialog.value = false }
+ )
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt
index 55682ba..67559d2 100644
--- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt
+++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt
@@ -13,66 +13,285 @@
package dev.lexip.hecate.ui
import android.content.Intent
+import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import dev.lexip.hecate.HecateApplication
+import dev.lexip.hecate.analytics.AnalyticsLogger
+import dev.lexip.hecate.data.AdaptiveThreshold
import dev.lexip.hecate.data.UserPreferencesRepository
import dev.lexip.hecate.services.BroadcastReceiverService
+import dev.lexip.hecate.ui.setup.PermissionWizardStep
import dev.lexip.hecate.util.DarkThemeHandler
+import dev.lexip.hecate.util.LightSensorManager
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
-private const val TAG = "AdaptiveThemeViewModel"
+sealed interface UiEvent {
+ data class CopyToClipboard(val text: String) : UiEvent
+}
data class AdaptiveThemeUiState(
- val adaptiveThemeEnabled: Boolean = false
+ val adaptiveThemeEnabled: Boolean = false,
+ val adaptiveThemeThresholdLux: Float = 1000f,
+ val customAdaptiveThemeThresholdLux: Float? = null,
+ val showPermissionWizard: Boolean = false,
+ val permissionWizardStep: PermissionWizardStep = PermissionWizardStep.ENABLE_DEVELOPER_MODE,
+ val permissionWizardCompleted: Boolean = false,
+ val hasAutoAdvancedFromDeveloperMode: Boolean = false,
+ val hasAutoAdvancedFromConnectUsb: Boolean = false
)
class AdaptiveThemeViewModel(
private val application: HecateApplication,
private val userPreferencesRepository: UserPreferencesRepository,
- private var darkThemeHandler: DarkThemeHandler
+ @Suppress("unused")
+ private var _darkThemeHandler: DarkThemeHandler
) : ViewModel() {
private val _uiState = MutableStateFlow(AdaptiveThemeUiState())
val uiState: StateFlow = _uiState.asStateFlow()
+ // One-shot UI events
+ private val _uiEvents = MutableSharedFlow(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val uiEvents = _uiEvents.asSharedFlow()
+
+ // Wizard + pending ADB command
+ private val _pendingAdbCommand = MutableStateFlow("")
+ val pendingAdbCommand: StateFlow = _pendingAdbCommand.asStateFlow()
+
+ // Light Sensor
+ private val lightSensorManager = LightSensorManager(application.applicationContext)
+ private var isListeningToSensor = false
+
+ private val _currentSensorLux = MutableStateFlow(0f)
+ val currentSensorLuxFlow: StateFlow = _currentSensorLux.asStateFlow()
+ val currentSensorLux: Float get() = _currentSensorLux.value
+
+ fun updateCurrentSensorLux(lux: Float) {
+ _currentSensorLux.value = lux
+ }
+
+ // Temporary variable for custom threshold
+ private var customThresholdTemp: Float? = null
+
init {
viewModelScope.launch {
userPreferencesRepository.userPreferencesFlow.collect { userPreferences ->
_uiState.value = AdaptiveThemeUiState(
- adaptiveThemeEnabled = userPreferences.adaptiveThemeEnabled
+ adaptiveThemeEnabled = userPreferences.adaptiveThemeEnabled,
+ adaptiveThemeThresholdLux = userPreferences.adaptiveThemeThresholdLux,
+ customAdaptiveThemeThresholdLux = userPreferences.customAdaptiveThemeThresholdLux,
+ permissionWizardCompleted = userPreferences.permissionWizardCompleted
)
+
+ if (userPreferences.adaptiveThemeEnabled) startLightSensorListening()
+ else stopLightSensorListening()
}
}
}
- fun updateAdaptiveThemeEnabled(enable: Boolean) {
- // TODO #30: Check for android.permission.WRITE_SECURE_SETTINGS
+ private fun startLightSensorListening() {
+ if (isListeningToSensor) return
+ isListeningToSensor = true
+ lightSensorManager.startListening { lux ->
+ viewModelScope.launch {
+ updateCurrentSensorLux(lux)
+ }
+ }
+ }
+
+ private fun stopLightSensorListening() {
+ if (!isListeningToSensor) return
+ isListeningToSensor = false
+ lightSensorManager.stopListening()
+ }
+
+ override fun onCleared() {
+ stopLightSensorListening()
+ super.onCleared()
+ }
+
+ /**
+ * Toggle adaptive theme service or show permission wizard.
+ * @return true if service was toggled, false if permission wizard is shown.
+ */
+ fun onServiceToggleRequested(
+ checked: Boolean,
+ hasPermission: Boolean,
+ packageName: String
+ ): Boolean {
+ if (checked && !hasPermission) {
+ startPermissionWizard(packageName)
+ AnalyticsLogger.logPermissionErrorShown(
+ application.applicationContext,
+ reason = "missing_write_secure_settings",
+ attemptedAction = "enable_adaptive_theme"
+ )
+ return false
+ }
+ updateAdaptiveThemeEnabled(checked)
+ return true
+ }
+
+ private fun startPermissionWizard(packageName: String) {
+ _pendingAdbCommand.value =
+ "adb shell pm grant $packageName android.permission.WRITE_SECURE_SETTINGS"
+ _uiState.value = _uiState.value.copy(
+ showPermissionWizard = true,
+ permissionWizardStep = PermissionWizardStep.ENABLE_DEVELOPER_MODE
+ )
+ }
+
+ fun goToNextPermissionWizardStep() {
+ val next = when (_uiState.value.permissionWizardStep) {
+ PermissionWizardStep.ENABLE_DEVELOPER_MODE -> PermissionWizardStep.CONNECT_USB
+ PermissionWizardStep.CONNECT_USB -> PermissionWizardStep.GRANT_PERMISSION
+ PermissionWizardStep.GRANT_PERMISSION -> PermissionWizardStep.GRANT_PERMISSION
+ }
+ _uiState.value = _uiState.value.copy(permissionWizardStep = next)
+ }
+
+ fun goToPreviousPermissionWizardStep() {
+ val prev = when (_uiState.value.permissionWizardStep) {
+ PermissionWizardStep.ENABLE_DEVELOPER_MODE -> PermissionWizardStep.ENABLE_DEVELOPER_MODE
+ PermissionWizardStep.CONNECT_USB -> PermissionWizardStep.ENABLE_DEVELOPER_MODE
+ PermissionWizardStep.GRANT_PERMISSION -> PermissionWizardStep.CONNECT_USB
+ }
+ _uiState.value = _uiState.value.copy(permissionWizardStep = prev)
+ }
+
+ fun dismissPermissionWizard() {
+ _uiState.value = _uiState.value.copy(showPermissionWizard = false)
+ }
+
+ fun recheckWriteSecureSettingsPermission(granted: Boolean) {
+ if (granted) {
+ _uiState.value =
+ _uiState.value.copy(permissionWizardStep = PermissionWizardStep.GRANT_PERMISSION)
+ }
+ }
+
+ fun completePermissionWizardAndEnableService() {
+ viewModelScope.launch {
+ userPreferencesRepository.updatePermissionWizardCompleted(true)
+ dismissPermissionWizard()
+ updateAdaptiveThemeEnabled(true)
+ }
+ }
+
+ fun requestCopyAdbCommand() {
+ val cmd = _pendingAdbCommand.value
+ viewModelScope.launch {
+ _uiEvents.emit(UiEvent.CopyToClipboard(cmd))
+ }
+ }
+
+ private fun updateAdaptiveThemeEnabled(enable: Boolean) {
+ val wasEnabled = _uiState.value.adaptiveThemeEnabled
viewModelScope.launch {
userPreferencesRepository.updateAdaptiveThemeEnabled(enable)
- if (enable) startBroadcastReceiverService() else stopBroadcastReceiverService()
- updateAdaptiveThemeThresholdLux(500f)
+ if (enable) {
+ startBroadcastReceiverService()
+ userPreferencesRepository.ensureAdaptiveThemeThresholdDefault()
+ AnalyticsLogger.logServiceEnabled(
+ application.applicationContext,
+ source = if (wasEnabled) "state_restore" else "ui_toggle"
+ )
+ } else {
+ stopBroadcastReceiverService()
+ AnalyticsLogger.logServiceDisabled(
+ application.applicationContext,
+ source = if (wasEnabled) "ui_toggle" else "state_restore"
+ )
+ }
+ }
+ }
+
+ fun updateAdaptiveThemeThresholdByIndex(index: Int) {
+ val threshold = AdaptiveThreshold.fromIndex(index)
+ val oldLux = _uiState.value.adaptiveThemeThresholdLux
+ viewModelScope.launch {
+ userPreferencesRepository.updateAdaptiveThemeThresholdLux(threshold.lux)
+ // Log threshold change
+ AnalyticsLogger.logBrightnessThresholdChanged(
+ application.applicationContext,
+ oldLux = oldLux,
+ newLux = threshold.lux
+ )
}
}
+ fun setCustomAdaptiveThemeThreshold(lux: Float) {
+ val oldLux = _uiState.value.adaptiveThemeThresholdLux
+ viewModelScope.launch {
+ userPreferencesRepository.updateCustomAdaptiveThemeThresholdLux(lux)
+ AnalyticsLogger.logBrightnessThresholdChanged(
+ application.applicationContext,
+ oldLux = oldLux,
+ newLux = lux
+ )
+ }
+ }
+
+ fun clearCustomAdaptiveThemeThreshold() {
+ viewModelScope.launch {
+ userPreferencesRepository.clearCustomAdaptiveThemeThreshold()
+ }
+ }
+
+ val isUsingCustomThreshold: Boolean
+ get() = _uiState.value.customAdaptiveThemeThresholdLux != null
+
+ fun getDisplayLuxSteps(baseLux: List): List {
+ val customLux = _uiState.value.customAdaptiveThemeThresholdLux ?: return baseLux
+ val index = AdaptiveThreshold.fromLux(customLux).ordinal
+ return baseLux.mapIndexed { i, value -> if (i == index) customLux else value }
+ }
+
+ fun getDisplayLabels(labels: List, customLabel: String): List {
+ return if (isUsingCustomThreshold) {
+ labels.mapIndexed { index, label ->
+ if (index == getIndexForCurrentLux()) customLabel else label
+ }
+ } else labels
+ }
+
+ fun onSliderValueCommitted(index: Int) {
+ if (isUsingCustomThreshold) {
+ customThresholdTemp = null
+ }
+ updateAdaptiveThemeThresholdByIndex(index)
+ }
+
+ fun getIndexForCurrentLux(): Int {
+ val lux = customThresholdTemp ?: _uiState.value.adaptiveThemeThresholdLux
+ return AdaptiveThreshold.fromLux(lux).ordinal
+ }
+
+ fun setPendingCustomSliderLux(lux: Float) {
+ customThresholdTemp = lux
+ }
+
private fun startBroadcastReceiverService() {
val intent = Intent(application.applicationContext, BroadcastReceiverService::class.java)
- application.applicationContext.startService(intent)
+ ContextCompat.startForegroundService(application.applicationContext, intent)
}
private fun stopBroadcastReceiverService() {
val intent = Intent(application.applicationContext, BroadcastReceiverService::class.java)
application.applicationContext.stopService(intent)
}
-
- private suspend fun updateAdaptiveThemeThresholdLux(lux: Float) {
- userPreferencesRepository.updateAdaptiveThemeThresholdLux(lux)
- }
-
}
class AdaptiveThemeViewModelFactory(
diff --git a/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt b/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt
new file mode 100644
index 0000000..148c8a8
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt
@@ -0,0 +1,153 @@
+/*
+ * Copyright (C) 2025 xLexip
+ *
+ * Licensed under the GNU General Public License, Version 3.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.gnu.org/licenses/gpl-3.0
+ *
+ * Please see the License for specific terms regarding permissions and limitations.
+ */
+
+package dev.lexip.hecate.ui
+
+import android.app.Activity
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.IntentSenderRequest
+import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
+import com.google.android.play.core.appupdate.AppUpdateManager
+import com.google.android.play.core.appupdate.AppUpdateManagerFactory
+import com.google.android.play.core.appupdate.AppUpdateOptions
+import com.google.android.play.core.install.model.AppUpdateType
+import com.google.android.play.core.install.model.UpdateAvailability
+
+private const val TAG = "InAppUpdateManager"
+private const val DAYS_FOR_IMMEDIATE_UPDATE = 0
+private const val MIN_PRIORITY_FOR_IMMEDIATE = 0
+
+class InAppUpdateManager(activity: ComponentActivity) {
+
+ private val appUpdateManager: AppUpdateManager = AppUpdateManagerFactory.create(activity)
+
+ private var updateLauncher: ActivityResultLauncher? = null
+
+ fun registerUpdateLauncher(activity: ComponentActivity) {
+ if (updateLauncher != null) return
+
+ updateLauncher =
+ activity.registerForActivityResult(StartIntentSenderForResult()) { result ->
+ when (result.resultCode) {
+ Activity.RESULT_OK -> {
+ Log.d(TAG, "In-app update completed successfully")
+ }
+
+ Activity.RESULT_CANCELED -> {
+ Log.w(TAG, "In-app update was cancelled by the user")
+ Toast.makeText(
+ activity,
+ "Update cancelled. You can update later from the Play Store.",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+
+ else -> {
+ Log.e(TAG, "In-app update failed with resultCode=${result.resultCode}")
+ Toast.makeText(
+ activity,
+ "Update failed to start. Please try again later.",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ }
+ }
+
+ fun checkForImmediateUpdate(
+ onNoUpdate: () -> Unit = {},
+ onError: (Throwable) -> Unit = {}
+ ) {
+ val launcher = updateLauncher
+ if (launcher == null) {
+ Log.w(TAG, "checkForImmediateUpdate called before launcher was registered")
+ return
+ }
+
+ appUpdateManager.appUpdateInfo
+ .addOnSuccessListener { appUpdateInfo ->
+ val availability = appUpdateInfo.updateAvailability()
+ val isImmediateAllowed = appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
+ val staleness = appUpdateInfo.clientVersionStalenessDays() ?: -1
+ val priority = appUpdateInfo.updatePriority()
+
+ Log.d(
+ TAG,
+ "Update info: availability=$availability, immediateAllowed=$isImmediateAllowed, stalenessDays=$staleness, priority=$priority"
+ )
+
+ val meetsStaleness = staleness == -1 || staleness >= DAYS_FOR_IMMEDIATE_UPDATE
+ val meetsPriority = priority >= MIN_PRIORITY_FOR_IMMEDIATE
+
+ if (availability == UpdateAvailability.UPDATE_AVAILABLE && isImmediateAllowed && meetsStaleness && meetsPriority) {
+ Log.d(TAG, "Immediate update available, starting update flow")
+ try {
+ appUpdateManager.startUpdateFlowForResult(
+ appUpdateInfo,
+ launcher,
+ AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build()
+ )
+ } catch (t: Throwable) {
+ Log.e(TAG, "Failed to launch immediate in-app update", t)
+ onError(t)
+ }
+ } else {
+ Log.d(
+ TAG,
+ "No eligible immediate update. availability=$availability, immediateAllowed=$isImmediateAllowed"
+ )
+ onNoUpdate()
+ }
+ }
+ .addOnFailureListener { throwable ->
+ Log.e(TAG, "Failed to retrieve appUpdateInfo", throwable)
+ onError(throwable)
+ }
+ }
+
+ fun resumeImmediateUpdateIfNeeded() {
+ val launcher = updateLauncher
+ if (launcher == null) {
+ Log.w(TAG, "resumeImmediateUpdateIfNeeded called before launcher was registered")
+ return
+ }
+
+ appUpdateManager.appUpdateInfo
+ .addOnSuccessListener { appUpdateInfo ->
+ if (appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS &&
+ appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
+ ) {
+ Log.d(TAG, "Resuming in-progress immediate in-app update")
+ try {
+ appUpdateManager.startUpdateFlowForResult(
+ appUpdateInfo,
+ launcher,
+ AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build()
+ )
+ } catch (t: Throwable) {
+ Log.e(TAG, "Failed to resume immediate in-app update", t)
+ }
+ } else {
+ Log.d(
+ TAG,
+ "No in-progress immediate update to resume. availability=${appUpdateInfo.updateAvailability()}"
+ )
+ }
+ }
+ .addOnFailureListener { throwable ->
+ Log.e(TAG, "Failed to check for in-progress immediate update", throwable)
+ }
+ }
+}
diff --git a/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt b/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt
index ec6a116..bba79de 100644
--- a/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt
+++ b/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2024 xLexip
+ * Copyright (C) 2024-2025 xLexip
*
* Licensed under the GNU General Public License, Version 3.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -12,7 +12,10 @@
package dev.lexip.hecate.ui
+import android.annotation.SuppressLint
+import android.os.Build
import android.os.Bundle
+import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -27,11 +30,29 @@ import dev.lexip.hecate.util.DarkThemeHandler
class MainActivity : ComponentActivity() {
+ private lateinit var inAppUpdateManager: InAppUpdateManager
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+
+ // Catch mysterious unsupported SDK versions despite minSDK 31
+ @SuppressLint("ObsoleteSdkInt")
+ if (Build.VERSION.SDK_INT < 31) {
+ Toast.makeText(
+ this,
+ "Unsupported Android version, please uninstall the app.",
+ Toast.LENGTH_LONG
+ ).show()
+ finish()
+ return
+ }
+
installSplashScreen()
enableEdgeToEdge()
+ inAppUpdateManager = InAppUpdateManager(this)
+ inAppUpdateManager.registerUpdateLauncher(this)
+
setContent {
val dataStore = (this.applicationContext as HecateApplication).userPreferencesDataStore
val adaptiveThemeViewModel: AdaptiveThemeViewModel = viewModel(
@@ -42,11 +63,21 @@ class MainActivity : ComponentActivity() {
)
)
val state by adaptiveThemeViewModel.uiState.collectAsState()
+
HecateTheme {
- AdaptiveThemeScreen(state, adaptiveThemeViewModel::updateAdaptiveThemeEnabled)
+ AdaptiveThemeScreen(
+ state
+ )
}
}
+ inAppUpdateManager.checkForImmediateUpdate()
}
-}
+ override fun onResume() {
+ super.onResume()
+ if (::inAppUpdateManager.isInitialized) {
+ inAppUpdateManager.resumeImmediateUpdateIfNeeded()
+ }
+ }
+}
diff --git a/app/src/main/java/dev/lexip/hecate/ui/components/SwitchPreferenceCard.kt b/app/src/main/java/dev/lexip/hecate/ui/components/MainSwitchPreferenceCard.kt
similarity index 53%
rename from app/src/main/java/dev/lexip/hecate/ui/components/SwitchPreferenceCard.kt
rename to app/src/main/java/dev/lexip/hecate/ui/components/MainSwitchPreferenceCard.kt
index f1d39fd..80f1d27 100644
--- a/app/src/main/java/dev/lexip/hecate/ui/components/SwitchPreferenceCard.kt
+++ b/app/src/main/java/dev/lexip/hecate/ui/components/MainSwitchPreferenceCard.kt
@@ -13,47 +13,72 @@
package dev.lexip.hecate.ui.components
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
@Composable
-fun SwitchPreferenceCard(text: String, isChecked: Boolean, onCheckedChange: (Boolean) -> Unit) {
- val mainSwitchTextSize = (MaterialTheme.typography.titleLarge.fontSize.value - 2).sp
-
+fun MainSwitchPreferenceCard(text: String, isChecked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth(),
- shape = MaterialTheme.shapes.extraLarge,
+ shape = RoundedCornerShape(percent = 100),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
onClick = { onCheckedChange(!isChecked) }
) {
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(PaddingValues(16.dp))
- .padding(start = 4.dp),
+ .padding(top = 12.dp, bottom = 12.dp, start = 32.dp, end = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
- style = MaterialTheme.typography.titleLarge,
- fontSize = mainSwitchTextSize,
+ modifier = Modifier
+ .fillMaxWidth(0.75f),
+ style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
- text = text
+ text = text,
+ overflow = TextOverflow.Ellipsis
+ )
+ Switch(
+ checked = isChecked,
+ onCheckedChange = onCheckedChange,
+ thumbContent = if (isChecked) {
+ {
+ Icon(
+ imageVector = Icons.Filled.Check,
+ contentDescription = null,
+ modifier = Modifier.size(SwitchDefaults.IconSize),
+ )
+ }
+ } else {
+ {
+ Icon(
+ imageVector = Icons.Filled.Clear,
+ contentDescription = null,
+ modifier = Modifier.size(SwitchDefaults.IconSize),
+ )
+ }
+ }
)
- Switch(checked = isChecked, onCheckedChange = onCheckedChange)
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/lexip/hecate/ui/components/preferences/CustomThresholdDialog.kt b/app/src/main/java/dev/lexip/hecate/ui/components/preferences/CustomThresholdDialog.kt
new file mode 100644
index 0000000..9deb084
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/components/preferences/CustomThresholdDialog.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright (C) 2025 xLexip
+ *
+ * Licensed under the GNU General Public License, Version 3.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.gnu.org/licenses/gpl-3.0
+ *
+ * Please see the License for specific terms regarding permissions and limitations.
+ */
+
+package dev.lexip.hecate.ui.components.preferences
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import dev.lexip.hecate.R
+
+private const val MAX_LUX = 100_000f
+
+@Composable
+fun CustomThresholdDialog(
+ show: Boolean,
+ currentLux: Float,
+ onConfirm: (Float) -> Unit,
+ onDismiss: () -> Unit
+) {
+ if (!show) return
+
+ val haptic = LocalHapticFeedback.current
+
+ var text by remember(currentLux) {
+ mutableStateOf(
+ if (currentLux >= 0) currentLux.toInt().toString() else ""
+ )
+ }
+ var error by remember { mutableStateOf(null) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(text = stringResource(id = R.string.title_custom_threshold)) },
+ text = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp),
+ value = text,
+ onValueChange = {
+ text = it
+ error = null
+ },
+ label = { Text(text = stringResource(id = R.string.hint_custom_threshold_value)) },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ singleLine = true,
+ isError = error != null,
+ supportingText = error?.let { errorRes ->
+ { Text(text = stringResource(id = errorRes)) }
+ }
+ )
+ }
+ },
+ confirmButton = {
+ Button(onClick = {
+ val value = text.toFloatOrNull()
+ if (value == null) {
+ error = R.string.error_invalid_lux_value
+ haptic.performHapticFeedback(HapticFeedbackType.Reject)
+ return@Button
+ }
+
+ if (value < 0f) {
+ error = R.string.error_negative_lux_value
+ haptic.performHapticFeedback(HapticFeedbackType.Reject)
+ return@Button
+ }
+
+ if (value > MAX_LUX) {
+ error = R.string.error_lux_value_too_large
+ haptic.performHapticFeedback(HapticFeedbackType.Reject)
+ return@Button
+ }
+
+ haptic.performHapticFeedback(HapticFeedbackType.Confirm)
+ onConfirm(value)
+ }) {
+ Text(text = stringResource(id = R.string.action_set))
+ }
+ },
+ dismissButton = {
+ OutlinedButton(onClick = {
+ onDismiss()
+ }) {
+ Text(text = stringResource(id = R.string.action_cancel))
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/dev/lexip/hecate/ui/components/preferences/DetailPreferenceCard.kt b/app/src/main/java/dev/lexip/hecate/ui/components/preferences/DetailPreferenceCard.kt
new file mode 100644
index 0000000..991f971
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/components/preferences/DetailPreferenceCard.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2025 xLexip
+ *
+ * Licensed under the GNU General Public License, Version 3.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.gnu.org/licenses/gpl-3.0
+ *
+ * Please see the License for specific terms regarding permissions and limitations.
+ */
+
+package dev.lexip.hecate.ui.components.preferences
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun DetailPreferenceCard(
+ title: String,
+ enabled: Boolean = true,
+ firstCard: Boolean = false,
+ lastCard: Boolean = false,
+ content: @Composable () -> Unit
+) {
+ val largeRadius = 20.dp
+ val smallRadius = 4.dp
+ val shape = RoundedCornerShape(
+ topStart = if (firstCard) largeRadius else smallRadius,
+ topEnd = if (firstCard) largeRadius else smallRadius,
+ bottomStart = if (lastCard) largeRadius else smallRadius,
+ bottomEnd = if (lastCard) largeRadius else smallRadius,
+ )
+
+ Card(
+ shape = shape,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceBright)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(14.dp)
+ .alpha(if (enabled) 1f else 0.38f)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ content()
+ }
+ }
+}
diff --git a/app/src/main/java/dev/lexip/hecate/ui/components/preferences/ProgressDetailCard.kt b/app/src/main/java/dev/lexip/hecate/ui/components/preferences/ProgressDetailCard.kt
new file mode 100644
index 0000000..8ff14df
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/components/preferences/ProgressDetailCard.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2025 xLexip
+ *
+ * Licensed under the GNU General Public License, Version 3.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.gnu.org/licenses/gpl-3.0
+ *
+ * Please see the License for specific terms regarding permissions and limitations.
+ */
+
+package dev.lexip.hecate.ui.components.preferences
+
+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.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun ProgressDetailCard(
+ title: String,
+ currentLux: Float,
+ luxSteps: List,
+ enabled: Boolean = true,
+ firstCard: Boolean = false,
+ lastCard: Boolean = false
+) {
+ DetailPreferenceCard(
+ title = title,
+ enabled = enabled,
+ firstCard = firstCard,
+ lastCard = lastCard
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp)
+ .padding(top = 8.dp, bottom = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ val segments = (luxSteps.size - 1).coerceAtLeast(1)
+
+ val activeIndex = remember(currentLux, luxSteps) {
+ computeActiveSegmentIndex(
+ luxSteps,
+ currentLux
+ ).coerceIn(-1, segments - 1)
+ }
+
+ SegmentedBrightnessRow(
+ segments = segments,
+ activeIndex = activeIndex,
+ enabled = enabled
+ )
+ }
+ }
+}
+
+@Composable
+private fun SegmentedBrightnessRow(segments: Int, activeIndex: Int, enabled: Boolean) {
+ val sliderColors = androidx.compose.material3.SliderDefaults.colors()
+ val activeColor = sliderColors.activeTrackColor
+ val inactiveColor = sliderColors.inactiveTrackColor
+
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ for (i in 0 until segments) {
+ val isActive = i <= activeIndex
+ val shape = RoundedCornerShape(8.dp)
+
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .height(16.dp)
+ .clip(shape)
+ .background(
+ if (enabled && isActive) activeColor
+ else inactiveColor
+ )
+ ) {}
+ }
+ }
+}
+
+private fun computeActiveSegmentIndex(luxSteps: List, currentLux: Float): Int {
+ val n = luxSteps.size
+ if (n < 2) return -1
+ var idx = -1
+ for (i in 0 until n - 1) {
+ val upper = luxSteps.getOrNull(i + 1) ?: continue
+ if (currentLux > upper) idx = i
+ }
+ return idx
+}
diff --git a/app/src/main/java/dev/lexip/hecate/ui/components/preferences/SliderDetailCard.kt b/app/src/main/java/dev/lexip/hecate/ui/components/preferences/SliderDetailCard.kt
new file mode 100644
index 0000000..3182e2d
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/components/preferences/SliderDetailCard.kt
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2025 xLexip
+ *
+ * Licensed under the GNU General Public License, Version 3.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.gnu.org/licenses/gpl-3.0
+ *
+ * Please see the License for specific terms regarding permissions and limitations.
+ */
+
+package dev.lexip.hecate.ui.components.preferences
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import dev.lexip.hecate.util.formatLux
+import kotlin.math.roundToInt
+
+@Composable
+fun SliderDetailCard(
+ title: String,
+ valueIndex: Int,
+ steps: Int,
+ labels: List,
+ lux: List? = null,
+ onValueChange: (Int) -> Unit,
+ enabled: Boolean = true,
+ firstCard: Boolean = false,
+ lastCard: Boolean = false,
+) {
+ DetailPreferenceCard(
+ title = title,
+ enabled = enabled,
+ firstCard = firstCard,
+ lastCard = lastCard
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(0.dp)
+ ) {
+ LabeledSlider(
+ valueIndex = valueIndex,
+ steps = steps,
+ labels = labels,
+ lux = lux,
+ onValueChange = onValueChange,
+ enabled = enabled
+ )
+ }
+ }
+}
+
+@Composable
+private fun LabeledSlider(
+ valueIndex: Int,
+ steps: Int,
+ labels: List,
+ lux: List? = null,
+ onValueChange: (Int) -> Unit,
+ enabled: Boolean = true
+) {
+ val haptic = LocalHapticFeedback.current
+ var sliderPosition by remember { mutableFloatStateOf(valueIndex.toFloat()) }
+ var lastLiveIndex by remember { mutableIntStateOf(valueIndex) }
+
+ LaunchedEffect(valueIndex) { sliderPosition = valueIndex.toFloat() }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Slider(
+ value = sliderPosition,
+ onValueChange = { new ->
+ sliderPosition = new
+ val liveIndex = sliderPosition.roundToInt().coerceIn(0, steps - 1)
+ if (liveIndex != lastLiveIndex) {
+ haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
+ lastLiveIndex = liveIndex
+ }
+ },
+ onValueChangeFinished = { onValueChange(sliderPosition.toInt()) },
+ valueRange = 0f..(steps - 1).toFloat(),
+ steps = (steps - 2).coerceAtLeast(0),
+ enabled = enabled,
+ colors = SliderDefaults.colors()
+ )
+
+ if (enabled) {
+ val liveIndex = sliderPosition.roundToInt().coerceIn(0, steps - 1)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = labels.getOrNull(liveIndex) ?: liveIndex.toString(),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.weight(1f)
+ )
+ Text(
+ text = lux?.getOrNull(liveIndex)?.let { "${it.formatLux()} lx" } ?: "",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.End
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupCommon.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupCommon.kt
new file mode 100644
index 0000000..561c63b
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupCommon.kt
@@ -0,0 +1,239 @@
+/*
+ * Copyright (C) 2025 xLexip
+ *
+ * Licensed under the GNU General Public License, Version 3.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.gnu.org/licenses/gpl-3.0
+ *
+ * Please see the License for specific terms regarding permissions and limitations.
+ */
+
+package dev.lexip.hecate.ui.setup
+
+import android.widget.Toast
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.tween
+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.Spacer
+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.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.outlined.Circle
+import androidx.compose.material.icons.outlined.ExpandLess
+import androidx.compose.material.icons.outlined.ExpandMore
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+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.draw.scale
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import dev.lexip.hecate.R
+
+@Composable
+internal fun StatusCard(
+ isCompleted: Boolean,
+ title: String,
+ onClick: (() -> Unit)? = null,
+ isWaiting: Boolean = false
+) {
+ val cardColors = if (isCompleted) {
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ )
+ } else {
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ )
+ }
+
+ val pulseScale = remember { Animatable(0.8f) }
+
+ LaunchedEffect(isWaiting) {
+ if (isWaiting) {
+ pulseScale.animateTo(
+ targetValue = 1.2f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 750, easing = LinearOutSlowInEasing),
+ repeatMode = RepeatMode.Reverse
+ )
+ )
+ } else {
+ pulseScale.snapTo(1.0f)
+ }
+ }
+
+ Card(
+ onClick = onClick ?: {},
+ enabled = onClick != null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(80.dp),
+ colors = cardColors
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(20.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.weight(1f)
+ ) {
+ val icon = if (isCompleted && !isWaiting) {
+ Icons.Filled.CheckCircle
+ } else {
+ Icons.Outlined.Circle
+ }
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier
+ .size(32.dp)
+ .scale(pulseScale.value),
+ tint = if (isCompleted && !isWaiting)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = if (isCompleted) FontWeight.Medium else FontWeight.Normal,
+ color = if (isCompleted)
+ MaterialTheme.colorScheme.onPrimaryContainer
+ else
+ MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+}
+
+@Composable
+internal fun ForExpertsSection(
+ adbCommand: String?,
+ onCopyAdbCommand: (() -> Unit)? = null,
+ onShareExpertCommand: (() -> Unit)? = null,
+) {
+ val context = LocalContext.current
+ val haptic = LocalHapticFeedback.current
+ var expanded by remember { mutableStateOf(false) }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { expanded = !expanded },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(id = R.string.permission_wizard_for_experts),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(onClick = { expanded = !expanded }) {
+ Icon(
+ imageVector = if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
+ contentDescription = null
+ )
+ }
+ }
+
+ if (expanded) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(id = R.string.permission_wizard_manual_command),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surface,
+ shape = MaterialTheme.shapes.small
+ ) {
+ Text(
+ text = adbCommand ?: "",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(12.dp),
+ fontWeight = FontWeight.Medium
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ OutlinedButton(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ onCopyAdbCommand?.invoke()
+ Toast.makeText(
+ context,
+ R.string.permission_wizard_copied,
+ Toast.LENGTH_SHORT
+ ).show()
+ },
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(text = stringResource(id = R.string.action_copy))
+ }
+
+ Button(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ onShareExpertCommand?.invoke()
+ },
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(text = stringResource(id = R.string.action_share))
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt
new file mode 100644
index 0000000..92838c1
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt
@@ -0,0 +1,550 @@
+/*
+ * Copyright (C) 2025 xLexip
+ *
+ * Licensed under the GNU General Public License, Version 3.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.gnu.org/licenses/gpl-3.0
+ *
+ * Please see the License for specific terms regarding permissions and limitations.
+ */
+
+package dev.lexip.hecate.ui.setup
+
+import android.widget.Toast
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.outlined.Circle
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import dev.lexip.hecate.R
+
+@Composable
+internal fun DeveloperModeStep(
+ isDeveloperOptionsEnabled: Boolean,
+ isUsbDebuggingEnabled: Boolean,
+ onNext: () -> Unit,
+ onExit: () -> Unit,
+ onOpenSettings: () -> Unit,
+ onOpenDeveloperSettings: () -> Unit,
+) {
+ val haptic = LocalHapticFeedback.current
+ val bothEnabled = isDeveloperOptionsEnabled && isUsbDebuggingEnabled
+
+ LaunchedEffect(isDeveloperOptionsEnabled) {
+ if (isDeveloperOptionsEnabled) {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ }
+ }
+
+ LaunchedEffect(isUsbDebuggingEnabled) {
+ if (isUsbDebuggingEnabled) {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ }
+ }
+
+ LaunchedEffect(bothEnabled) {
+ if (bothEnabled) {
+ onNext()
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ text = stringResource(id = R.string.permission_wizard_developer_mode_title),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = stringResource(id = R.string.permission_wizard_developer_mode_body),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ DeveloperOptionsCard(
+ isEnabled = isDeveloperOptionsEnabled,
+ onOpenSettings = onOpenSettings
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ UsbDebuggingCard(
+ isEnabled = isUsbDebuggingEnabled,
+ isDeveloperOptionsEnabled = isDeveloperOptionsEnabled,
+ onOpenDeveloperSettings = onOpenDeveloperSettings
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ OutlinedButton(onClick = onExit) {
+ Text(text = stringResource(id = R.string.action_close))
+ }
+ Button(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ onNext()
+ },
+ enabled = bothEnabled
+ ) {
+ Text(text = stringResource(id = R.string.action_continue))
+ }
+ }
+ }
+}
+
+@Composable
+private fun DeveloperOptionsCard(
+ isEnabled: Boolean,
+ onOpenSettings: () -> Unit,
+) {
+ val context = LocalContext.current
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isEnabled)
+ MaterialTheme.colorScheme.primaryContainer
+ else
+ MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Column(modifier = Modifier.padding(20.dp)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ if (isEnabled) {
+ Icon(
+ imageVector = Icons.Filled.CheckCircle,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ }
+ Text(
+ text = stringResource(
+ id = if (isEnabled)
+ R.string.permission_wizard_developer_options_enabled
+ else
+ R.string.permission_wizard_developer_options_title
+ ),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = if (isEnabled)
+ MaterialTheme.colorScheme.onPrimaryContainer
+ else
+ MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ if (!isEnabled) {
+ Spacer(modifier = Modifier.height(12.dp))
+ val toastText = stringResource(R.string.permission_wizard_dev_options_toast)
+ Button(
+ onClick = {
+ onOpenSettings()
+ Toast.makeText(
+ context,
+ toastText,
+ Toast.LENGTH_LONG
+ ).show()
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(id = R.string.permission_wizard_action_open_settings))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun UsbDebuggingCard(
+ isEnabled: Boolean,
+ isDeveloperOptionsEnabled: Boolean,
+ onOpenDeveloperSettings: () -> Unit,
+) {
+ val context = LocalContext.current
+ val usbDebuggingToastText = stringResource(R.string.permission_wizard_usb_debugging_toast)
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isEnabled)
+ MaterialTheme.colorScheme.primaryContainer
+ else
+ MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Column(modifier = Modifier.padding(20.dp)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ if (isEnabled) {
+ Icon(
+ imageVector = Icons.Filled.CheckCircle,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ }
+ Text(
+ text = stringResource(
+ id = if (isEnabled)
+ R.string.permission_wizard_usb_debugging_enabled
+ else
+ R.string.permission_wizard_usb_debugging_disabled
+ ),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = if (isEnabled)
+ MaterialTheme.colorScheme.onPrimaryContainer
+ else
+ MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ if (!isEnabled && isDeveloperOptionsEnabled) {
+ Spacer(modifier = Modifier.height(12.dp))
+ Button(
+ onClick = {
+ onOpenDeveloperSettings()
+ Toast.makeText(
+ context,
+ usbDebuggingToastText,
+ Toast.LENGTH_LONG
+ ).show()
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(id = R.string.permission_wizard_action_open_developer_settings))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+internal fun ConnectUsbStep(
+ isUsbConnected: Boolean,
+ onNext: () -> Unit,
+ onExit: () -> Unit,
+) {
+ val haptic = LocalHapticFeedback.current
+
+ LaunchedEffect(isUsbConnected) {
+ if (isUsbConnected) {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ }
+ }
+
+ LaunchedEffect(isUsbConnected) {
+ if (isUsbConnected) {
+ onNext()
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ text = stringResource(id = R.string.permission_wizard_connect_title),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = stringResource(id = R.string.permission_wizard_connect_body),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ StatusCard(
+ isCompleted = isUsbConnected,
+ title = if (isUsbConnected)
+ stringResource(id = R.string.permission_wizard_usb_connected)
+ else
+ stringResource(id = R.string.permission_wizard_usb_not_connected),
+ isWaiting = !isUsbConnected
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ ConnectionWhySection()
+
+ Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ OutlinedButton(onClick = onExit) {
+ Text(text = stringResource(id = R.string.action_close))
+ }
+ Button(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ onNext()
+ },
+ enabled = isUsbConnected
+ ) {
+ Text(text = stringResource(id = R.string.action_continue))
+ }
+ }
+ }
+}
+
+@Composable
+private fun ConnectionWhySection() {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = stringResource(id = R.string.permission_wizard_why_other_device_title),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(id = R.string.permission_wizard_why_other_device),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+@Composable
+internal fun GrantPermissionStep(
+ adbCommand: String,
+ hasWriteSecureSettings: Boolean,
+ onCopyAdbCommand: () -> Unit,
+ onShareSetupUrl: () -> Unit,
+ onShareExpertCommand: () -> Unit,
+ onCheckPermission: () -> Unit,
+ onExit: () -> Unit
+) {
+ val haptic = LocalHapticFeedback.current
+
+ val pulseScale = remember { Animatable(0.8f) }
+ LaunchedEffect(!hasWriteSecureSettings) {
+ if (!hasWriteSecureSettings) {
+ pulseScale.animateTo(
+ targetValue = 1.2f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 750, easing = LinearOutSlowInEasing),
+ repeatMode = RepeatMode.Reverse
+ )
+ )
+ } else {
+ pulseScale.snapTo(0.8f)
+ }
+ }
+
+ LaunchedEffect(hasWriteSecureSettings) {
+ if (hasWriteSecureSettings) {
+ haptic.performHapticFeedback(HapticFeedbackType.Confirm)
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ text = stringResource(id = R.string.permission_wizard_grant_title),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = stringResource(id = R.string.permission_wizard_grant_body),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ WebsiteShareCard(onShareSetupUrl = onShareSetupUrl)
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ PermissionStatusSection(
+ hasWriteSecureSettings = hasWriteSecureSettings,
+ pulseScale = pulseScale
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.height(12.dp))
+
+ ForExpertsSection(
+ adbCommand = adbCommand,
+ onCopyAdbCommand = onCopyAdbCommand,
+ onShareExpertCommand = onShareExpertCommand
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ OutlinedButton(onClick = onExit) {
+ Text(text = stringResource(id = R.string.action_close))
+ }
+ Button(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ onCheckPermission()
+ },
+ enabled = hasWriteSecureSettings
+ ) {
+ Text(text = stringResource(id = R.string.action_finish))
+ }
+ }
+ }
+}
+
+@Composable
+private fun WebsiteShareCard(
+ onShareSetupUrl: () -> Unit,
+) {
+ val haptic = LocalHapticFeedback.current
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = stringResource(id = R.string.permission_wizard_website_url),
+ style = MaterialTheme.typography.displaySmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ OutlinedButton(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ onShareSetupUrl()
+ },
+ modifier = Modifier.wrapContentWidth(),
+ contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp)
+ ) {
+ Text(text = stringResource(id = R.string.action_share_setup_url))
+ }
+ }
+ }
+}
+
+@Composable
+private fun PermissionStatusSection(
+ hasWriteSecureSettings: Boolean,
+ pulseScale: Animatable,
+) {
+ if (hasWriteSecureSettings) {
+ StatusCard(
+ isCompleted = true,
+ title = stringResource(id = R.string.permission_wizard_permission_granted)
+ )
+ } else {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Circle,
+ contentDescription = null,
+ modifier = Modifier
+ .size(24.dp)
+ .scale(pulseScale.value),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = stringResource(id = R.string.permission_wizard_permission_not_granted),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt
new file mode 100644
index 0000000..3961a72
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2025 xLexip
+ *
+ * Licensed under the GNU General Public License, Version 3.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.gnu.org/licenses/gpl-3.0
+ *
+ * Please see the License for specific terms regarding permissions and limitations.
+ */
+
+package dev.lexip.hecate.ui.setup
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+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.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import dev.lexip.hecate.R
+
+enum class PermissionWizardStep {
+ ENABLE_DEVELOPER_MODE,
+ CONNECT_USB,
+ GRANT_PERMISSION
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PermissionSetupWizardScreen(
+ step: PermissionWizardStep,
+ adbCommand: String,
+ isUsbConnected: Boolean,
+ hasWriteSecureSettings: Boolean,
+ isDeveloperOptionsEnabled: Boolean,
+ isUsbDebuggingEnabled: Boolean,
+ onNext: () -> Unit,
+ onExit: () -> Unit,
+ onOpenSettings: () -> Unit,
+ onOpenDeveloperSettings: () -> Unit,
+ onShareSetupUrl: () -> Unit,
+ onCopyAdbCommand: () -> Unit,
+ onShareExpertCommand: () -> Unit,
+ onCheckPermission: () -> Unit,
+) {
+ val totalSteps = PermissionWizardStep.entries.size
+ val currentStepIndex = step.ordinal + 1
+ val progress = (currentStepIndex.toFloat() - (0.1).toFloat()) / totalSteps.toFloat()
+
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ topBar = {
+ TopAppBar(
+ title = { Text(text = "Service Setup", fontWeight = FontWeight.Bold) },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ titleContentColor = MaterialTheme.colorScheme.onSurface
+ )
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ // Progress indicator section
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(top = 16.dp)
+ ) {
+ LinearProgressIndicator(
+ progress = { progress },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(8.dp),
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(
+ id = R.string.permission_wizard_step_counter,
+ currentStepIndex,
+ totalSteps
+ ),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Main content
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp),
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ when (step) {
+ PermissionWizardStep.ENABLE_DEVELOPER_MODE -> DeveloperModeStep(
+ isDeveloperOptionsEnabled = isDeveloperOptionsEnabled,
+ isUsbDebuggingEnabled = isUsbDebuggingEnabled,
+ onNext = onNext,
+ onExit = onExit,
+ onOpenSettings = onOpenSettings,
+ onOpenDeveloperSettings = onOpenDeveloperSettings
+ )
+
+ PermissionWizardStep.CONNECT_USB -> ConnectUsbStep(
+ isUsbConnected = isUsbConnected,
+ onNext = onNext,
+ onExit = onExit
+ )
+
+ PermissionWizardStep.GRANT_PERMISSION -> GrantPermissionStep(
+ adbCommand = adbCommand,
+ hasWriteSecureSettings = hasWriteSecureSettings,
+ onCopyAdbCommand = onCopyAdbCommand,
+ onShareSetupUrl = onShareSetupUrl,
+ onShareExpertCommand = onShareExpertCommand,
+ onCheckPermission = onCheckPermission,
+ onExit = onExit
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ }
+}
diff --git a/app/src/main/java/dev/lexip/hecate/ui/theme/Theme.kt b/app/src/main/java/dev/lexip/hecate/ui/theme/Theme.kt
index a80b1c0..002bcd1 100644
--- a/app/src/main/java/dev/lexip/hecate/ui/theme/Theme.kt
+++ b/app/src/main/java/dev/lexip/hecate/ui/theme/Theme.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2024 xLexip
+ * Copyright (C) 2024-2025 xLexip
*
* Licensed under the GNU General Public License, Version 3.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -18,10 +18,18 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.Typography
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import dev.lexip.hecate.R
@Composable
fun HecateTheme(
@@ -29,21 +37,58 @@ fun HecateTheme(
context: Context = LocalContext.current,
content: @Composable () -> Unit
) {
- val colorScheme =
- if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ val colorScheme = when {
+ darkTheme -> dynamicDarkColorScheme(context)
+ else -> dynamicLightColorScheme(context)
+ }
+
+ // Bundle Nunito Font to match system settings design
+ val nunitoFontFamily = FontFamily(
+ Font(R.font.nunito_black, weight = FontWeight.Black),
+ Font(R.font.nunito_extrabold, weight = FontWeight.ExtraBold),
+ Font(R.font.nunito_bold, weight = FontWeight.Bold),
+ Font(R.font.nunito_semibold, weight = FontWeight.SemiBold),
+ Font(R.font.nunito_medium, weight = FontWeight.Medium),
+ Font(R.font.nunito_regular, weight = FontWeight.Normal),
+ Font(R.font.nunito_light, weight = FontWeight.Light),
+ Font(R.font.nunito_italic, weight = FontWeight.Normal, style = FontStyle.Italic)
+ )
+
+ val appTypography = Typography(
+ displaySmall = TextStyle(
+ fontFamily = nunitoFontFamily,
+ fontWeight = FontWeight.Bold,
+ fontSize = 36.sp
+ ),
+ titleMedium = TextStyle(
+ fontFamily = nunitoFontFamily,
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp
+ ),
+ bodyLarge = TextStyle(
+ fontFamily = nunitoFontFamily,
+ fontSize = 17.sp
+ ),
+ bodySmall = TextStyle(
+ fontFamily = nunitoFontFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 12.sp
+ )
+ )
MaterialTheme(
colorScheme = colorScheme,
+ typography = appTypography,
content = content
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun hecateTopAppBarColors(): TopAppBarColors = TopAppBarDefaults.largeTopAppBarColors(
+fun hecateTopAppBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors(
// This represents the top app bar style of the android system settings app in Android 15.
containerColor = MaterialTheme.colorScheme.surfaceContainer,
- scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface
diff --git a/app/src/main/java/dev/lexip/hecate/util/FormatUtils.kt b/app/src/main/java/dev/lexip/hecate/util/FormatUtils.kt
new file mode 100644
index 0000000..dece9df
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/util/FormatUtils.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2025 xLexip
+ *
+ * Licensed under the GNU General Public License, Version 3.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.gnu.org/licenses/gpl-3.0
+ *
+ * Please see the License for specific terms regarding permissions and limitations.
+ */
+
+package dev.lexip.hecate.util
+
+import java.text.NumberFormat
+import java.util.Locale
+
+/**
+ * Extension to format lux values with locale-aware thousands separators.
+ * Usage: val s = 10000.formatLux()
+ */
+fun Int.formatLux(): String {
+ val nf = NumberFormat.getIntegerInstance(Locale.getDefault())
+ return nf.format(this)
+}
+
+fun Float.formatLux(): String {
+ val nf = NumberFormat.getNumberInstance(Locale.getDefault()).apply {
+ maximumFractionDigits = if (this@formatLux % 1f == 0f) 0 else 1
+ minimumFractionDigits = 0
+ }
+ return nf.format(this)
+}
diff --git a/app/src/main/java/dev/lexip/hecate/util/LightSensorManager.kt b/app/src/main/java/dev/lexip/hecate/util/LightSensorManager.kt
index 89072d4..7b05dfa 100644
--- a/app/src/main/java/dev/lexip/hecate/util/LightSensorManager.kt
+++ b/app/src/main/java/dev/lexip/hecate/util/LightSensorManager.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2024 xLexip
+ * Copyright (C) 2024-2025 xLexip
*
* Licensed under the GNU General Public License, Version 3.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -31,7 +31,7 @@ class LightSensorManager(private val context: Context) : SensorEventListener {
fun startListening(callback: (Float) -> Unit) {
this.callback = callback
lightSensor?.let {
- sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL)
+ sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_FASTEST)
}
}
diff --git a/app/src/main/java/dev/lexip/hecate/util/ProximitySensorManager.kt b/app/src/main/java/dev/lexip/hecate/util/ProximitySensorManager.kt
index b569f0d..68cf2d7 100644
--- a/app/src/main/java/dev/lexip/hecate/util/ProximitySensorManager.kt
+++ b/app/src/main/java/dev/lexip/hecate/util/ProximitySensorManager.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2024 xLexip
+ * Copyright (C) 2024-2025 xLexip
*
* Licensed under the GNU General Public License, Version 3.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -31,7 +31,7 @@ class ProximitySensorManager(private val context: Context) : SensorEventListener
fun startListening(callback: (Float) -> Unit) {
this.callback = callback
proximitySensor?.let {
- sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL)
+ sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_FASTEST)
}
}
diff --git a/app/src/main/res/drawable/ic_app.xml b/app/src/main/res/drawable/ic_app.xml
new file mode 100644
index 0000000..d1e86c7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_app.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
deleted file mode 100644
index 47be326..0000000
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
index 4250414..5a0b4e2 100644
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -1,16 +1,14 @@
-
-
-
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+
+
+
diff --git a/app/src/main/res/font/nunito_black.ttf b/app/src/main/res/font/nunito_black.ttf
new file mode 100644
index 0000000..99491f8
Binary files /dev/null and b/app/src/main/res/font/nunito_black.ttf differ
diff --git a/app/src/main/res/font/nunito_bold.ttf b/app/src/main/res/font/nunito_bold.ttf
new file mode 100644
index 0000000..6909689
Binary files /dev/null and b/app/src/main/res/font/nunito_bold.ttf differ
diff --git a/app/src/main/res/font/nunito_extrabold.ttf b/app/src/main/res/font/nunito_extrabold.ttf
new file mode 100644
index 0000000..6f4ccde
Binary files /dev/null and b/app/src/main/res/font/nunito_extrabold.ttf differ
diff --git a/app/src/main/res/font/nunito_italic.ttf b/app/src/main/res/font/nunito_italic.ttf
new file mode 100644
index 0000000..97fd169
Binary files /dev/null and b/app/src/main/res/font/nunito_italic.ttf differ
diff --git a/app/src/main/res/font/nunito_light.ttf b/app/src/main/res/font/nunito_light.ttf
new file mode 100644
index 0000000..fb050fc
Binary files /dev/null and b/app/src/main/res/font/nunito_light.ttf differ
diff --git a/app/src/main/res/font/nunito_medium.ttf b/app/src/main/res/font/nunito_medium.ttf
new file mode 100644
index 0000000..a6993eb
Binary files /dev/null and b/app/src/main/res/font/nunito_medium.ttf differ
diff --git a/app/src/main/res/font/nunito_regular.ttf b/app/src/main/res/font/nunito_regular.ttf
new file mode 100644
index 0000000..be80c3f
Binary files /dev/null and b/app/src/main/res/font/nunito_regular.ttf differ
diff --git a/app/src/main/res/font/nunito_semibold.ttf b/app/src/main/res/font/nunito_semibold.ttf
new file mode 100644
index 0000000..06f29ea
Binary files /dev/null and b/app/src/main/res/font/nunito_semibold.ttf differ
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 52ac069..1c84df9 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,6 +1,6 @@
-
-
-
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
deleted file mode 100644
index 52ac069..0000000
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
deleted file mode 100644
index f3b0768..0000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
deleted file mode 100644
index 451a6f2..0000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
deleted file mode 100644
index 52d3700..0000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
deleted file mode 100644
index f95c47a..0000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
deleted file mode 100644
index 194f656..0000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
deleted file mode 100644
index b4ca1bb..0000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
deleted file mode 100644
index db64c28..0000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 95e20fc..0000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
deleted file mode 100644
index 890a2e5..0000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 8939e36..0000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/raw/nunito_ofl.txt b/app/src/main/res/raw/nunito_ofl.txt
new file mode 100644
index 0000000..8f5b7e2
--- /dev/null
+++ b/app/src/main/res/raw/nunito_ofl.txt
@@ -0,0 +1,93 @@
+Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties
new file mode 100644
index 0000000..63b46f9
--- /dev/null
+++ b/app/src/main/res/resources.properties
@@ -0,0 +1 @@
+unqualifiedResLocale=en
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
new file mode 100644
index 0000000..91a8285
--- /dev/null
+++ b/app/src/main/res/values-de/strings.xml
@@ -0,0 +1,73 @@
+
+
+
+ Abbrechen
+ Schließen
+ Weiter
+ Kopieren
+ Benachrichtigung ausblenden
+ Fertig
+ Festlegen
+ Teilen
+ Link teilen
+ Dienst stoppen
+ Adaptive Theme verwenden
+
+
+ Wechselt automatisch zwischen hellem und dunklem Modus basierend auf dem Umgebungslicht – für bessere Sichtbarkeit und Akkulaufzeit. Der Wechsel erfolgt nur beim Einschalten des Bildschirms und nur, wenn das Gerät nicht abgedeckt ist.
+
+
+
+ Systemtheme wird anhand der Umgebungshelligkeit angepasst.
+
+
+ Ungültiger Lux-Wert.
+ Der Lux-Wert kann maximal 100.000 sein.
+ Der Lux-Wert darf nicht negativ sein.
+ Keine E-Mail-App gefunden.
+
+ Info
+ Helligkeitsschwelle
+ Sprache ändern
+ Aktuelle Helligkeit
+ Benutzerdefinierte Schwelle
+ Mehr
+ Hintergrundaktivität
+ Feedback senden
+
+ Hell
+ Benutzerdefiniert
+ Dunkel
+ Tageslicht
+ Schwach
+ Leicht
+ Sonnenlicht
+
+ Lux-Wert
+
+
+ Einstellungen öffnen
+ Einstellungen öffnen
+ Um die Berechtigung zu erteilen, benötigen Sie ein weiteres Gerät (vorzugsweise einen Computer) mit Webbrowser. Verbinden Sie dieses Gerät per USB-Kabel damit.
+ Mit einem anderen Gerät verbinden
+ In die Zwischenablage kopiert.
+ Tippen Sie einfach mehrmals auf die Build-Nummer, um die Entwickleroptionen freizuschalten, und aktivieren Sie dort USB-Debugging.
+ Entwicklermodus aktivieren
+ Entwickleroptionen aktiviert
+ Entwickleroptionen aktivieren
+ Mehrfach auf die Build-Nummer tippen, um die Entwickleroptionen zu aktivieren.
+ Alternative für Experten
+ Öffne diese Website auf einem anderen Gerät und folge den Anweisungen dort:
+ Berechtigung erteilen
+ Falls ADB auf dem anderen Gerät installiert ist, kannst du dort stattdessen diesen Command ausführen:
+ Berechtigung erfolgreich erteilt!
+ Berechtigung noch nicht erteilt. Schließen Sie die Einrichtung auf dem anderen Gerät ab.
+ Schritt %1$d von %2$d
+ USB verbunden
+ USB-Debugging aktivieren
+ USB-Debugging aktiviert
+ USB-Debugging aktivieren
+ Warten auf USB-Verbindung …
+ Die Berechtigung kann nur über ADB erteilt werden, wofür ein anderes Gerät mit einem Webbrowser oder ADB erforderlich ist.
+ Warum wird ein anderes Gerät benötigt?
+
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..1a9fc46
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #181920
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d66de97..66d7063 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,8 +1,74 @@
- Adaptive theme
- Disable notification
- Use adaptive theme
- Adaptive Theme automatically adjusts the device theme based on ambient brightness. It enhances visibility in bright conditions while optimizing battery life.
- Background Activity
- Adaptive theme is active
-
\ No newline at end of file
+ Adaptive Theme
+ lexip.dev/setup
+
+
+ Cancel
+ Close
+ Continue
+ Copy
+ Hide Notification
+ Finish
+ Set
+ Share
+ Share Link
+ Stop Service
+ Use Adaptive Theme
+
+
+ Automatically switches between Light and Dark mode based on ambient light for better visibility and battery life. Theme changes occur only when the screen turns on and the device is uncovered.
+
+
+ Adjusting the system theme based on ambient brightness.
+
+
+ No email app found.
+ Invalid lux value.
+ Lux value cannot be negative.
+ Lux value cannot exceed 100,000.
+
+ About
+ Brightness Threshold
+ Change Language
+ Current Brightness
+ Custom Threshold
+ More
+ Background Activity
+ Send Feedback
+
+ Bright
+ Custom
+ Dark
+ Daylight
+ Dim
+ Soft
+ Sunlight
+
+ Lux value
+
+
+ Open Settings
+ Open Settings
+ To grant the permission, you\'ll need another device (preferably a computer) with a web browser. Connect this device to it using a USB cable.
+ Connect to another device
+ Command copied to clipboard.
+ Tap the build number several times to unlock Developer options, then enable USB debugging there.
+ Enable Developer Mode
+ Developer options enabled
+ Enable developer options
+ Look for the build number and tap it 7 times to enable the developer settings. You\'ll see a message confirming it\'s enabled.
+ Alternative for experts
+ On your other device, open this website and follow the instructions:
+ Grant the permission
+ If you have ADB installed on your computer, you can run this command instead:
+ Permission granted successfully!
+ Permission not yet granted. Complete the setup on your other device.
+ Step %1$d of %2$d
+ USB connected
+ Enable USB debugging
+ USB debugging enabled
+ Enable USB debugging
+ Waiting for USB connection
+ The permission can only be granted via ADB, which requires another device with either a web browser or ADB installed.
+ Why is another device required?
+
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
new file mode 100644
index 0000000..cca96b0
--- /dev/null
+++ b/app/src/main/res/xml/locales_config.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/dev/lexip/hecate/ExampleUnitTest.kt b/app/src/test/java/dev/lexip/hecate/ExampleUnitTest.kt
deleted file mode 100644
index 0f57358..0000000
--- a/app/src/test/java/dev/lexip/hecate/ExampleUnitTest.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package dev.lexip.hecate
-
-import org.junit.Assert.*
-import org.junit.Test
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index 17166ed..b4995d4 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -3,13 +3,15 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
- id("org.sonarqube") version "6.1.0.5360"
+ id("org.sonarqube") version "7.2.0.6526"
+ id("com.google.gms.google-services") version "4.4.4" apply false
+ alias(libs.plugins.google.firebase.crashlytics) apply false
}
sonar {
properties {
property("sonar.projectKey", "xLexip_Hecate")
- property("sonar.projectVersion", "1.0.0")
+ property("sonar.projectVersion", "0.6.0")
property("sonar.organization", "xlexip")
property("sonar.host.url", "https://sonarcloud.io")
property("sonar.androidLint.reportPaths", "app/build/reports/lint-results-debug.html")
diff --git a/gradle.properties b/gradle.properties
index 20e2a01..36d0b3e 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -20,4 +20,6 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
-android.nonTransitiveRClass=true
\ No newline at end of file
+android.nonTransitiveRClass=true
+# Enable optimized resource shrinking
+android.r8.optimizedResourceShrinking=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b57eb2c..1ed0222 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,25 +1,31 @@
[versions]
-activityCompose = "1.10.1"
-agp = "8.9.2"
-appcompat = "1.7.0"
-composeBom = "2025.04.01"
-coreKtx = "1.16.0"
-coreSplashscreenVersion = "1.0.1"
-datastorePreferencesCore = "1.1.4"
-espressoCore = "3.6.1"
+activityCompose = "1.12.1"
+agp = "8.13.1"
+appcompat = "1.7.1"
+appUpdateKtx = "2.1.0"
+composeBom = "2025.12.00"
+coreKtx = "1.17.0"
+coreSplashscreenVersion = "1.2.0"
+datastorePreferencesCore = "1.2.0"
+espressoCore = "3.7.0"
+firebaseBom = "34.6.0"
+firebaseAnalytics = "23.0.0"
+firebaseCrashlytics = "20.0.3"
+googleFirebaseCrashlytics = "3.0.6"
junit = "4.13.2"
-junitVersion = "1.2.1"
-kotlin = "2.1.20"
-lifecycleRuntimeKtx = "2.8.7"
-lifecycleViewmodelCompose = "2.8.7"
+junitVersion = "1.3.0"
+kotlin = "2.2.21"
+lifecycleRuntimeKtx = "2.10.0"
+lifecycleViewmodelCompose = "2.10.0"
localbroadcastmanager = "1.1.0"
-material = "1.12.0"
+material = "1.13.0"
preference = "1.2.1"
[libraries]
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-core-splashscreen-v100 = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreenVersion" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferencesCore" }
@@ -33,13 +39,18 @@ androidx-preference = { group = "androidx.preference", name = "preference", vers
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
-androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version = "1.8.0" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version = "1.10.0" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+app-update-ktx = { module = "com.google.android.play:app-update-ktx", version.ref = "appUpdateKtx" }
+firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics", version.ref = "firebaseAnalytics" }
+firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebaseCrashlytics" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+google-firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "googleFirebaseCrashlytics" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index 1b33c55..f8e1ee3 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index ca025c8..23449a2 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
index 23d15a9..adff685 100755
--- a/gradlew
+++ b/gradlew
@@ -1,7 +1,7 @@
#!/bin/sh
#
-# Copyright © 2015-2021 the original authors.
+# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
-CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
- CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
- -classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
diff --git a/gradlew.bat b/gradlew.bat
index 5eed7ee..e509b2d 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -70,11 +70,10 @@ goto fail
:execute
@rem Setup the command line
-set CLASSPATH=
@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
diff --git a/renovate.json b/renovate.json
index ef1b19c..2e664a2 100644
--- a/renovate.json
+++ b/renovate.json
@@ -1,28 +1,31 @@
{
- "$schema": "https://docs.renovatebot.com/renovate-schema.json",
- "extends": [
- "config:recommended",
- ":semanticCommits"
- ],
- "baseBranches": [
- "develop"
- ],
- "dependencyDashboard": true,
- "packageRules": [
- {
- "groupName": "Patched Dependencies",
- "groupSlug": "patch-updates",
- "matchUpdateTypes": [
- "patch"
- ]
- },
- {
- "commitMessageAction": "Apply",
- "groupName": "Minor Dependency Updates",
- "groupSlug": "minor-updates",
- "matchUpdateTypes": [
- "minor"
- ]
- }
- ]
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": [
+ "config:recommended",
+ ":semanticCommits"
+ ],
+ "baseBranchPatterns": [
+ "develop"
+ ],
+ "dependencyDashboard": true,
+ "labels": [
+ "renovate"
+ ],
+ "packageRules": [
+ {
+ "groupName": "Patched Dependencies",
+ "groupSlug": "patch-updates",
+ "matchUpdateTypes": [
+ "patch"
+ ]
+ },
+ {
+ "commitMessageAction": "Apply",
+ "groupName": "Minor Dependency Updates",
+ "groupSlug": "minor-updates",
+ "matchUpdateTypes": [
+ "minor"
+ ]
+ }
+ ]
}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 65bbffc..f7c1ba3 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -19,5 +19,5 @@ dependencyResolutionManagement {
}
}
-rootProject.name = "Hecate"
+rootProject.name = "Adaptive Theme"
include(":app")
\ No newline at end of file