diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml
index 81a797ab..549c17ea 100644
--- a/.github/workflows/debug.yml
+++ b/.github/workflows/debug.yml
@@ -1,44 +1,38 @@
-name: Debug CI
+name: PurrfectSnap Debug CI
on:
workflow_dispatch:
- inputs:
- ci_upload:
- description: 'Upload to CI channel'
- required: false
- type: boolean
-
+ inputs:
+ ci_upload:
+ description: 'Upload to CI channel'
+ required: false
+ type: boolean
jobs:
job_armv8:
runs-on: ubuntu-latest
+ outputs:
+ armv8_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: 'recursive'
-
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
-
- name: Grant execute permission for gradlew
run: chmod +x gradlew
-
- name: Setup NPM Dependencies
run: npm install typescript -g
-
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
-
- name: Build
- run: ./gradlew assembleArmv8Debug
-
+ run: ./gradlew --configuration-cache assembleArmv8Debug
- name: Determine the latest Build Tools version installed
shell: bash
run: echo "BUILD_TOOL_VERSION=$(ls "$ANDROID_HOME/build-tools/" | tail -n 1)" >> $GITHUB_ENV
-
- name: Sign APK
id: sign_app
uses: SnapEnhance/sign-android-release@master
@@ -50,55 +44,47 @@ jobs:
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
-
- name: Get current build version
id: version-env
run: |
- ./gradlew getVersion
+ ./gradlew --configuration-cache getVersion
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
-
- name: Delete unsigned APK file and rename the signed one
run: |
find app/build/outputs/apk/armv8/debug/ -type f ! -name '*-signed*' -delete
- mv ${{steps.sign_app.outputs.signedReleaseFile}} app/build/outputs/apk/armv8/debug/snapenhance-${{ env.version }}-armv8-${GITHUB_SHA::7}.apk
-
+ mv ${{steps.sign_app.outputs.signedReleaseFile}} app/build/outputs/apk/armv8/debug/purrfectsnap-${{ env.version }}-armv8-${GITHUB_SHA::7}.apk
- name: Upload artifact
+ id: upload_apk
uses: actions/upload-artifact@v4
with:
- name: snapenhance-armv8-debug
+ name: purrfectsnap-armv8-debug
path: app/build/outputs/apk/armv8/debug/*.apk
-
job_armv7:
runs-on: ubuntu-latest
+ outputs:
+ armv7_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: 'recursive'
-
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
-
- name: Grant execute permission for gradlew
run: chmod +x gradlew
-
- name: Setup NPM Dependencies
run: npm install typescript -g
-
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
-
- name: Build
- run: ./gradlew assembleArmv7Debug
-
+ run: ./gradlew --configuration-cache assembleArmv7Debug
- name: Determine the latest Build Tools version installed
shell: bash
- run: echo "BUILD_TOOL_VERSION=$(ls "$ANDROID_HOME/build-tools/" | tail -n 1)" >> $GITHUB_ENV
-
+ run: echo "BUILD_TOOL_VERSION=$(ls \"$ANDROID_HOME/build-tools/\" | tail -n 1)" >> $GITHUB_ENV
- name: Sign APK
id: sign_app
uses: SnapEnhance/sign-android-release@master
@@ -110,77 +96,93 @@ jobs:
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
-
- name: Get current build version
id: version-env
run: |
- ./gradlew getVersion
+ ./gradlew --configuration-cache getVersion
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
-
- name: Delete unsigned APK file and rename the signed one
run: |
find app/build/outputs/apk/armv7/debug/ -type f ! -name '*-signed*' -delete
- mv ${{steps.sign_app.outputs.signedReleaseFile}} app/build/outputs/apk/armv7/debug/snapenhance-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
-
+ mv ${{steps.sign_app.outputs.signedReleaseFile}} app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
- name: Upload artifact
+ id: upload_apk
uses: actions/upload-artifact@v4
with:
- name: snapenhance-armv7-debug
+ name: purrfectsnap-armv7-debug
path: app/build/outputs/apk/armv7/debug/*.apk
-
job_manager:
runs-on: ubuntu-latest
+ outputs:
+ manager_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: 'recursive'
-
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
-
- name: Grant execute permission for gradlew
run: chmod +x gradlew
-
- name: Build
- run: ./gradlew manager:assembleDebug
-
+ run: ./gradlew --configuration-cache manager:assembleDebug
- name: Upload artifact
+ id: upload_apk
uses: actions/upload-artifact@v4
with:
name: manager
path: manager/build/outputs/apk/debug/*.apk
-
job_core:
runs-on: ubuntu-latest
+ outputs:
+ core_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: 'recursive'
-
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
-
- name: Grant execute permission for gradlew
run: chmod +x gradlew
-
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
-
- name: Build
- run: ./gradlew assembleCoreDebug
-
+ run: ./gradlew --configuration-cache assembleCoreDebug
- name: Upload artifact
+ id: upload_apk
uses: actions/upload-artifact@v4
with:
name: core
path: app/build/outputs/apk/core/debug/*.apk
+ make_prerelease:
+ needs: [job_armv7, job_armv8, job_manager, job_core]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Download all build artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: ./all-apks
+ - name: Display all files for debug
+ run: find ./all-apks
+ - name: Set up GitHub CLI
+ run: |
+ sudo apt-get install -y gh
+ - name: Create Debug Prerelease with all APKs
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ VERSION="debug-${{ github.run_id }}-${{ github.sha }}"
+ TITLE="Debug CI $VERSION"
+ NOTE="Auto-generated debug prerelease including all APKs from jobs."
+ APKLIST=$(find ./all-apks -type f -name '*.apk' | tr '\n' ' ')
+ echo "APKS: $APKLIST"
+ gh release create "$VERSION" $APKLIST --repo ${{ github.repository }} --title "$TITLE" --notes "$NOTE" --prerelease
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index eca12a5c..0b7580ec 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -29,22 +29,22 @@ jobs:
run: rustup target add armv7-linux-androideabi aarch64-linux-android
- name: Build
- run: ./gradlew assembleArmv8Debug
+ run: ./gradlew --configuration-cache assembleArmv8Debug
- name: Get current build version
id: version-env
run: |
- ./gradlew getVersion
+ ./gradlew --configuration-cache getVersion
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
- name: Rename APK file
run: |
- mv app/build/outputs/apk/armv8/debug/*.apk app/build/outputs/apk/armv8/debug/snapenhance-${{ env.version }}-armv8-${GITHUB_SHA::7}.apk
+ mv app/build/outputs/apk/armv8/debug/*.apk app/build/outputs/apk/armv8/debug/purrfectsnap-${{ env.version }}-armv8-${GITHUB_SHA::7}.apk
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
- name: snapenhance-armv8-debug
+ name: purrfectsnap-armv8-debug
path: app/build/outputs/apk/armv8/debug/*.apk
job_armv7:
@@ -72,22 +72,22 @@ jobs:
run: rustup target add armv7-linux-androideabi aarch64-linux-android
- name: Build
- run: ./gradlew assembleArmv7Debug
+ run: ./gradlew --configuration-cache assembleArmv7Debug
- name: Get current build version
id: version-env
run: |
- ./gradlew getVersion
+ ./gradlew --configuration-cache getVersion
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
- name: Rename APK file
run: |
- mv app/build/outputs/apk/armv7/debug/*.apk app/build/outputs/apk/armv7/debug/snapenhance-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
+ mv app/build/outputs/apk/armv7/debug/*.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
- name: snapenhance-armv7-debug
+ name: purrfectsnap-armv7-debug
path: app/build/outputs/apk/armv7/debug/*.apk
job_manager:
@@ -109,7 +109,7 @@ jobs:
run: chmod +x gradlew
- name: Build
- run: ./gradlew manager:assembleDebug
+ run: ./gradlew --configuration-cache manager:assembleDebug
- name: Upload artifact
uses: actions/upload-artifact@v4
@@ -139,10 +139,11 @@ jobs:
run: rustup target add armv7-linux-androideabi aarch64-linux-android
- name: Build
- run: ./gradlew assembleCoreDebug
+ run: ./gradlew --configuration-cache assembleCoreDebug
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: core
path: app/build/outputs/apk/core/debug/*.apk
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ced10bf8..70f58327 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -7,15 +7,13 @@ import java.io.ByteArrayOutputStream
plugins {
alias(libs.plugins.androidApplication)
- alias(libs.plugins.kotlinAndroid)
alias(libs.plugins.compose.compiler)
id("kotlin-parcelize")
}
android {
namespace = rootProject.ext["applicationId"].toString()
- compileSdk = 34
-
+ compileSdk = 36
buildFeatures {
aidl = true
compose = true
@@ -26,7 +24,7 @@ android {
versionCode = rootProject.ext["appVersionCode"].toString().toInt()
versionName = rootProject.ext["appVersionName"].toString()
minSdk = 28
- targetSdk = 34
+ targetSdk = 36
multiDexEnabled = true
}
@@ -46,8 +44,6 @@ android {
}
flavorDimensions += "abi"
-
- //noinspection ChromeOsAbiSupport
productFlavors {
packaging {
jniLibs {
@@ -62,25 +58,21 @@ android {
excludes += "META-INF/*.kotlin_module"
}
}
-
create("core") {
dimension = "abi"
}
-
create("armv8") {
ndk {
abiFilters += "arm64-v8a"
}
dimension = "abi"
}
-
create("armv7") {
ndk {
abiFilters += "armeabi-v7a"
}
dimension = "abi"
}
-
create("all") {
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
@@ -89,30 +81,30 @@ android {
}
}
- properties["debug_flavor"]?.let {
- android.productFlavors.find { it.name == it.toString()}?.setIsDefault(true)
- }
-
- applicationVariants.all {
- outputs.map { it as BaseVariantOutputImpl }.forEach { outputVariant ->
- outputVariant.outputFileName = when {
- name.startsWith("core") -> "core.apk"
- else -> "snapenhance_${rootProject.ext["appVersionName"]}-${outputVariant.name}.apk"
- }
- }
- }
-
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
-
- kotlinOptions {
- jvmTarget = "21"
- }
}
androidComponents {
+ onVariants { variant ->
+ val flavorName = variant.flavorName
+ if (properties["debug_flavor"] == flavorName) {
+ // variant.makeDefault.set(true) // This is no longer supported
+ }
+
+ variant.outputs.forEach { output ->
+ val variantOutput = output as com.android.build.api.variant.impl.VariantOutputImpl
+ variantOutput.outputFileName.set(
+ when {
+ variant.name.startsWith("core") -> "core.apk"
+ else -> "snapenhance_${rootProject.ext["appVersionName"]}-${variant.name}.apk"
+ }
+ )
+ }
+ }
+
onVariants(selector().withFlavor("abi", "core")) {
it.packaging.jniLibs.apply {
pickFirsts.set(listOf("**/lib${rootProject.ext["buildHash"]}.so"))
@@ -152,38 +144,60 @@ dependencies {
properties["debug_flavor"]?.let {
debugImplementation(libs.androidx.ui.tooling)
}
+ implementation("androidx.appcompat:appcompat:1.7.1")
+ implementation("com.google.android.material:material:1.13.0")
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.work.runtime.ktx)
+ implementation(libs.fetch)
+ // --- COMPOSE: explicit modern UI/Foundation for widthIn/wrapContentWidth ----
+ fullImplementation(libs.foundation)
+ fullImplementation(libs.ui)
+ fullImplementation(libs.foundation.layout)
+
+ // Animated navigation + transitions (Accompanist)
+ fullImplementation(libs.accompanist.navigation.animation)
}
afterEvaluate {
- properties["debug_flavor"]?.toString()?.let { tasks.findByName("install${it.capitalized()}Debug") }?.doLast {
- runCatching {
- val devices = ByteArrayOutputStream().also {
- exec {
- commandLine("adb", "devices")
- standardOutput = it
- }
- }.toString().lines().drop(1).mapNotNull {
- line -> line.split("\t").firstOrNull()?.takeIf { it.isNotEmpty() }
- }
-
- runBlocking {
- devices.forEach { device ->
- launch {
- exec {
- commandLine("adb", "-s", device, "shell", "am", "force-stop", properties["debug_package_name"])
+ properties["debug_flavor"]?.toString()
+ ?.let { flavor -> tasks.findByName("install${flavor.replaceFirstChar { it.uppercase() }}Debug") }
+ ?.doLast {
+ runCatching {
+ val packageName = properties["debug_package_name"]?.toString() ?: return@runCatching
+ val devicesProcess = ProcessBuilder("adb", "devices")
+ .redirectErrorStream(true)
+ .start()
+ val devices = devicesProcess.inputStream.bufferedReader().useLines { lines ->
+ lines.drop(1)
+ .mapNotNull { line ->
+ line.split("\t").firstOrNull()?.takeIf { it.isNotEmpty() }
}
- delay(500)
- exec {
- commandLine("adb", "-s", device, "shell", "am", "start", properties["debug_package_name"])
+ .toList()
+ }
+ devicesProcess.waitFor()
+
+ runBlocking {
+ devices.forEach { device ->
+ launch {
+ ProcessBuilder("adb", "-s", device, "shell", "am", "force-stop", packageName)
+ .redirectErrorStream(true)
+ .start()
+ .apply { waitFor() }
+ delay(500)
+ ProcessBuilder("adb", "-s", device, "shell", "am", "start", packageName)
+ .redirectErrorStream(true)
+ .start()
+ .apply { waitFor() }
}
}
}
}
}
- }
}
+
properties["debug_flavor"]?.let {
configurations.all {
exclude(group = "androidx.profileinstaller", "profileinstaller")
}
}
+
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index b52bc96e..57107f3f 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -14,4 +14,8 @@
-keepclassmembers class * implements android.os.Parcelable {
public static final ** CREATOR;
-}
\ No newline at end of file
+}
+# Prevent WorkManager from stripping generated Room database constructor
+-keep class androidx.work.impl.WorkDatabase_Impl { *; }
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 20c2f400..1790135c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,15 +7,11 @@
-
-
-
-
+
-
+ android:value="PurrfectSnap by Eternal" />
-
-
+ android:exported="true"
+ android:screenOrientation="portrait">
@@ -68,9 +63,7 @@
android:theme="@style/BiometricPromptTheme"
android:excludeFromRecents="true"
android:exported="true" />
-
-
-
-
\ No newline at end of file
+
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteFileHandleManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteFileHandleManager.kt
index b98b7034..6344d83f 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteFileHandleManager.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteFileHandleManager.kt
@@ -70,51 +70,46 @@ class RemoteFileHandleManager(
mkdirs()
}
- override fun getFileHandle(scope: String, name: String): FileHandle? {
+ override fun getFileHandle(scope: String, name: String): FileHandle? {
val fileHandleScope = FileHandleScope.fromValue(scope) ?: run {
context.log.error("invalid file handle scope: $scope", "FileHandleManager")
return null
}
- when (fileHandleScope) {
+ return when (fileHandleScope) {
FileHandleScope.INTERNAL -> {
val fileHandleType = InternalFileHandleType.fromValue(name) ?: run {
context.log.error("invalid file handle name: $name", "FileHandleManager")
return null
}
-
- return LocalFileHandle(
- fileHandleType.resolve(context.androidContext)
- )
+ LocalFileHandle(fileHandleType.resolve(context.androidContext))
}
FileHandleScope.LOCALE -> {
val foundLocale = context.androidContext.resources.assets.list("lang")?.firstOrNull {
it.startsWith(name)
}?.substringBefore(".") ?: return null
-
if (name == LocaleWrapper.DEFAULT_LOCALE) {
- return AssetFileHandle(
+ AssetFileHandle(
context,
"lang/${LocaleWrapper.DEFAULT_LOCALE}.json"
)
+ } else {
+ AssetFileHandle(
+ context,
+ "lang/$foundLocale.json"
+ )
}
-
- return AssetFileHandle(
- context,
- "lang/$foundLocale.json"
- )
}
FileHandleScope.USER_IMPORT -> {
- return LocalFileHandle(
+ LocalFileHandle(
File(userImportFolder, name.substringAfterLast("/"))
)
}
FileHandleScope.COMPOSER -> {
- return AssetFileHandle(
+ AssetFileHandle(
context,
"composer/${name.substringAfterLast("/")}"
)
}
- else -> return null
}
}
@@ -150,4 +145,5 @@ class RemoteFileHandleManager(
context.log.error("Failed to delete file: ${it.message}", it)
}.isSuccess
}
-}
\ No newline at end of file
+}
+
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt
index 39134414..c9b71703 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt
@@ -48,11 +48,19 @@ import java.io.ByteArrayInputStream
import java.lang.ref.WeakReference
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
+import com.tonyodev.fetch2.Fetch
+import com.tonyodev.fetch2.FetchConfiguration
class RemoteSideContext(
val androidContext: Context
) {
+ val fetch: Fetch by lazy {
+ val fetchConfiguration = FetchConfiguration.Builder(androidContext)
+ .setDownloadConcurrentLimit(3)
+ .build()
+ Fetch.getInstance(fetchConfiguration)
+ }
val coroutineScope = CoroutineScope(Dispatchers.IO)
private var _activity: WeakReference? = null
@@ -64,11 +72,12 @@ class RemoteSideContext(
val sharedPreferences: SharedPreferences get() = androidContext.getSharedPreferences("prefs", 0)
val fileHandleManager = RemoteFileHandleManager(this)
+ val database = AppDatabase(this)
+ val trackerDataManager = me.rhunk.snapenhance.storage.TrackerDataManagerImpl(database)
val config = ModConfig(androidContext, constantLazyBridge { fileHandleManager })
val translation = LocaleWrapper(constantLazyBridge { fileHandleManager })
val mappings = MappingsWrapper(constantLazyBridge { fileHandleManager })
val taskManager = TaskManager(this)
- val database = AppDatabase(this)
val streaksReminder = StreaksReminder(this)
val log = LogManager(this)
val scriptManager = RemoteScriptManager(this)
@@ -145,11 +154,12 @@ class RemoteSideContext(
val installationSummary by lazy {
InstallationSummary(
snapchatInfo = mappings.getSnapchatPackageInfo()?.let {
+ val packageName = requireNotNull(it.packageName) { "Package name cannot be null" }
SnapchatAppInfo(
- packageName = it.packageName,
- version = it.versionName,
+ packageName = packageName,
+ version = it.versionName ?: "unknown",
versionCode = it.longVersionCode,
- isLSPatched = it.applicationInfo.appComponentFactory != CoreComponentFactory::class.java.name,
+ isLSPatched = it.applicationInfo?.appComponentFactory != CoreComponentFactory::class.java.name,
isSplitApk = it.splitNames?.isNotEmpty() ?: false
)
},
@@ -238,3 +248,4 @@ class RemoteSideContext(
androidContext.startActivity(intent)
}
}
+
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt
index d548bf2f..0076139c 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt
@@ -50,34 +50,28 @@ class BridgeService : Service() {
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
return
}
- val modDatabase = remoteSideContext.database
+
+ val database = remoteSideContext.database
val syncedObject = when (scope) {
SocialScope.FRIEND -> {
- if (updateOnly && modDatabase.getFriendInfo(id) == null) return
+ if (updateOnly && database.getFriendInfo(id) == null) return
syncCallback.syncFriend(id)
}
SocialScope.GROUP -> {
- if (updateOnly && modDatabase.getGroupInfo(id) == null) return
+ if (updateOnly && database.getGroupInfo(id) == null) return
syncCallback.syncGroup(id)
}
- else -> null
- }
-
- if (syncedObject == null) {
+ } ?: run {
remoteSideContext.log.warn("Failed to sync $scope $id")
return
}
when (scope) {
SocialScope.FRIEND -> {
- toParcelable(syncedObject)?.let {
- modDatabase.syncFriend(it)
- }
+ toParcelable(syncedObject)?.let { database.syncFriend(it) }
}
SocialScope.GROUP -> {
- toParcelable(syncedObject)?.let {
- modDatabase.syncGroupInfo(it)
- }
+ toParcelable(syncedObject)?.let { database.syncGroupInfo(it) }
}
}
}.onFailure {
@@ -247,3 +241,4 @@ class BridgeService : Service() {
}
}
}
+
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt b/app/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt
index f7961765..43171af3 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt
@@ -4,7 +4,13 @@ import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface
import me.rhunk.snapenhance.bridge.e2ee.EncryptionResult
import me.rhunk.snapenhance.core.util.EvictingMap
-import org.bouncycastle.pqc.crypto.crystals.kyber.*
+import org.bouncycastle.pqc.crypto.crystals.kyber.KyberKEMExtractor
+import org.bouncycastle.pqc.crypto.crystals.kyber.KyberKEMGenerator
+import org.bouncycastle.pqc.crypto.crystals.kyber.KyberKeyGenerationParameters
+import org.bouncycastle.pqc.crypto.crystals.kyber.KyberKeyPairGenerator
+import org.bouncycastle.pqc.crypto.crystals.kyber.KyberParameters
+import org.bouncycastle.pqc.crypto.crystals.kyber.KyberPrivateKeyParameters
+import org.bouncycastle.pqc.crypto.crystals.kyber.KyberPublicKeyParameters
import java.io.File
import java.security.MessageDigest
import java.security.SecureRandom
@@ -163,4 +169,4 @@ class E2EEImplementation (
return null
}.getOrNull()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt
index c4de8670..27a71cb5 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt
@@ -145,8 +145,11 @@ class RemoteScriptManager(
if (!response.isSuccessful) {
throw Exception("Failed to fetch script. Code: ${response.code}")
}
- response.body.byteStream().use { inputStream ->
- val bufferedInputStream = inputStream.buffered()
+
+ val inputStream = response.body?.byteStream() ?: throw Exception("Response body is null or cannot be read")
+
+ inputStream.use {
+ val bufferedInputStream = it.buffered()
bufferedInputStream.mark(0)
val moduleInfo = bufferedInputStream.bufferedReader().readModuleInfo()
bufferedInputStream.reset()
@@ -173,7 +176,7 @@ class RemoteScriptManager(
if (!response.isSuccessful) {
return@runCatching null
}
- response.body.byteStream().use { inputStream ->
+ response.body?.byteStream()?.use { inputStream ->
val reader = inputStream.buffered().bufferedReader()
val moduleInfo = reader.readModuleInfo()
moduleInfo.takeIf {
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt
index b59f36a5..350982dc 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt
@@ -63,6 +63,7 @@ class AppDatabase(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"enabled BOOLEAN DEFAULT 1",
"name VARCHAR",
+ "author VARCHAR",
),
"tracker_scopes" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
@@ -104,7 +105,9 @@ class AppDatabase(
"content TEXT",
),
"repositories" to listOf(
- "url VARCHAR PRIMARY KEY",
+ "url VARCHAR",
+ "type VARCHAR",
+ "PRIMARY KEY (url, type)"
),
"notes" to listOf(
"id CHAR(36) PRIMARY KEY",
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt
index bfec9920..82ac4a54 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt
@@ -7,7 +7,7 @@ import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.common.util.ktx.getInteger
import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
-
+import java.io.Serializable
fun AppDatabase.getGroups(): List {
return database.rawQuery("SELECT * FROM groups", null).use { cursor ->
@@ -20,7 +20,10 @@ fun AppDatabase.getGroups(): List {
}
fun AppDatabase.getFriends(descOrder: Boolean = false): List {
- return database.rawQuery("SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id ORDER BY id ${if (descOrder) "DESC" else "ASC"}", null).use { cursor ->
+ return database.rawQuery(
+ "SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id ORDER BY id ${if (descOrder) "DESC" else "ASC"}",
+ null
+ ).use { cursor ->
val friends = mutableListOf()
while (cursor.moveToNext()) {
runCatching {
@@ -33,15 +36,17 @@ fun AppDatabase.getFriends(descOrder: Boolean = false): List(
friend.userId,
friend.dmConversationId,
friend.displayName,
@@ -65,13 +70,15 @@ fun AppDatabase.syncFriend(friend: MessagingFriendInfo) {
//sync streaks
friend.streaks?.takeIf { it.length > 0 }?.also {
val streaks = getFriendStreaks(friend.userId)
-
- database.execSQL("INSERT OR REPLACE INTO streaks (id, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)", arrayOf(
- friend.userId,
- streaks?.notify != false,
- it.expirationTimestamp,
- it.length
- ))
+ database.execSQL(
+ "INSERT OR REPLACE INTO streaks (id, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)",
+ arrayOf(
+ friend.userId,
+ streaks?.notify != false,
+ it.expirationTimestamp,
+ it.length
+ )
+ )
} ?: database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
} catch (e: Exception) {
throw e
@@ -79,16 +86,18 @@ fun AppDatabase.syncFriend(friend: MessagingFriendInfo) {
}
}
-
-
fun AppDatabase.getRules(targetUuid: String): List {
- return database.rawQuery("SELECT type FROM rules WHERE targetUuid = ?", arrayOf(
- targetUuid
- )).use { cursor ->
+ return database.rawQuery(
+ "SELECT type FROM rules WHERE targetUuid = ?", arrayOf(targetUuid)
+ ).use { cursor ->
val rules = mutableListOf()
while (cursor.moveToNext()) {
runCatching {
- rules.add(MessagingRuleType.getByName(cursor.getStringOrNull("type")!!) ?: return@runCatching)
+ cursor.getStringOrNull("type")?.let {
+ MessagingRuleType.getByName(it)
+ }?.let { ruleType ->
+ rules.add(ruleType)
+ }
}.onFailure {
context.log.error("Failed to parse rule", it)
}
@@ -100,28 +109,33 @@ fun AppDatabase.getRules(targetUuid: String): List {
fun AppDatabase.setRule(targetUuid: String, type: String, enabled: Boolean) {
executeAsync {
if (enabled) {
- database.execSQL("INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)", arrayOf(
- targetUuid,
- type
- ))
+ database.execSQL(
+ "INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)",
+ arrayOf(targetUuid, type)
+ )
} else {
- database.execSQL("DELETE FROM rules WHERE targetUuid = ? AND type = ?", arrayOf(
- targetUuid,
- type
- ))
+ database.execSQL(
+ "DELETE FROM rules WHERE targetUuid = ? AND type = ?",
+ arrayOf(targetUuid, type)
+ )
}
}
}
fun AppDatabase.getFriendInfo(userId: String): MessagingFriendInfo? {
- return database.rawQuery("SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id WHERE userId = ?", arrayOf(userId)).use { cursor ->
+ return database.rawQuery(
+ "SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id WHERE userId = ?",
+ arrayOf(userId)
+ ).use { cursor ->
if (!cursor.moveToFirst()) return@use null
MessagingFriendInfo.fromCursor(cursor)
}
}
fun AppDatabase.findFriend(conversationId: String): MessagingFriendInfo? {
- return database.rawQuery("SELECT * FROM friends WHERE dmConversationId = ?", arrayOf(conversationId)).use { cursor ->
+ return database.rawQuery(
+ "SELECT * FROM friends WHERE dmConversationId = ?", arrayOf(conversationId)
+ ).use { cursor ->
if (!cursor.moveToFirst()) return@use null
MessagingFriendInfo.fromCursor(cursor)
}
@@ -143,14 +157,18 @@ fun AppDatabase.deleteGroup(conversationId: String) {
}
fun AppDatabase.getGroupInfo(conversationId: String): MessagingGroupInfo? {
- return database.rawQuery("SELECT * FROM groups WHERE conversationId = ?", arrayOf(conversationId)).use { cursor ->
+ return database.rawQuery(
+ "SELECT * FROM groups WHERE conversationId = ?", arrayOf(conversationId)
+ ).use { cursor ->
if (!cursor.moveToFirst()) return@use null
MessagingGroupInfo.fromCursor(cursor)
}
}
fun AppDatabase.getFriendStreaks(userId: String): FriendStreaks? {
- return database.rawQuery("SELECT * FROM streaks WHERE id = ?", arrayOf(userId)).use { cursor ->
+ return database.rawQuery(
+ "SELECT * FROM streaks WHERE id = ?", arrayOf(userId)
+ ).use { cursor ->
if (!cursor.moveToFirst()) return@use null
FriendStreaks(
notify = cursor.getInteger("notify") == 1,
@@ -162,18 +180,20 @@ fun AppDatabase.getFriendStreaks(userId: String): FriendStreaks? {
fun AppDatabase.setFriendStreaksNotify(userId: String, notify: Boolean) {
executeAsync {
- database.execSQL("UPDATE streaks SET notify = ? WHERE id = ?", arrayOf(
- if (notify) 1 else 0,
- userId
- ))
+ database.execSQL(
+ "UPDATE streaks SET notify = ? WHERE id = ?",
+ arrayOf(if (notify) 1 else 0, userId)
+ )
}
}
fun AppDatabase.getRuleIds(type: String): MutableList {
- return database.rawQuery("SELECT targetUuid FROM rules WHERE type = ?", arrayOf(type)).use { cursor ->
+ return database.rawQuery(
+ "SELECT targetUuid FROM rules WHERE type = ?", arrayOf(type)
+ ).use { cursor ->
val ruleIds = mutableListOf()
while (cursor.moveToNext()) {
- ruleIds.add(cursor.getStringOrNull("targetUuid")!!)
+ cursor.getStringOrNull("targetUuid")?.let { ruleIds.add(it) }
}
ruleIds
}
@@ -184,4 +204,3 @@ fun AppDatabase.clearRuleIds(type: String) {
database.execSQL("DELETE FROM rules WHERE type = ?", arrayOf(type))
}
}
-
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/QuickTiles.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/QuickTiles.kt
index facd56d2..7066e656 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/storage/QuickTiles.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/QuickTiles.kt
@@ -2,12 +2,14 @@ package me.rhunk.snapenhance.storage
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
-
fun AppDatabase.getQuickTiles(): List {
return database.rawQuery("SELECT `key` FROM quick_tiles ORDER BY position ASC", null).use { cursor ->
val keys = mutableListOf()
while (cursor.moveToNext()) {
- keys.add(cursor.getStringOrNull("key") ?: continue)
+ val key = cursor.getStringOrNull("key")
+ if (key != null) {
+ keys.add(key)
+ }
}
keys
}
@@ -17,10 +19,10 @@ fun AppDatabase.setQuickTiles(keys: List) {
executeAsync {
database.execSQL("DELETE FROM quick_tiles")
keys.forEachIndexed { index, key ->
- database.execSQL("INSERT INTO quick_tiles (`key`, position) VALUES (?, ?)", arrayOf(
- key,
- index
- ))
+ database.execSQL(
+ "INSERT INTO quick_tiles (`key`, position) VALUES (?, ?)",
+ arrayOf(key, index)
+ )
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Repositories.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Repositories.kt
index 1cd9843d..f96d15f2 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Repositories.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Repositories.kt
@@ -6,9 +6,9 @@ import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
-fun AppDatabase.getRepositories(): List {
+fun AppDatabase.getRepositories(type: String): List {
return runBlocking(executor.asCoroutineDispatcher()) {
- database.rawQuery("SELECT url FROM repositories", null).use { cursor ->
+ database.rawQuery("SELECT url FROM repositories WHERE type = ?", arrayOf(type)).use { cursor ->
val repos = mutableListOf()
while (cursor.moveToNext()) {
repos.add(cursor.getStringOrNull("url") ?: continue)
@@ -18,16 +18,17 @@ fun AppDatabase.getRepositories(): List {
}
}
-fun AppDatabase.removeRepo(url: String) {
+fun AppDatabase.removeRepo(type: String, url: String) {
runBlocking(executor.asCoroutineDispatcher()) {
- database.delete("repositories", "url = ?", arrayOf(url))
+ database.delete("repositories", "url = ? AND type = ?", arrayOf(url, type))
}
}
-fun AppDatabase.addRepo(url: String) {
+fun AppDatabase.addRepo(type: String, url: String) {
runBlocking(executor.asCoroutineDispatcher()) {
database.insert("repositories", null, ContentValues().apply {
put("url", url)
+ put("type", type)
})
}
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt
index d40eb170..0b49f448 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt
@@ -13,10 +13,9 @@ import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
import kotlin.coroutines.suspendCoroutine
-
fun AppDatabase.clearTrackerRules() {
runBlocking {
- suspendCoroutine { continuation ->
+ suspendCoroutine { continuation ->
executeAsync {
database.execSQL("DELETE FROM tracker_rules")
database.execSQL("DELETE FROM tracker_rules_events")
@@ -33,12 +32,13 @@ fun AppDatabase.deleteTrackerRule(ruleId: Int) {
}
}
-fun AppDatabase.newTrackerRule(name: String = "Custom Rule"): Int {
+fun AppDatabase.newTrackerRule(name: String = "Custom Rule", author: String? = null): Int {
return runBlocking {
- suspendCoroutine { continuation ->
+ suspendCoroutine { continuation ->
executeAsync {
val id = database.insert("tracker_rules", null, ContentValues().apply {
put("name", name)
+ put("author", author)
})
continuation.resumeWith(Result.success(id.toInt()))
}
@@ -54,10 +54,10 @@ fun AppDatabase.addOrUpdateTrackerRuleEvent(
actions: List
): Int? {
return runBlocking {
- suspendCoroutine { continuation ->
+ suspendCoroutine { continuation ->
executeAsync {
val id = if (ruleEventId != null) {
- database.execSQL("UPDATE tracker_rules_events SET params = ?, actions = ? WHERE id = ?", arrayOf(
+ database.execSQL("UPDATE tracker_rules_events SET params = ?, actions = ? WHERE id = ?", arrayOf(
context.gson.toJson(params),
context.gson.toJson(actions.map { it.key }),
ruleEventId
@@ -85,7 +85,6 @@ fun AppDatabase.deleteTrackerRuleEvent(eventId: Int) {
fun AppDatabase.getTrackerRulesDesc(): List {
val rules = mutableListOf()
-
database.rawQuery("SELECT * FROM tracker_rules ORDER BY id DESC", null).use { cursor ->
while (cursor.moveToNext()) {
rules.add(
@@ -93,11 +92,11 @@ fun AppDatabase.getTrackerRulesDesc(): List {
id = cursor.getInteger("id"),
enabled = cursor.getInteger("enabled") == 1,
name = cursor.getStringOrNull("name") ?: "",
+ author = cursor.getStringOrNull("author")
)
)
}
}
-
return rules
}
@@ -108,6 +107,19 @@ fun AppDatabase.getTrackerRule(ruleId: Int): TrackerRule? {
id = cursor.getInteger("id"),
enabled = cursor.getInteger("enabled") == 1,
name = cursor.getStringOrNull("name") ?: "",
+ author = cursor.getStringOrNull("author")
+ )
+ }
+}
+
+fun AppDatabase.getTrackerRuleByName(name: String): TrackerRule? {
+ return database.rawQuery("SELECT * FROM tracker_rules WHERE name = ?", arrayOf(name)).use { cursor ->
+ if (!cursor.moveToFirst()) return@use null
+ TrackerRule(
+ id = cursor.getInteger("id"),
+ enabled = cursor.getInteger("enabled") == 1,
+ name = cursor.getStringOrNull("name") ?: "",
+ author = cursor.getStringOrNull("author")
)
}
}
@@ -118,9 +130,15 @@ fun AppDatabase.setTrackerRuleName(ruleId: Int, name: String) {
}
}
+fun AppDatabase.setTrackerRuleAuthor(ruleId: Int, author: String) {
+ executeAsync {
+ database.execSQL("UPDATE tracker_rules SET author = ? WHERE id = ?", arrayOf(author, ruleId))
+ }
+}
+
fun AppDatabase.setTrackerRuleState(ruleId: Int, enabled: Boolean) {
executeAsync {
- database.execSQL("UPDATE tracker_rules SET enabled = ? WHERE id = ?", arrayOf(if (enabled) 1 else 0, ruleId))
+ database.execSQL("UPDATE tracker_rules SET enabled = ? WHERE id = ?", arrayOf(if (enabled) 1 else 0, ruleId))
}
}
@@ -128,10 +146,12 @@ fun AppDatabase.getTrackerEvents(ruleId: Int): List {
val events = mutableListOf()
database.rawQuery("SELECT * FROM tracker_rules_events WHERE rule_id = ?", arrayOf(ruleId.toString())).use { cursor ->
while (cursor.moveToNext()) {
+ val eventType = cursor.getStringOrNull("event_type")
+ if (eventType == null) continue
events.add(
TrackerRuleEvent(
id = cursor.getInteger("id"),
- eventType = cursor.getStringOrNull("event_type") ?: continue,
+ eventType = eventType,
enabled = cursor.getInteger("flags") == 1,
params = context.gson.fromJson(cursor.getStringOrNull("params") ?: "{}", TrackerRuleActionParams::class.java),
actions = context.gson.fromJson(cursor.getStringOrNull("actions") ?: "[]", JsonArray::class.java).mapNotNull {
@@ -146,7 +166,8 @@ fun AppDatabase.getTrackerEvents(ruleId: Int): List {
fun AppDatabase.getTrackerEvents(eventType: String): Map {
val events = mutableMapOf()
- database.rawQuery("SELECT tracker_rules_events.id as event_id, tracker_rules_events.params as event_params," +
+ database.rawQuery(
+ "SELECT tracker_rules_events.id as event_id, tracker_rules_events.params as event_params," +
"tracker_rules_events.actions, tracker_rules_events.flags, tracker_rules_events.event_type, tracker_rules.name, tracker_rules.id as rule_id " +
"FROM tracker_rules_events " +
"INNER JOIN tracker_rules " +
@@ -154,14 +175,17 @@ fun AppDatabase.getTrackerEvents(eventType: String): Map
while (cursor.moveToNext()) {
+ val name = cursor.getStringOrNull("name") ?: ""
val trackerRule = TrackerRule(
id = cursor.getInteger("rule_id"),
enabled = true,
- name = cursor.getStringOrNull("name") ?: "",
+ name = name,
)
+ val curEventType = cursor.getStringOrNull("event_type")
+ if (curEventType == null) continue
val trackerRuleEvent = TrackerRuleEvent(
id = cursor.getInteger("event_id"),
- eventType = cursor.getStringOrNull("event_type") ?: continue,
+ eventType = curEventType,
enabled = cursor.getInteger("flags") == 1,
params = context.gson.fromJson(cursor.getStringOrNull("event_params") ?: "{}", TrackerRuleActionParams::class.java),
actions = context.gson.fromJson(cursor.getStringOrNull("actions") ?: "[]", JsonArray::class.java).mapNotNull {
@@ -178,20 +202,24 @@ fun AppDatabase.setRuleTrackerScopes(ruleId: Int, type: TrackerScopeType, scopes
executeAsync {
database.execSQL("DELETE FROM tracker_scopes WHERE rule_id = ?", arrayOf(ruleId))
scopes.forEach { scopeId ->
- database.execSQL("INSERT INTO tracker_scopes (rule_id, scope_type, scope_id) VALUES (?, ?, ?)", arrayOf(
- ruleId,
- type.key,
- scopeId
- ))
+ database.execSQL(
+ "INSERT INTO tracker_scopes (rule_id, scope_type, scope_id) VALUES (?, ?, ?)",
+ arrayOf(ruleId, type.key, scopeId)
+ )
}
}
}
fun AppDatabase.getRuleTrackerScopes(ruleId: Int, limit: Int = Int.MAX_VALUE): Map {
val scopes = mutableMapOf()
- database.rawQuery("SELECT * FROM tracker_scopes WHERE rule_id = ? LIMIT ?", arrayOf(ruleId.toString(), limit.toString())).use { cursor ->
+ database.rawQuery(
+ "SELECT * FROM tracker_scopes WHERE rule_id = ? LIMIT ?", arrayOf(ruleId.toString(), limit.toString())
+ ).use { cursor ->
while (cursor.moveToNext()) {
- scopes[cursor.getStringOrNull("scope_id") ?: continue] = TrackerScopeType.entries.find { it.key == cursor.getStringOrNull("scope_type") } ?: continue
+ val scopeId = cursor.getStringOrNull("scope_id") ?: continue
+ val scopeTypeKey = cursor.getStringOrNull("scope_type")
+ val type = TrackerScopeType.entries.find { it.key == scopeTypeKey } ?: continue
+ scopes[scopeId] = type
}
}
return scopes
@@ -199,21 +227,19 @@ fun AppDatabase.getRuleTrackerScopes(ruleId: Int, limit: Int = Int.MAX_VALUE): M
fun AppDatabase.updateFriendScore(userId: String, score: Long): Long {
return runBlocking {
- suspendCoroutine { continuation ->
+ suspendCoroutine { continuation ->
executeAsync {
val currentScore = database.rawQuery("SELECT score FROM friend_scores WHERE userId = ?", arrayOf(userId)).use { cursor ->
if (!cursor.moveToFirst()) return@use null
cursor.getLongOrNull("score")
}
-
if (currentScore != null) {
database.execSQL("UPDATE friend_scores SET score = ? WHERE userId = ?", arrayOf(score, userId))
} else {
database.execSQL("INSERT INTO friend_scores (userId, score) VALUES (?, ?)", arrayOf(userId, score))
}
-
continuation.resumeWith(Result.success(currentScore ?: -1))
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/TrackerDataManagerImpl.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/TrackerDataManagerImpl.kt
new file mode 100644
index 00000000..f562c788
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/TrackerDataManagerImpl.kt
@@ -0,0 +1,59 @@
+package me.rhunk.snapenhance.storage
+
+import me.rhunk.snapenhance.common.data.ExportedTrackerData
+import me.rhunk.snapenhance.common.data.TrackerDataManager
+import me.rhunk.snapenhance.storage.AppDatabase
+
+class TrackerDataManagerImpl(private val db: AppDatabase) : TrackerDataManager {
+ override fun getExportedTrackerData(): ExportedTrackerData {
+ return ExportedTrackerData(
+ type = me.rhunk.snapenhance.common.data.ExportType.BULK,
+ rules = db.getTrackerRulesDesc().map { rule ->
+ rule.copy(
+ events = db.getTrackerEvents(rule.id),
+ scopes = db.getRuleTrackerScopes(rule.id)
+ )
+ }
+ )
+ }
+
+ override fun getExportedTrackerData(ruleId: Int): ExportedTrackerData? {
+ return db.getTrackerRule(ruleId)?.let {
+ ExportedTrackerData(
+ type = me.rhunk.snapenhance.common.data.ExportType.SINGLE,
+ rules = listOf(it.copy(
+ events = db.getTrackerEvents(it.id),
+ scopes = db.getRuleTrackerScopes(it.id)
+ ))
+ )
+ }
+ }
+
+ override fun importTrackerData(data: ExportedTrackerData) {
+ if (data.type == me.rhunk.snapenhance.common.data.ExportType.BULK) {
+ db.clearTrackerRules()
+ }
+ data.rules.forEach { rule ->
+ if (db.getTrackerRuleByName(rule.name) != null) {
+ return@forEach
+ }
+ val ruleId = db.newTrackerRule(rule.name, rule.author)
+ db.setTrackerRuleState(ruleId, rule.enabled)
+ rule.events?.forEach { event ->
+ db.addOrUpdateTrackerRuleEvent(
+ ruleId = ruleId,
+ eventType = event.eventType,
+ params = event.params,
+ actions = event.actions
+ )
+ }
+ rule.scopes?.let { scopes ->
+ if (scopes.isNotEmpty()) {
+ val scopeType = scopes.values.first()
+ val scopeIds = scopes.keys.toList()
+ db.setRuleTrackerScopes(ruleId, scopeType, scopeIds)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/task/UpdateCheckWorker.kt b/app/src/main/kotlin/me/rhunk/snapenhance/task/UpdateCheckWorker.kt
new file mode 100644
index 00000000..dc9c5d5a
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/task/UpdateCheckWorker.kt
@@ -0,0 +1,74 @@
+package me.rhunk.snapenhance.task
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.work.CoroutineWorker
+import android.Manifest
+import androidx.core.content.ContextCompat
+import android.content.pm.PackageManager
+import androidx.work.WorkerParameters
+import me.rhunk.snapenhance.R
+import me.rhunk.snapenhance.ui.manager.MainActivity
+import me.rhunk.snapenhance.ui.manager.data.Updater
+
+class UpdateCheckWorker(
+ private val appContext: Context,
+ workerParams: WorkerParameters
+) : CoroutineWorker(appContext, workerParams) {
+
+ override suspend fun doWork(): Result {
+ return try {
+ val latestRelease = Updater.latestRelease
+ if (latestRelease != null) {
+ showUpdateNotification(latestRelease.versionName)
+ }
+ Result.success()
+ } catch (e: Exception) {
+ Result.failure()
+ }
+ }
+
+ private fun showUpdateNotification(versionName: String) {
+ val channelId = "snapenhance_updates"
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val name = "SnapEnhance Updates"
+ val descriptionText = "Notifications for SnapEnhance updates"
+ val importance = NotificationManager.IMPORTANCE_DEFAULT
+ val channel = NotificationChannel(channelId, name, importance).apply {
+ description = descriptionText
+ }
+ val notificationManager: NotificationManager =
+ appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ val intent = Intent(appContext, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ val pendingIntent: PendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
+
+ val builder = NotificationCompat.Builder(appContext, channelId)
+ .setSmallIcon(R.drawable.launcher_icon_monochrome)
+ .setContentTitle("PurrfectSnap Update Available")
+ .setContentText("Version $versionName is now available.")
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setContentIntent(pendingIntent)
+ .setAutoCancel(true)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ // Cannot request permission from a worker. The user must grant it from the app's settings.
+ return
+ }
+ }
+ with(NotificationManagerCompat.from(appContext)) {
+ notify(1, builder.build())
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/MainActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/MainActivity.kt
index 58d91c65..5a4b9998 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/MainActivity.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/MainActivity.kt
@@ -1,18 +1,42 @@
+@file:OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
package me.rhunk.snapenhance.ui.manager
+import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
-import androidx.compose.runtime.remember
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.foundation.background
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.navigationBars
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsControllerCompat
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
+import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.common.ui.AppMaterialTheme
+import me.rhunk.snapenhance.common.ui.ThemeMode
+import me.rhunk.snapenhance.common.ui.ThemePreferences
+import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
class MainActivity : ComponentActivity() {
private lateinit var navController: NavHostController
@@ -24,7 +48,7 @@ class MainActivity : ComponentActivity() {
intent.getStringExtra("route")?.let { route ->
navController.popBackStack()
navController.navigate(route) {
- popUpTo(navController.graph.findStartDestination().id){
+ popUpTo(navController.graph.findStartDestination().id) {
inclusive = true
}
}
@@ -32,32 +56,119 @@ class MainActivity : ComponentActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
super.onCreate(savedInstanceState)
-
managerContext = SharedContextHolder.remote(this).apply {
activity = this@MainActivity
checkForRequirements()
}
-
val routes = Routes(managerContext)
+ routes.activityLauncher = ActivityLauncherHelper(this)
routes.getRoutes().forEach { it.init() }
setContent {
+ val context = LocalContext.current
+ // ThemeMode is tracked directly
+ val themeMode by ThemePreferences.getThemeModeFlow(context).collectAsState(initial = ThemeMode.SYSTEM)
+ // USE THE CORRECT CONTROLLER (not accompanist):
navController = rememberNavController()
val navigation = remember {
Navigation(managerContext, navController, routes.also {
it.navController = navController
})
}
- val startDestination = remember { intent.getStringExtra("route") ?: routes.home.routeInfo.id }
-
- AppMaterialTheme {
+ val startDestination = remember {
+ intent.getStringExtra("route") ?: run {
+ val def = managerContext.sharedPreferences.getString("manager_default_tab", "home") ?: "home"
+ val allowed = setOf("tasks", "features", "home", "social", "scripts")
+ if (def in allowed) def else "home"
+ }
+ }
+ AppMaterialTheme(themeMode = themeMode) {
+ val background = MaterialTheme.colorScheme.background
+ val isLight = background.luminance() > 0.5f
+ val view = LocalView.current
+ @Suppress("DEPRECATION")
+ SideEffect {
+ val window = (view.context as Activity).window
+ // Modern coloring:
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ window.statusBarColor = Color.Transparent.toArgb()
+ window.navigationBarColor = Color.Transparent.toArgb()
+ val insetsController = WindowInsetsControllerCompat(window, window.decorView)
+ insetsController.isAppearanceLightStatusBars = isLight
+ insetsController.isAppearanceLightNavigationBars = isLight
+ }
+ // Floating bottom bar height and vertical spacing so floating action buttons and scrolling content remain readable:
+ val bottomPadding = 80.dp + 16.dp +
+ WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ routes.bottomPadding = bottomPadding
+ val navBackStackEntry by navController.currentBackStackEntryAsState()
+ val currentRoute = navBackStackEntry?.destination?.route
+ val fullscreenRoutes = remember {
+ listOf(
+ Routes.CONFIG_IMPORT_CONFIRMATION_ROUTE,
+ Routes.CONFIG_EXPORT_SUMMARY_ROUTE,
+ Routes.FRIEND_TRACKER_CONFIG_EXPORT_ROUTE,
+ Routes.FRIEND_TRACKER_CONFIG_IMPORT_ROUTE
+ )
+ }
+ val isFullscreen = currentRoute in fullscreenRoutes
Scaffold(
+ modifier = Modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.background,
- topBar = { navigation.TopBar() },
- bottomBar = { navigation.BottomBar() },
- floatingActionButton = { navigation.FloatingActionButton() }
- ) { innerPadding -> navigation.Content(innerPadding, startDestination) }
+ topBar = {
+ if (!isFullscreen) {
+ navigation.TopBar()
+ }
+ },
+ floatingActionButton = {
+ if (!isFullscreen) {
+ Box(Modifier.padding(bottom = bottomPadding)) {
+ navigation.Fab()
+ }
+ }
+ },
+ // Disable automatic padding so content can draw behind the bottom bar
+ contentWindowInsets = WindowInsets(0, 0, 0, 0)
+ ) { innerPadding ->
+ Box(Modifier.fillMaxSize()) {
+ val contentPadding = if (!isFullscreen) {
+ PaddingValues(
+ top = innerPadding.calculateTopPadding(),
+ start = innerPadding.calculateStartPadding(LayoutDirection.Ltr),
+ end = innerPadding.calculateEndPadding(LayoutDirection.Ltr),
+ bottom = innerPadding.calculateBottomPadding()
+ )
+ } else {
+ PaddingValues(0.dp)
+ }
+ navigation.NavContent(contentPadding, startDestination)
+ if (!isFullscreen) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .height(routes.bottomPadding)
+ .background(
+ brush = Brush.verticalGradient(
+ colors = listOf(
+ Color.Transparent,
+ MaterialTheme.colorScheme.background
+ )
+ )
+ )
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.BottomCenter)
+ ) {
+ navigation.FloatingBottomBar()
+ }
+ }
+ }
+ }
}
}
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt
index 05b7fc49..4806fe72 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt
@@ -1,145 +1,565 @@
package me.rhunk.snapenhance.ui.manager
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
-import androidx.compose.foundation.layout.*
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material3.*
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.DragHandle
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationBarItemDefaults
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.rememberModalBottomSheetState
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.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
+import androidx.compose.ui.zIndex
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
-import androidx.navigation.navigation
+import androidx.navigation.compose.navigation
import me.rhunk.snapenhance.RemoteSideContext
+import kotlin.math.round
+import kotlin.math.PI
+import kotlin.math.sin
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(
+ ExperimentalMaterial3Api::class,
+ ExperimentalFoundationApi::class,
+ ExperimentalLayoutApi::class,
+ androidx.compose.animation.ExperimentalAnimationApi::class
+)
class Navigation(
private val context: RemoteSideContext,
private val navController: NavHostController,
- val routes: Routes = Routes(context).also {
- it.navController = navController
- }
-){
+ val routes: Routes = Routes(context).also { it.navController = navController }
+) {
+ var openBottomBarCustomization by mutableStateOf(false)
@Composable
fun TopBar() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
-
- val canGoBack = remember(navBackStackEntry) { currentRoute?.let {
- !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination)
- } == true }
-
- TopAppBar(title = {
- currentRoute?.apply {
- title?.invoke() ?: routeInfo.translatedKey?.value?.let {
- Text(text = it)
+ if (currentRoute?.routeInfo?.hasOwnTopBar == true) return
+ val canGoBack = remember(navBackStackEntry) {
+ currentRoute?.let { !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination) } == true
+ }
+ TopAppBar(
+ title = {
+ currentRoute?.apply {
+ title?.invoke() ?: routeInfo.translatedKey?.value?.let { Text(it) }
}
- }
- }, navigationIcon = {
- val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f,
- label = "backButtonAnimation"
- )
-
- Box(
- modifier = Modifier
- .graphicsLayer { alpha = backButtonAnimation }
- .width(lerp(0.dp, 48.dp, backButtonAnimation))
- .height(48.dp)
- ) {
- IconButton(
- onClick = {
- if (canGoBack) {
- navController.popBackStack()
- }
- }
+ },
+ navigationIcon = {
+ val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f, label = "backButton")
+ Box(
+ modifier = Modifier
+ .graphicsLayer { alpha = backButtonAnimation }
+ .width(lerp(0.dp, 48.dp, backButtonAnimation))
+ .height(48.dp)
) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+ IconButton(onClick = { if (canGoBack) navController.popBackStack() }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+ }
+ }
+ },
+ actions = {
+ currentRoute?.topBarActions?.invoke(this)
+ if (currentRoute?.routeInfo?.id == routes.settings.routeInfo.id) {
+ IconButton(onClick = { openBottomBarCustomization = true }) {
+ Icon(Icons.Filled.Tune, contentDescription = null)
+ }
}
}
- }, actions = {
- currentRoute?.topBarActions?.invoke(this)
- })
+ )
}
-
@Composable
- fun BottomBar() {
+ fun FloatingBottomBar() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
- val primaryRoutes = remember { routes.getRoutes().filter { it.routeInfo.showInNavBar } }
-
- NavigationBar {
- primaryRoutes.forEach { route ->
- NavigationBarItem(
- alwaysShowLabel = true,
- icon = {
- Icon(imageVector = route.routeInfo.icon, contentDescription = null)
- },
- label = {
- Text(
- textAlign = TextAlign.Center,
- softWrap = false,
- fontSize = 12.sp,
- modifier = Modifier.wrapContentWidth(unbounded = true),
- text = remember(context.translation.loadedLocale) { context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"] },
- )
- },
- selected = currentRoute == route,
- onClick = {
- route.navigateReset()
+ val availableRoutes = remember {
+ listOf(routes.tasks, routes.features, routes.home, routes.social, routes.scripting, routes.friendTracker)
+ }
+ val availableRouteMap = remember(availableRoutes) { availableRoutes.associateBy { it.routeInfo.id } }
+ val prefs = remember { context.sharedPreferences }
+ val defaultOrder = remember { listOf("tasks", "features", "home", "social", "scripts") }
+ fun loadSelected(): List {
+ val raw = prefs.getString("manager_nav_tabs", null)?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
+ val cleaned = raw.filter { availableRouteMap.containsKey(it) }
+ val list = (if (cleaned.isNotEmpty()) cleaned else defaultOrder).distinct()
+ return list.take(5)
+ }
+ var defaultTabId by remember { mutableStateOf(prefs.getString("manager_default_tab", "home") ?: "home") }
+ fun saveDefault(id: String) {
+ defaultTabId = id
+ prefs.edit().putString("manager_default_tab", id).apply()
+ }
+ fun saveSelected(ids: List) {
+ if (defaultTabId !in ids) {
+ val candidate = when {
+ "home" in ids -> "home"
+ ids.isNotEmpty() -> ids.first()
+ else -> defaultTabId
+ }
+ saveDefault(candidate)
+ }
+ prefs.edit().putString("manager_nav_tabs", ids.joinToString(",")).apply()
+ }
+ var selectedTabIds by remember { mutableStateOf(loadSelected()) }
+ val selectedRoutes = remember(selectedTabIds) { selectedTabIds.mapNotNull { availableRouteMap[it] } }
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
+ .navigationBarsPadding(),
+ contentAlignment = Alignment.BottomCenter
+ ) {
+ val baseItemWidth = 92.dp
+ val containerPadding = 24.dp
+ val targetBarWidth = if (selectedRoutes.size < 5) baseItemWidth * selectedRoutes.size.toFloat() + containerPadding else null
+ val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth")
+ Surface(
+ shape = RoundedCornerShape(24.dp),
+ color = MaterialTheme.colorScheme.surface,
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
+ modifier = Modifier
+ .then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth())
+ .shadow(
+ elevation = 16.dp,
+ shape = RoundedCornerShape(24.dp),
+ spotColor = MaterialTheme.colorScheme.primary,
+ ambientColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
+ )
+ ) {
+ Box(Modifier.fillMaxWidth().height(80.dp)) {
+ var barWidthPx by remember { mutableStateOf(0f) }
+ val itemCount = selectedRoutes.size.coerceAtLeast(1)
+ val density = androidx.compose.ui.platform.LocalDensity.current
+ val selectedIndex = remember(currentRoute, selectedRoutes) {
+ selectedRoutes.indexOf(currentRoute).coerceAtLeast(0)
}
- )
+ val itemWidthPx = remember(barWidthPx, itemCount) { if (itemCount > 0) barWidthPx / itemCount else 0f }
+ val offsetAnim = remember { Animatable(0f) }
+ var lastSelectedIndex by remember { mutableStateOf(selectedIndex) }
+ LaunchedEffect(itemWidthPx) {
+ if (itemWidthPx > 0f) {
+ offsetAnim.snapTo(selectedIndex * itemWidthPx)
+ }
+ }
+ LaunchedEffect(selectedIndex, itemWidthPx) {
+ if (itemWidthPx <= 0f) return@LaunchedEffect
+ val dist = kotlin.math.abs(selectedIndex - lastSelectedIndex).coerceAtLeast(1)
+ val damping = when {
+ dist >= 3 -> 0.65f
+ dist == 2 -> 0.75f
+ else -> 0.90f
+ }
+ val stiffness = Spring.StiffnessMediumLow
+ offsetAnim.animateTo(
+ targetValue = selectedIndex * itemWidthPx,
+ animationSpec = spring(dampingRatio = damping, stiffness = stiffness)
+ )
+ lastSelectedIndex = selectedIndex
+ }
+ val horizontalInset = 8.dp
+ val indicatorWidth = with(density) { itemWidthPx.toDp() } - horizontalInset * 2
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .onGloballyPositioned { barWidthPx = it.size.width.toFloat() }
+ ) {
+ val motionProgress = remember { Animatable(1f) }
+ LaunchedEffect(selectedIndex) {
+ motionProgress.snapTo(0f)
+ val dist = kotlin.math.abs(selectedIndex - lastSelectedIndex).coerceAtLeast(1)
+ val dur = when {
+ dist >= 3 -> 440
+ dist == 2 -> 380
+ else -> 320
+ }
+ motionProgress.animateTo(1f, animationSpec = tween(durationMillis = dur, easing = FastOutSlowInEasing))
+ }
+ val pulse = sin(PI * motionProgress.value).toFloat()
+ val distForScale = kotlin.math.abs(selectedIndex - lastSelectedIndex).coerceAtLeast(1)
+ val scaleXBase = 0.18f
+ val scaleXExtra = 0.06f
+ val scaleYBase = 0.06f
+ val scaleYExtra = 0.02f
+ val mult = (distForScale - 1).coerceAtLeast(0)
+ val scaleXAnim = 1f + (scaleXBase + scaleXExtra * mult) * pulse
+ val scaleYAnim = 1f - (scaleYBase + scaleYExtra * mult) * pulse
+ if (barWidthPx > 0f && itemCount > 0) {
+ val offsetX = with(density) { offsetAnim.value.toDp() } + horizontalInset
+ Box(
+ modifier = Modifier
+ .fillMaxHeight()
+ .width(indicatorWidth.coerceAtLeast(0.dp))
+ .offset(x = offsetX)
+ .padding(vertical = 8.dp)
+ .graphicsLayer { scaleX = scaleXAnim; scaleY = scaleYAnim }
+ .clip(RoundedCornerShape(14.dp))
+ .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.10f))
+ .border(
+ BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.30f)),
+ RoundedCornerShape(14.dp)
+ )
+ )
+ }
+ }
+ NavigationBar(
+ containerColor = Color.Transparent,
+ tonalElevation = 0.dp,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ selectedRoutes.forEach { route ->
+ NavigationBarItem(
+ alwaysShowLabel = true,
+ icon = { Icon(imageVector = route.routeInfo.icon, contentDescription = null) },
+ label = {
+ val label = if (route.routeInfo.id == "friend_tracker") "Tracker" else context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"]
+ val isLong = label.length > 11
+ Text(
+ text = label,
+ textAlign = TextAlign.Center,
+ fontSize = 12.sp,
+ maxLines = if (isLong) 2 else 1,
+ overflow = if (isLong) TextOverflow.Ellipsis else TextOverflow.Clip,
+ softWrap = isLong,
+ modifier = if (isLong) Modifier.widthIn(max = 80.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally)
+ )
+ },
+ selected = currentRoute == route,
+ colors = NavigationBarItemDefaults.colors(indicatorColor = Color.Transparent),
+ onClick = { route.navigateReset() }
+ )
+ }
+ }
+ }
+ }
+ if (openBottomBarCustomization) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ ModalBottomSheet(onDismissRequest = { openBottomBarCustomization = false }, sheetState = sheetState) {
+ Column(Modifier.fillMaxWidth()) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(112.dp)
+ .clip(RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp))
+ .background(
+ brush = Brush.linearGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.16f),
+ MaterialTheme.colorScheme.tertiary.copy(alpha = 0.2f)
+ )
+ )
+ )
+ .padding(horizontal = 20.dp, vertical = 16.dp)
+ ) {
+ Column(Modifier.fillMaxSize()) {
+ Text(text = "Customize Bottom Bar", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface)
+ Spacer(Modifier.height(4.dp))
+ Text(text = "Reorder, add or remove tabs. Max of five.", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 8.dp)
+ .width(36.dp)
+ .height(4.dp)
+ .clip(RoundedCornerShape(50))
+ .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))
+ )
+ }
+ Spacer(Modifier.height(8.dp))
+ Text(text = "Shown Tabs", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(horizontal = 16.dp))
+ Spacer(Modifier.height(8.dp))
+ if (selectedTabIds.isEmpty()) {
+ Text(text = "No tabs selected", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ } else {
+ val haptic = LocalHapticFeedback.current
+ var draggingId by remember { mutableStateOf(null) }
+ var dragDelta by remember { mutableStateOf(0f) }
+ var dragStartIndex by remember { mutableStateOf(-1) }
+ var rowHeight by remember { mutableStateOf(0) }
+ val listState = rememberLazyListState()
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp),
+ contentPadding = PaddingValues(bottom = 8.dp),
+ state = listState
+ ) {
+ itemsIndexed(selectedTabIds, key = { _, id -> id }) { index, id ->
+ val route = availableRouteMap[id] ?: return@itemsIndexed
+ val label = if (route.routeInfo.id == "friend_tracker") "Tracker" else context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"]
+ val isDragging = draggingId == id
+ ElevatedCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 6.dp)
+ .animateItem()
+ .zIndex(if (isDragging) 1f else 0f)
+ .graphicsLayer { if (isDragging) { scaleX = 1.02f; scaleY = 1.02f } }
+ .onGloballyPositioned { if (rowHeight == 0) rowHeight = it.size.height }
+ .pointerInput(id) {
+ detectDragGestures(
+ onDragStart = {
+ draggingId = id
+ dragStartIndex = selectedTabIds.indexOf(id)
+ dragDelta = 0f
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ },
+ onDrag = { _: PointerInputChange, dragAmount ->
+ dragDelta += dragAmount.y
+ if (rowHeight > 0 && dragStartIndex >= 0) {
+ val currentIndex = selectedTabIds.indexOf(id)
+ val deltaRows = round(dragDelta / rowHeight.toFloat()).toInt()
+ val targetIndex = (dragStartIndex + deltaRows).coerceIn(0, selectedTabIds.lastIndex)
+ if (targetIndex != currentIndex) {
+ val list = selectedTabIds.toMutableList()
+ list.removeAt(currentIndex)
+ list.add(targetIndex, id)
+ selectedTabIds = list
+ saveSelected(selectedTabIds)
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
+ }
+ }
+ },
+ onDragEnd = { draggingId = null; dragDelta = 0f; dragStartIndex = -1 },
+ onDragCancel = { draggingId = null; dragDelta = 0f; dragStartIndex = -1 }
+ )
+ }
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(10.dp)
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(Icons.Filled.DragHandle, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.width(8.dp))
+ Icon(route.routeInfo.icon, contentDescription = null)
+ Spacer(Modifier.width(12.dp))
+ Text(text = label, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
+ val defaultEligible = remember { setOf("tasks","features","home","social","scripts") }
+ RadioButton(selected = defaultTabId == id, onClick = { if (id in defaultEligible) saveDefault(id) }, enabled = id in defaultEligible)
+ IconButton(
+ onClick = {
+ if (selectedTabIds.size > 1 && id != defaultTabId && id != "home") {
+ selectedTabIds = selectedTabIds.toMutableList().also { it.removeAt(index) }
+ saveSelected(selectedTabIds)
+ }
+ },
+ enabled = id != defaultTabId && id != "home"
+ ) { Icon(Icons.Filled.Close, contentDescription = null) }
+ }
+ }
+ }
+ }
+ }
+ Spacer(Modifier.height(16.dp))
+ Text(text = "Available Tabs", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(horizontal = 16.dp))
+ Spacer(Modifier.height(8.dp))
+ FlowRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ availableRoutes.forEach { route ->
+ val id = route.routeInfo.id
+ val already = selectedTabIds.contains(id)
+ val label = if (id == "friend_tracker") "Tracker" else context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"]
+ AnimatedVisibility(
+ visible = !already && selectedTabIds.size < 5,
+ enter = scaleIn(tween(160), initialScale = 0.95f) + fadeIn(tween(180)) + slideInVertically(tween(180), initialOffsetY = { it / 3 }),
+ exit = scaleOut(tween(120)) + fadeOut(tween(120)) + slideOutVertically(tween(120))
+ ) {
+ AssistChip(
+ onClick = {
+ if (!already && selectedTabIds.size < 5) {
+ selectedTabIds = selectedTabIds + id
+ saveSelected(selectedTabIds)
+ }
+ },
+ label = { Text(text = label) },
+ leadingIcon = { Icon(route.routeInfo.icon, contentDescription = null) },
+ enabled = true
+ )
+ }
+ }
+ }
+ Spacer(Modifier.height(20.dp))
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ OutlinedButton(onClick = {
+ selectedTabIds = defaultOrder
+ saveSelected(selectedTabIds)
+ }) { Text(text = "Reset", style = MaterialTheme.typography.labelLarge) }
+ Button(onClick = { openBottomBarCustomization = false }) { Text(text = "Done", style = MaterialTheme.typography.labelLarge) }
+ }
+ Spacer(Modifier.height(8.dp))
+ }
+ }
}
}
}
-
@Composable
- fun FloatingActionButton() {
+ fun Fab() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }?.floatingActionButton?.invoke()
}
-
@Composable
- fun Content(paddingValues: PaddingValues, startDestination: String) {
+ fun NavContent(paddingValues: PaddingValues, startDestination: String) {
NavHost(
navController = navController,
startDestination = startDestination,
- Modifier.padding(paddingValues),
- enterTransition = { fadeIn(tween(100)) },
- exitTransition = { fadeOut(tween(100)) }
+ modifier = Modifier.padding(paddingValues)
) {
routes.getRoutes().filter { it.parentRoute == null }.forEach { route ->
val children = routes.getRoutes().filter { it.parentRoute == route }
if (children.isEmpty()) {
- composable(route.routeInfo.id) {
- route.content.invoke(it)
- }
+ val isSummaryScreen = route.routeInfo.id == Routes.CONFIG_IMPORT_CONFIRMATION_ROUTE ||
+ route.routeInfo.id == Routes.CONFIG_EXPORT_SUMMARY_ROUTE ||
+ route.routeInfo.id == Routes.FRIEND_TRACKER_CONFIG_EXPORT_ROUTE ||
+ route.routeInfo.id == Routes.FRIEND_TRACKER_CONFIG_IMPORT_ROUTE
+ val isAddRuleScreen = route.routeInfo.id.startsWith("edit_rule")
+ val animatedRoutes = setOf("friend_tracker_catalog", "manage_friend_tracker_repos", "manage_script_repos", "manage_repos")
+ val isAnimatedRoute = animatedRoutes.contains(route.routeInfo.id)
+ val addRuleEnterAnimation = slideInHorizontally(animationSpec = tween(400)) { it }
+ val addRuleExitAnimation = slideOutHorizontally(animationSpec = tween(400)) { -it }
+ val addRulePopEnterAnimation = slideInHorizontally(animationSpec = tween(400)) { -it }
+ val addRulePopExitAnimation = slideOutHorizontally(animationSpec = tween(400)) { it }
+ val animatedRouteEnter = slideInHorizontally(animationSpec = tween(400)) { it }
+ val animatedRouteExit = slideOutHorizontally(animationSpec = tween(400)) { -it }
+ val animatedRoutePopEnter = slideInHorizontally(animationSpec = tween(400)) { -it }
+ val animatedRoutePopExit = slideOutHorizontally(animationSpec = tween(400)) { it }
+ composable(
+ route.routeInfo.id,
+ enterTransition = {
+ when {
+ isSummaryScreen -> slideInHorizontally { it }
+ isAddRuleScreen -> addRuleEnterAnimation
+ isAnimatedRoute -> animatedRouteEnter
+ else -> fadeIn(tween(100))
+ }
+ },
+ exitTransition = {
+ when {
+ isSummaryScreen -> slideOutHorizontally { -it }
+ isAddRuleScreen -> addRuleExitAnimation
+ isAnimatedRoute -> animatedRouteExit
+ else -> fadeOut(tween(100))
+ }
+ },
+ popEnterTransition = {
+ when {
+ isSummaryScreen -> slideInHorizontally { -it }
+ isAddRuleScreen -> addRulePopEnterAnimation
+ isAnimatedRoute -> animatedRoutePopEnter
+ else -> fadeIn(tween(100))
+ }
+ },
+ popExitTransition = {
+ when {
+ isSummaryScreen -> slideOutHorizontally { it }
+ isAddRuleScreen -> addRulePopExitAnimation
+ isAnimatedRoute -> animatedRoutePopExit
+ else -> fadeOut(tween(100))
+ }
+ }
+ ) { route.content.invoke(it) }
route.customComposables.invoke(this)
} else {
navigation("main_" + route.routeInfo.id, route.routeInfo.id) {
- composable("main_" + route.routeInfo.id) {
- route.content.invoke(it)
- }
- children.forEach { child ->
- composable(child.routeInfo.id) {
- child.content.invoke(it)
- }
- }
+ composable("main_" + route.routeInfo.id) { route.content.invoke(it) }
+ children.forEach { child -> composable(child.routeInfo.id) { child.content.invoke(it) } }
route.customComposables.invoke(this)
}
}
}
}
}
+
+ @Composable fun FloatingActionButton() = Fab()
+ @Composable fun Content(paddingValues: PaddingValues, startDestination: String) = NavContent(paddingValues, startDestination)
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt
index ac764bcc..9a9b3523 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt
@@ -5,6 +5,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy
@@ -28,6 +29,9 @@ import me.rhunk.snapenhance.ui.manager.pages.social.MessagingPreview
import me.rhunk.snapenhance.ui.manager.pages.social.SocialRootSection
import me.rhunk.snapenhance.ui.manager.pages.tracker.EditRule
import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerManagerRoot
+import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerCatalog
+import me.rhunk.snapenhance.ui.manager.pages.tracker.ManageFriendTrackerReposSection
+import me.rhunk.snapenhance.ui.manager.pages.scripting.ManageScriptReposSection
data class RouteInfo(
@@ -36,6 +40,7 @@ data class RouteInfo(
val icon: ImageVector = Icons.Default.Home,
val primary: Boolean = false,
val showInNavBar: Boolean = primary,
+ val hasOwnTopBar: Boolean = false,
) {
var translatedKey: Lazy? = null
val childIds = mutableListOf()
@@ -45,8 +50,23 @@ data class RouteInfo(
class Routes(
private val context: RemoteSideContext,
) {
+ companion object {
+ const val CONFIG_IMPORT_CONFIRMATION_ROUTE = "config_import_confirmation"
+ const val CONFIG_EXPORT_SUMMARY_ROUTE = "config_export_summary/?exportSensitiveData={exportSensitiveData}"
+ const val FRIEND_TRACKER_CONFIG_EXPORT_ROUTE = "friend_tracker_config_export/?rule_id={rule_id}"
+ const val FRIEND_TRACKER_CONFIG_IMPORT_ROUTE = "friend_tracker_config_import"
+ }
+
lateinit var navController: NavController
+ lateinit var activityLauncher: me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
private val routes = mutableListOf()
+ var bottomPadding: androidx.compose.ui.unit.Dp = 0.dp
+ var configJsonForImport: String? = null
+ var friendTrackerConfigJsonForImport: String? = null
+ var onRuleImported: (() -> Unit)? = null
+
+ val configImportConfirmation = route(RouteInfo(CONFIG_IMPORT_CONFIRMATION_ROUTE), me.rhunk.snapenhance.ui.manager.pages.features.ConfigImportConfirmationScreen())
+ val configExportSummary = route(RouteInfo(CONFIG_EXPORT_SUMMARY_ROUTE), me.rhunk.snapenhance.ui.manager.pages.features.ConfigExportSummaryScreen())
val tasks = route(RouteInfo("tasks", icon = Icons.Default.TaskAlt, primary = true), TasksRootSection())
@@ -57,11 +77,15 @@ class Routes(
val settings = route(RouteInfo("home_settings"), HomeSettings()).parent(home)
val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home)
val loggerHistory = route(RouteInfo("logger_history"), LoggerHistoryRoot()).parent(home)
- val friendTracker = route(RouteInfo("friend_tracker"), FriendTrackerManagerRoot()).parent(home)
- val editRule = route(RouteInfo("edit_rule/?rule_id={rule_id}"), EditRule())
+ val friendTracker = route(RouteInfo("friend_tracker", icon = Icons.Default.PersonSearch), FriendTrackerManagerRoot()).parent(home)
+ val editRule = route(RouteInfo("edit_rule/?rule_id={rule_id}", hasOwnTopBar = true), EditRule())
+ val friendTrackerConfigExport = route(RouteInfo(FRIEND_TRACKER_CONFIG_EXPORT_ROUTE), me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerConfigExportScreen())
+ val friendTrackerConfigImport = route(RouteInfo(FRIEND_TRACKER_CONFIG_IMPORT_ROUTE), me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerConfigImportScreen())
+ val friendTrackerCatalog = route(RouteInfo("friend_tracker_catalog"), FriendTrackerCatalog())
+ val manageFriendTrackerRepos = route(RouteInfo("manage_friend_tracker_repos"), ManageFriendTrackerReposSection())
val fileImports = route(RouteInfo("file_imports"), FileImportsRoot()).parent(home)
- val manageRepos = route(RouteInfo("manage_repos"), ManageReposSection())
+ val manageRepos = route(RouteInfo("manage_repos/?type={type}"), ManageReposSection())
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRootSection())
val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social)
@@ -69,6 +93,7 @@ class Routes(
val loggedStories = route(RouteInfo("logged_stories/?id={id}"), LoggedStories()).parent(social)
val scripting = route(RouteInfo("scripts", icon = Icons.Filled.DataObject, primary = true), ScriptingRootSection())
+ val manageScriptRepos = route(RouteInfo("manage_script_repos"), ManageScriptReposSection())
val betterLocation = route(RouteInfo("better_location", showInNavBar = false, primary = true), BetterLocationRoot())
@@ -152,4 +177,4 @@ class Routes(
routes.add(route)
return route
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/components/AestheticDialog.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/components/AestheticDialog.kt
new file mode 100644
index 00000000..5555af10
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/components/AestheticDialog.kt
@@ -0,0 +1,83 @@
+package me.rhunk.snapenhance.ui.manager.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import me.rhunk.snapenhance.ui.util.Motion
+
+@Composable
+fun AestheticDialog(
+ onDismissRequest: () -> Unit,
+ title: String,
+ text: String,
+ icon: ImageVector,
+ confirmButtonText: String,
+ onConfirm: () -> Unit,
+ dismissButtonText: String? = null,
+ onDismiss: (() -> Unit)? = null
+) {
+ var visible by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) { visible = true }
+
+ Dialog(onDismissRequest = onDismissRequest) {
+ AnimatedVisibility(
+ visible = visible,
+ enter = fadeIn(animationSpec = Motion.tweenFloatSpec(180)) + scaleIn(animationSpec = Motion.tweenFloatSpec(220)),
+ exit = fadeOut(animationSpec = Motion.tweenFloatSpec(150)) + scaleOut(animationSpec = Motion.tweenFloatSpec(180))
+ ) {
+ Card(
+ shape = RoundedCornerShape(16.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
+ ) {
+ if (dismissButtonText != null && onDismiss != null) {
+ Button(onClick = onDismiss) {
+ Text(dismissButtonText)
+ }
+ }
+ Button(onClick = onConfirm) {
+ Text(confirmButtonText)
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/UpdateDownloader.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/UpdateDownloader.kt
new file mode 100644
index 00000000..4c012d72
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/UpdateDownloader.kt
@@ -0,0 +1,134 @@
+package me.rhunk.snapenhance.ui.manager.data
+
+import android.content.Context
+import android.content.Intent
+import android.widget.Toast
+import androidx.core.content.FileProvider
+import com.tonyodev.fetch2.*
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import me.rhunk.snapenhance.RemoteSideContext
+import java.io.File
+import java.io.FileOutputStream
+import java.util.zip.ZipInputStream
+
+object UpdateDownloader {
+ enum class DownloadState {
+ IDLE,
+ DOWNLOADING,
+ COMPLETED,
+ FAILED
+ }
+
+ val downloadState = MutableStateFlow(DownloadState.IDLE)
+ val downloadProgress = MutableStateFlow(0f)
+ private var fetch: Fetch? = null
+ private var listener: FetchListener? = null
+
+ private fun getInstance(context: Context): Fetch {
+ val appContext = context.applicationContext
+ // If RemoteSideContext is part of your app, keep this, otherwise remove
+ fetch?.let { return it }
+ fetch = when (appContext) {
+ is RemoteSideContext -> appContext.fetch
+ else -> {
+ val fetchConfiguration = FetchConfiguration.Builder(appContext)
+ .setDownloadConcurrentLimit(3)
+ .build()
+ Fetch.getInstance(fetchConfiguration)
+ }
+ }
+ return fetch!!
+ }
+
+ private fun unzip(zipFile: File, targetDirectory: File) {
+ ZipInputStream(zipFile.inputStream()).use { zis ->
+ var zipEntry = zis.nextEntry
+ while (zipEntry != null) {
+ val newFile = File(targetDirectory, zipEntry.name)
+ if (zipEntry.isDirectory) {
+ newFile.mkdirs()
+ } else {
+ FileOutputStream(newFile).use { fos ->
+ zis.copyTo(fos)
+ }
+ }
+ zipEntry = zis.nextEntry
+ }
+ }
+ }
+
+ fun downloadAndInstall(
+ context: Context,
+ downloadUrl: String,
+ fileName: String,
+ scope: CoroutineScope
+ ) {
+ val fetch = getInstance(context)
+ val filePath = File(context.externalCacheDir, fileName).path
+ val request = Request(downloadUrl, filePath).apply {
+ priority = Priority.HIGH
+ networkType = NetworkType.ALL
+ }
+ listener?.let { fetch.removeListener(it) }
+ listener = object : AbstractFetchListener() {
+ override fun onAdded(download: Download) {
+ downloadState.value = DownloadState.DOWNLOADING
+ }
+
+ override fun onQueued(download: Download, waitingOnNetwork: Boolean) {
+ downloadState.value = DownloadState.DOWNLOADING
+ Toast.makeText(context, "Download started", Toast.LENGTH_SHORT).show()
+ }
+
+ override fun onProgress(download: Download, etaInMilliSeconds: Long, downloadedBytesPerSecond: Long) {
+ downloadProgress.value = download.progress / 100f
+ }
+
+ override fun onCompleted(download: Download) {
+ downloadState.value = DownloadState.COMPLETED
+ Toast.makeText(context, "Download completed", Toast.LENGTH_SHORT).show()
+ runCatching {
+ val downloadedFile = File(download.file)
+ val unzipDir = File(context.externalCacheDir, "update")
+ if (unzipDir.exists()) {
+ unzipDir.deleteRecursively()
+ }
+ unzipDir.mkdirs()
+ unzip(downloadedFile, unzipDir)
+ val apkFile = unzipDir.walk().find { it.isFile && it.extension == "apk" }
+ ?: throw Exception("No APK found in the downloaded file")
+ val uri = FileProvider.getUriForFile(context, "me.rhunk.snapenhance.fileprovider", apkFile)
+ val installIntent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, "application/vnd.android.package-archive")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(installIntent)
+ }.onFailure {
+ it.printStackTrace()
+ Toast.makeText(context, "Failed to install update. Check logs for more details.", Toast.LENGTH_SHORT).show()
+ downloadState.value = DownloadState.FAILED
+ }
+ fetch.removeListener(this)
+ scope.launch {
+ delay(2000)
+ downloadState.value = DownloadState.IDLE
+ }
+ }
+
+ override fun onError(download: Download, error: Error, throwable: Throwable?) {
+ downloadState.value = DownloadState.FAILED
+ Toast.makeText(context, "Download failed: $error", Toast.LENGTH_SHORT).show()
+ fetch.removeListener(this)
+ scope.launch {
+ delay(2000)
+ downloadState.value = DownloadState.IDLE
+ }
+ }
+ }
+ fetch.addListener(listener!!)
+ fetch.enqueue(request, { }, { })
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/Updater.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/Updater.kt
index a4a40483..870e0bb1 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/Updater.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/Updater.kt
@@ -10,16 +10,17 @@ import okhttp3.Request
object Updater {
data class LatestRelease(
val versionName: String,
- val releaseUrl: String
+ val releaseUrl: String,
+ val workflowId: Long?,
)
private fun fetchLatestRelease() = runCatching {
- val endpoint = Request.Builder().url("https://api.github.com/repos/rhunk/SnapEnhance/releases").build()
+ val endpoint = Request.Builder().url("https://api.github.com/repos/particle-box/PurrfectSnap/releases").build()
val response = OkHttpClient().newCall(endpoint).execute()
if (!response.isSuccessful) throw Throwable("Failed to fetch releases: ${response.code}")
- val releases = JsonParser.parseString(response.body.string()).asJsonArray.also {
+ val releases = JsonParser.parseString(response.body?.string()).asJsonArray.also {
if (it.size() == 0) throw Throwable("No releases found")
}
@@ -29,16 +30,17 @@ object Updater {
LatestRelease(
versionName = latestVersion,
- releaseUrl = endpoint.url.toString().replace("api.", "").replace("repos/", "")
+ releaseUrl = endpoint.url.toString().replace("api.", "").replace("repos/", ""),
+ workflowId = null
)
}.onFailure {
AbstractLogger.directError("Failed to fetch latest release", it)
}.getOrNull()
private fun fetchLatestDebugCI() = runCatching {
- val actionRuns = OkHttpClient().newCall(Request.Builder().url("https://api.github.com/repos/rhunk/SnapEnhance/actions/runs?event=workflow_dispatch").build()).execute().use {
+ val actionRuns = OkHttpClient().newCall(Request.Builder().url("https://api.github.com/repos/particle-box/PurrfectSnap/actions/runs?event=workflow_dispatch&branch=dev").build()).execute().use {
if (!it.isSuccessful) throw Throwable("Failed to fetch CI runs: ${it.code}")
- JsonParser.parseString(it.body.string()).asJsonObject
+ JsonParser.parseString(it.body?.string()).asJsonObject
}
val debugRuns = actionRuns.getAsJsonArray("workflow_runs")?.mapNotNull { it.asJsonObject }?.filter { run ->
run.get("conclusion")?.takeIf { it.isJsonPrimitive }?.asString == "success" && run.getAsJsonPrimitive("path")?.asString == ".github/workflows/debug.yml"
@@ -51,7 +53,8 @@ object Updater {
LatestRelease(
versionName = headSha.substring(0, headSha.length.coerceAtMost(7)) + "-debug",
- releaseUrl = latestRun.getAsJsonPrimitive("html_url")?.asString?.replace("github.com", "nightly.link") ?: return@runCatching null
+ releaseUrl = latestRun.getAsJsonPrimitive("html_url")?.asString ?: return@runCatching null,
+ workflowId = latestRun.getAsJsonPrimitive("id")?.asLong,
)
}.onFailure {
AbstractLogger.directError("Failed to fetch latest debug CI", it)
@@ -60,4 +63,4 @@ object Updater {
val latestRelease by lazy {
if (BuildConfig.DEBUG) fetchLatestDebugCI() else fetchLatestRelease()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FileImportsRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FileImportsRoot.kt
index d27a070b..241721ae 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FileImportsRoot.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FileImportsRoot.kt
@@ -92,7 +92,8 @@ class FileImportsRoot: Routes.Route() {
modifier = Modifier
.fillMaxSize()
.padding(2.dp),
- verticalArrangement = Arrangement.spacedBy(5.dp)
+ verticalArrangement = Arrangement.spacedBy(5.dp),
+ contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
item {
if (files.isEmpty()) {
@@ -150,9 +151,6 @@ class FileImportsRoot: Routes.Route() {
}
}
}
- item {
- Spacer(modifier = Modifier.height(100.dp))
- }
}
}
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt
index 652ec65f..e177ecff 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt
@@ -306,7 +306,9 @@ class LoggerHistoryRoot : Routes.Route() {
var lastFetchMessageTimestamp by remember(selectedConversation, stringFilter, reverseOrder) { mutableLongStateOf(if (reverseOrder) Long.MAX_VALUE else Long.MIN_VALUE) }
val messages = remember(selectedConversation, stringFilter, reverseOrder) { mutableStateListOf() }
- LazyColumn {
+ LazyColumn(
+ contentPadding = PaddingValues(bottom = routes.bottomPadding)
+ ) {
items(messages) { message ->
MessageView(message)
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/ManageReposSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/ManageReposSection.kt
index 637f5dba..eb2f4c3e 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/ManageReposSection.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/ManageReposSection.kt
@@ -1,8 +1,10 @@
+@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package me.rhunk.snapenhance.ui.manager.pages
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.animation.animateContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Public
import androidx.compose.material3.*
@@ -18,6 +20,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry
+import androidx.navigation.compose.currentBackStackEntryAsState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.common.data.RepositoryIndex
@@ -37,6 +40,9 @@ class ManageReposSection: Routes.Route() {
override val floatingActionButton: @Composable () -> Unit = {
var showAddDialog by remember { mutableStateOf(false) }
+ val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
+ val repoType = navBackStackEntry?.arguments?.getString("type") ?: "theme"
+
ExtendedFloatingActionButton(onClick = {
showAddDialog = true
}) {
@@ -59,7 +65,7 @@ class ManageReposSection: Routes.Route() {
if (!response.isSuccessful) {
throw Exception("Failed to fetch default branch: ${response.code}")
}
- val json = response.body.string()
+ val json = response.body?.string()
val defaultBranch = context.gson.fromJson(json, Map::class.java)["default_branch"] as String
context.log.info("Default branch for $repoName is $defaultBranch")
modifiedUrl = "https://raw.githubusercontent.com/$repoName/$defaultBranch/"
@@ -74,11 +80,11 @@ class ManageReposSection: Routes.Route() {
throw Exception("Failed to fetch index from $indexUri: ${response.code}")
}
runCatching {
- val repoIndex = context.gson.fromJson(response.body.charStream(), RepositoryIndex::class.java).also {
+ val repoIndex = context.gson.fromJson(response.body?.charStream(), RepositoryIndex::class.java).also {
context.log.info("repository index: $it")
}
- context.database.addRepo(modifiedUrl)
+ context.database.addRepo(repoType, modifiedUrl)
context.shortToast("Repository added successfully! $repoIndex")
showAddDialog = false
updateDispatcher.dispatch()
@@ -144,13 +150,14 @@ class ManageReposSection: Routes.Route() {
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
+ val repoType = it.arguments?.getString("type") ?: "theme"
val repositories = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateDispatcher) {
- context.database.getRepositories()
+ context.database.getRepositories(repoType)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(8.dp),
+ contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp + routes.bottomPadding),
) {
item {
if (repositories.isEmpty()) {
@@ -162,7 +169,7 @@ class ManageReposSection: Routes.Route() {
items(repositories) { url ->
ElevatedCard(onClick = {
context.androidContext.copyToClipboard(url)
- }) {
+ }, modifier = Modifier.animateContentSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -174,7 +181,7 @@ class ManageReposSection: Routes.Route() {
Text(text = url, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 4, fontSize = 15.sp, lineHeight = 15.sp)
Button(
onClick = {
- context.database.removeRepo(url)
+ context.database.removeRepo(repoType, url)
coroutineScope.launch {
updateDispatcher.dispatch()
}
@@ -187,4 +194,4 @@ class ManageReposSection: Routes.Route() {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRootSection.kt
index 92202942..6562bef6 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRootSection.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRootSection.kt
@@ -483,7 +483,8 @@ class TasksRootSection : Routes.Route() {
LazyColumn(
state = scrollState,
- modifier = Modifier.fillMaxSize()
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
item {
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/ConfigExportSummaryScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/ConfigExportSummaryScreen.kt
new file mode 100644
index 00000000..9efd076e
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/ConfigExportSummaryScreen.kt
@@ -0,0 +1,243 @@
+package me.rhunk.snapenhance.ui.manager.pages.features
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import me.rhunk.snapenhance.ui.manager.Routes
+import me.rhunk.snapenhance.ui.util.saveFile
+import org.json.JSONArray
+import org.json.JSONObject
+
+class ConfigExportSummaryScreen : Routes.Route() {
+
+ private data class ImportedFeature(
+ val category: String,
+ val name: String,
+ val key: String,
+ val value: Any,
+ val indentation: Int
+ )
+
+ private inner class ConfigParser {
+ fun parse(configJson: String): Map> {
+ val featureList = mutableListOf()
+ val json = JSONObject(configJson)
+ fun parseProperties(categoryKey: String, niceCategoryName: String, properties: JSONObject, prefix: String, indent: Int) {
+ for (key in properties.keys()) {
+ val value = properties.get(key)
+ val currentPrefix = if (prefix.isEmpty()) key else "$prefix.$key"
+ if (value is JSONObject && value.has("state") && value.has("properties")) {
+ val featureNameKey = "features.properties.$categoryKey.properties.${currentPrefix.split('.').joinToString(".properties.")}.name"
+ val featureName = context.translation[featureNameKey] ?: key
+ featureList.add(ImportedFeature(niceCategoryName, featureName, key, value.getBoolean("state"), indent))
+ parseProperties(categoryKey, niceCategoryName, value.getJSONObject("properties"), currentPrefix, indent + 1)
+ } else if (value is JSONObject && value.has("properties")) {
+ parseProperties(categoryKey, niceCategoryName, value.getJSONObject("properties"), currentPrefix, indent)
+ } else {
+ val featureNameKey = "features.properties.$categoryKey.properties.${currentPrefix.split('.').joinToString(".properties.")}.name"
+ var featureName = context.translation[featureNameKey] ?: key
+ if (key == "save_folder") {
+ featureName = "Save Folder"
+ }
+ featureList.add(ImportedFeature(niceCategoryName, featureName, key, value, indent))
+ }
+ }
+ }
+ for (categoryKey in json.keys()) {
+ val value = json.get(categoryKey)
+ if (value is JSONObject) {
+ val niceCategoryName = context.translation["features.properties.$categoryKey.name"] ?: categoryKey.replaceFirstChar { it.uppercase() }
+ if (value.has("state") && !value.has("properties")) {
+ featureList.add(ImportedFeature(niceCategoryName, "Enable Feature", categoryKey, value.getBoolean("state"), 0))
+ } else if (value.has("properties")) {
+ parseProperties(categoryKey, niceCategoryName, value.getJSONObject("properties"), "", 0)
+ }
+ }
+ }
+ return featureList.groupBy { it.category }
+ }
+
+ fun parseValue(featureKey: String, value: Any): Any {
+ fun innerParse(v: Any): String {
+ if (v is String) {
+ val translationKey = "features.options.$featureKey.$v"
+ val translated = context.translation[translationKey]
+ return translated!!
+ }
+ return v.toString()
+ }
+ return when (value) {
+ is Boolean -> if (value) "Enabled" else "Disabled"
+ is JSONArray -> {
+ val list = mutableListOf()
+ for (i in 0 until value.length()) {
+ list.add(innerParse(value.get(i)))
+ }
+ list
+ }
+ else -> innerParse(value)
+ }
+ }
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
+ val exportSensitiveData = it.arguments?.getBoolean("exportSensitiveData") ?: false
+ val parser = remember { ConfigParser() }
+ val featuresByCategory = remember {
+ parser.parse(context.config.exportToString(exportSensitiveData))
+ }
+ val expandedState = remember { mutableStateMapOf() }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Export Summary") },
+ navigationIcon = {
+ IconButton(onClick = { routes.navController.popBackStack() }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = {
+ TextButton(onClick = {
+ routes.activityLauncher.saveFile("config.json", "application/json") { uri ->
+ runCatching {
+ context.androidContext.contentResolver.openOutputStream(android.net.Uri.parse(uri))?.use {
+ context.config.writeConfig()
+ context.config.exportToString(exportSensitiveData).byteInputStream().copyTo(it)
+ context.shortToast(context.translation["manager.sections.features.config_export_success_toast"])
+ }
+ }.onFailure {
+ context.longToast(
+ context.translation.format(
+ "manager.sections.features.config_export_failure_toast",
+ "error" to it.message.toString()
+ )
+ )
+ }
+ }
+ }) {
+ Text("Save")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier
+ .padding(padding)
+ .fillMaxSize(),
+ contentPadding = PaddingValues(
+ start = 16.dp,
+ top = 16.dp,
+ end = 16.dp,
+ bottom = 16.dp + routes.bottomPadding
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(featuresByCategory.toList()) { (category, features) ->
+ val isExpanded = expandedState[category] ?: false
+ val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { expandedState[category] = !isExpanded },
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = category,
+ fontWeight = FontWeight.Bold,
+ fontSize = 18.sp,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(onClick = { expandedState[category] = !isExpanded }) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = "Expand",
+ modifier = Modifier.graphicsLayer(rotationZ = rotationState)
+ )
+ }
+ }
+ AnimatedVisibility(visible = isExpanded) {
+ Column {
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+ features.forEachIndexed { index, feature ->
+ when (val parsedValue = parser.parseValue(feature.key, feature.value)) {
+ is List<*> -> {
+ Column(modifier = Modifier.padding(start = (feature.indentation * 16).dp, top = 4.dp, bottom = 4.dp)) {
+ Text(
+ text = feature.name,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Column(modifier = Modifier.padding(start = 16.dp)) {
+ parsedValue.forEach { item ->
+ Row {
+ Text(
+ text = "•",
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ Text(
+ text = item.toString(),
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ }
+ is String -> {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .padding(start = (feature.indentation * 16).dp),
+ verticalAlignment = Alignment.Top
+ ) {
+ Text(
+ text = feature.name,
+ modifier = Modifier.weight(1f),
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = parsedValue,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.End,
+ )
+ }
+ }
+ }
+ if (index < features.size - 1) {
+ HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/ConfigImportConfirmationScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/ConfigImportConfirmationScreen.kt
new file mode 100644
index 00000000..551c58a4
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/ConfigImportConfirmationScreen.kt
@@ -0,0 +1,330 @@
+package me.rhunk.snapenhance.ui.manager.pages.features
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.clickable
+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.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import me.rhunk.snapenhance.ui.manager.Routes
+import org.json.JSONArray
+import org.json.JSONObject
+
+class ConfigImportConfirmationScreen : Routes.Route() {
+
+ private data class ImportedFeature(
+ val category: String,
+ val name: String,
+ val key: String,
+ val value: Any,
+ val indentation: Int
+ )
+
+ private inner class ConfigParser {
+ fun parse(configJson: String): Map> {
+ val featureList = mutableListOf()
+ val json = JSONObject(configJson)
+ fun parseProperties(
+ categoryKey: String,
+ niceCategoryName: String,
+ properties: JSONObject,
+ prefix: String,
+ indent: Int
+ ) {
+ for (key in properties.keys()) {
+ val value = properties.get(key)
+ val currentPrefix = if (prefix.isEmpty()) key else "$prefix.$key"
+ if (value is JSONObject && value.has("state") && value.has("properties")) {
+ val featureNameKey =
+ "features.properties.$categoryKey.properties.${currentPrefix.split('.')
+ .joinToString(".properties.")}.name"
+ val featureName = context.translation[featureNameKey] ?: key
+ featureList.add(
+ ImportedFeature(
+ niceCategoryName,
+ featureName,
+ key,
+ value.getBoolean("state"),
+ indent
+ )
+ )
+ parseProperties(
+ categoryKey,
+ niceCategoryName,
+ value.getJSONObject("properties"),
+ currentPrefix,
+ indent + 1
+ )
+ } else if (value is JSONObject && value.has("properties")) {
+ parseProperties(
+ categoryKey,
+ niceCategoryName,
+ value.getJSONObject("properties"),
+ currentPrefix,
+ indent
+ )
+ } else {
+ val featureNameKey =
+ "features.properties.$categoryKey.properties.${currentPrefix.split('.')
+ .joinToString(".properties.")}.name"
+ val featureName = context.translation[featureNameKey] ?: key
+ featureList.add(
+ ImportedFeature(
+ niceCategoryName,
+ featureName,
+ key,
+ value,
+ indent
+ )
+ )
+ }
+ }
+ }
+ for (categoryKey in json.keys()) {
+ val value = json.get(categoryKey)
+ if (value is JSONObject) {
+ val niceCategoryName =
+ context.translation["features.properties.$categoryKey.name"]
+ ?: categoryKey.replaceFirstChar { it.uppercase() }
+ if (value.has("state") && !value.has("properties")) {
+ featureList.add(
+ ImportedFeature(
+ niceCategoryName,
+ "Enable Feature",
+ categoryKey,
+ value.getBoolean("state"),
+ 0
+ )
+ )
+ } else if (value.has("properties")) {
+ parseProperties(
+ categoryKey,
+ niceCategoryName,
+ value.getJSONObject("properties"),
+ "",
+ 0
+ )
+ }
+ }
+ }
+ return featureList.groupBy { it.category }
+ }
+
+ fun parseValue(featureKey: String, value: Any): Any {
+ fun innerParse(v: Any): String {
+ if (v is String) {
+ val translationKey = "features.options.$featureKey.$v"
+ val translated = context.translation[translationKey]
+ return translated!!
+ }
+ return v.toString()
+ }
+ return when (value) {
+ is Boolean -> if (value) "Enabled" else "Disabled"
+ is JSONArray -> {
+ val list = mutableListOf()
+ for (i in 0 until value.length()) {
+ list.add(innerParse(value.get(i)))
+ }
+ list
+ }
+ else -> innerParse(value)
+ }
+ }
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
+ val parser = remember { ConfigParser() }
+ val featuresByCategory = remember {
+ routes.configJsonForImport?.let { parser.parse(it) } ?: emptyMap()
+ }
+ val expandedState = remember { mutableStateMapOf() }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Confirm Import") },
+ navigationIcon = {
+ IconButton(onClick = { routes.navController.popBackStack() }) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back"
+ )
+ }
+ },
+ actions = {
+ TextButton(onClick = {
+ routes.configJsonForImport?.let {
+ runCatching {
+ context.config.loadFromString(it)
+ }.onFailure { err ->
+ context.longToast(
+ context.translation.format(
+ "config_import_failure_toast",
+ "error" to (err.message ?: "Unknown error")
+ )
+ )
+ // Return is not needed since last statement in lambda
+ }
+ context.shortToast("Config Imported!")
+ context.coroutineScope.launch(Dispatchers.Main) {
+ routes.features.navigateReload()
+ }
+ }
+ }) {
+ Text("Confirm")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier
+ .padding(padding)
+ .fillMaxSize(),
+ contentPadding = PaddingValues(
+ start = 16.dp,
+ top = 16.dp,
+ end = 16.dp,
+ bottom = 16.dp + routes.bottomPadding
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(featuresByCategory.toList()) { (category, features) ->
+ val isExpanded = expandedState[category] ?: false
+ val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { expandedState[category] = !isExpanded },
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = category,
+ fontWeight = FontWeight.Bold,
+ fontSize = 18.sp,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(onClick = { expandedState[category] = !isExpanded }) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = "Expand",
+ modifier = Modifier.graphicsLayer(rotationZ = rotationState)
+ )
+ }
+ }
+
+ AnimatedVisibility(visible = isExpanded) {
+ Column {
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+ features.forEachIndexed { index, feature ->
+ when (val parsedValue = parser.parseValue(feature.key, feature.value)) {
+ is List<*> -> {
+ Column(
+ modifier = Modifier
+ .padding(
+ start = (feature.indentation * 16).dp,
+ top = 4.dp,
+ bottom = 4.dp
+ )
+ ) {
+ Text(
+ text = feature.name,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Column(modifier = Modifier.padding(start = 16.dp)) {
+ parsedValue.forEach { item ->
+ Row {
+ Text(
+ text = "•",
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ Text(
+ text = item.toString(),
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ is String -> {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .padding(start = (feature.indentation * 16).dp),
+ verticalAlignment = Alignment.Top
+ ) {
+ Text(
+ text = feature.name,
+ modifier = Modifier.weight(1f),
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = parsedValue,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.End,
+ )
+ }
+ }
+ }
+ // Always show divider only when there are more features to follow
+ if (index < features.size - 1) {
+ HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRootSection.kt
index 9b59f956..390f67e9 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRootSection.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRootSection.kt
@@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@@ -21,11 +22,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
import androidx.lifecycle.Lifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraph.Companion.findStartDestination
@@ -43,6 +48,8 @@ import me.rhunk.snapenhance.common.ui.transparentTextFieldColors
import me.rhunk.snapenhance.ui.manager.MainActivity
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.*
+import org.json.JSONArray
+import org.json.JSONObject
class FeaturesRootSection : Routes.Route() {
private val alertDialogs by lazy { AlertDialogs(context.translation) }
@@ -87,18 +94,8 @@ class FeaturesRootSection : Routes.Route() {
)
}
- override val init: () -> Unit = {
- activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
- }
-
private fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) {
- activityLauncherHelper?.let(block) ?: run {
- //open manager if activity launcher is null
- val intent = Intent(context.androidContext, MainActivity::class.java)
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- intent.putExtra("route", routeInfo.id)
- context.androidContext.startActivity(intent)
- }
+ routes.activityLauncher.let(block)
}
override val content: @Composable (NavBackStackEntry) -> Unit = {
@@ -251,9 +248,13 @@ class FeaturesRootSection : Routes.Route() {
when (val dataType = remember { property.key.dataType.type }) {
DataProcessors.Type.BOOLEAN -> {
var state by remember { mutableStateOf(propertyValue.get() as Boolean) }
+ val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = state,
onCheckedChange = registerClickCallback {
+ if (context.config.root.global.uiSettings.hapticFeedback.get()) {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
state = state.not()
propertyValue.setAny(state)
}
@@ -363,9 +364,13 @@ class FeaturesRootSection : Routes.Route() {
))
}
+ val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = state,
onCheckedChange = {
+ if (context.config.root.global.uiSettings.hapticFeedback.get()) {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
state = state.not()
container.globalState = state
}
@@ -521,6 +526,41 @@ class FeaturesRootSection : Routes.Route() {
}
}
+ @Composable
+ private fun SensitiveDataDialog(
+ onDismiss: () -> Unit,
+ onConfirm: (exportSensitiveData: Boolean) -> Unit
+ ) {
+ Dialog(onDismissRequest = onDismiss) {
+ Card(shape = RoundedCornerShape(16.dp)) {
+ Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = "Export Sensitive Data?",
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+ Text(
+ text = "Do you want to export the config with sensitive data? (Such as location coordinates, etc.)",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(bottom = 24.dp)
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
+ ) {
+ TextButton(onClick = { onConfirm(false) }) {
+ Text("No")
+ }
+ TextButton(onClick = { onConfirm(true) }) {
+ Text("Yes")
+ }
+ }
+ }
+ }
+ }
+ }
+
override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{
var showSearchBar by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
@@ -590,41 +630,12 @@ class FeaturesRootSection : Routes.Route() {
}
if (showExportDialog) {
- fun exportConfig(
- exportSensitiveData: Boolean
- ) {
- showExportDialog = false
- activityLauncher {
- saveFile("config.json", "application/json") { uri ->
- runCatching {
- context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use {
- context.config.writeConfig()
- context.config.exportToString(exportSensitiveData).byteInputStream().copyTo(it)
- context.shortToast(translation["config_export_success_toast"])
- }
- }.onFailure {
- context.longToast(translation.format("config_export_failure_toast", "error" to it.message.toString()))
- }
- }
- }
- }
-
- AlertDialog(
- title = { Text(text = context.translation["manager.dialogs.export_config.title"]) },
- text = { Text(text = context.translation["manager.dialogs.export_config.content"]) },
- onDismissRequest = { showExportDialog = false },
- confirmButton = {
- Button(
- onClick = { exportConfig(true) }
- ) {
- Text(text = context.translation["button.positive"])
- }
- },
- dismissButton = {
- Button(
- onClick = { exportConfig(false) }
- ) {
- Text(text = context.translation["button.negative"])
+ SensitiveDataDialog(
+ onDismiss = { showExportDialog = false },
+ onConfirm = { exportSensitiveData ->
+ showExportDialog = false
+ routes.configExportSummary.navigate {
+ put("exportSensitiveData", exportSensitiveData.toString())
}
}
)
@@ -637,16 +648,8 @@ class FeaturesRootSection : Routes.Route() {
activityLauncher {
openFile("application/json") { uri ->
context.androidContext.contentResolver.openInputStream(Uri.parse(uri))?.use {
- runCatching {
- context.config.loadFromString(it.readBytes().toString(Charsets.UTF_8))
- }.onFailure {
- context.longToast(translation.format("config_import_failure_toast", "error" to it.message.toString()))
- return@use
- }
- context.shortToast(translation["config_import_success_toast"])
- context.coroutineScope.launch(Dispatchers.Main) {
- navigateReload()
- }
+ routes.configJsonForImport = it.readBytes().toString(Charsets.UTF_8)
+ routes.navController.navigate(Routes.CONFIG_IMPORT_CONFIRMATION_ROUTE)
}
}
}
@@ -685,23 +688,15 @@ class FeaturesRootSection : Routes.Route() {
private fun PropertiesView(
properties: List>
) {
- Scaffold(
+ LazyColumn(
modifier = Modifier.fillMaxSize(),
- content = { innerPadding ->
- LazyColumn(
- modifier = Modifier
- .fillMaxHeight()
- .padding(innerPadding),
- //save button space
- contentPadding = PaddingValues(top = 10.dp, bottom = 110.dp),
- verticalArrangement = Arrangement.Top
- ) {
- items(properties, key = { it.key.propertyName() }) {
- PropertyCard(it)
- }
- }
+ verticalArrangement = Arrangement.Top,
+ contentPadding = PaddingValues(bottom = routes.bottomPadding)
+ ) {
+ items(properties, key = { it.key.propertyName() }) {
+ PropertyCard(it)
}
- )
+ }
}
override val floatingActionButton: @Composable () -> Unit = {
@@ -726,12 +721,15 @@ class FeaturesRootSection : Routes.Route() {
}
+
@Composable
private fun Container(
configContainer: ConfigContainer
) {
PropertiesView(remember {
- configContainer.properties.map { PropertyPair(it.key, it.value) }
+ configContainer.properties.map { PropertyPair(it.key, it.value) }.filter {
+ !it.key.params.flags.contains(ConfigFlag.HIDDEN)
+ }
})
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt
index 2a2af0ae..d8b91c8a 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt
@@ -1,3 +1,4 @@
+@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package me.rhunk.snapenhance.ui.manager.pages.home
import android.net.Uri
@@ -8,6 +9,8 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.animation.animateContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown
import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp
@@ -21,6 +24,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
@@ -45,20 +49,16 @@ import me.rhunk.snapenhance.ui.util.saveFile
class HomeLogs : Routes.Route() {
private val logListState by lazy { LazyListState(0) }
private lateinit var activityLauncherHelper: ActivityLauncherHelper
-
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
-
override val topBarActions: @Composable (RowScope.() -> Unit) = {
var showDropDown by remember { mutableStateOf(false) }
-
IconButton(onClick = {
showDropDown = true
}) {
Icon(Icons.Filled.MoreVert, contentDescription = null)
}
-
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
@@ -73,9 +73,8 @@ class HomeLogs : Routes.Route() {
}, text = {
Text(translation["clear_logs_button"])
})
-
DropdownMenuItem(onClick = {
- activityLauncherHelper.saveFile("snapenhance-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri ->
+ activityLauncherHelper.saveFile("purrfectsnap-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri ->
context.coroutineScope.launch {
context.shortToast(translation["saving_logs_toast"])
context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use {
@@ -84,7 +83,7 @@ class HomeLogs : Routes.Route() {
context.longToast(translation["saved_logs_success_toast"])
}.onFailure {
context.longToast(translation["saved_logs_failure_toast"])
- context.log.error("Failed to save logs to $uri!", it)
+ context.log.error("Failed to save logs to ${'$'}uri!", it)
}
}
}
@@ -95,14 +94,13 @@ class HomeLogs : Routes.Route() {
})
}
}
-
+ @Suppress("DEPRECATION")
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
- val clipboardManager = LocalClipboardManager.current
+ val clipboard: ClipboardManager = LocalClipboardManager.current
var lineCount by remember { mutableIntStateOf(0) }
var logReader by remember { mutableStateOf(null) }
var isRefreshing by remember { mutableStateOf(false) }
-
fun refreshLogs() {
coroutineScope.launch(Dispatchers.IO) {
runCatching {
@@ -120,16 +118,13 @@ class HomeLogs : Routes.Route() {
}
}
}
-
val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh = {
refreshLogs()
})
-
LaunchedEffect(Unit) {
isRefreshing = true
refreshLogs()
}
-
Box(
modifier = Modifier
.fillMaxSize()
@@ -138,7 +133,8 @@ class HomeLogs : Routes.Route() {
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.horizontalScroll(ScrollState(0)),
- state = logListState
+ state = logListState,
+ contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
item {
if (lineCount == 0 && logReader != null) {
@@ -157,21 +153,22 @@ class HomeLogs : Routes.Route() {
})
}
logLine?.let { line ->
- Box(modifier = Modifier
- .fillMaxWidth()
- .pointerInput(Unit) {
- detectTapGestures(
- onLongPress = {
- coroutineScope.launch {
- clipboardManager.setText(
- AnnotatedString(
- line.message
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .animateContentSize()
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onLongPress = {
+ coroutineScope.launch {
+ clipboard.setText(
+ AnnotatedString(line.message)
)
- )
+ }
}
- }
- )
- }) {
+ )
+ }
+ ) {
Column(
modifier = Modifier
.padding(4.dp)
@@ -187,26 +184,22 @@ class HomeLogs : Routes.Route() {
LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report
LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info
LogLevel.WARN -> Icons.Outlined.Warning
- else -> Icons.Outlined.Info
},
modifier = Modifier.size(16.dp),
contentDescription = null,
)
-
Text(
text = LogChannel.fromChannel(line.tag)?.shortName ?: line.tag,
modifier = Modifier.padding(start = 4.dp),
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
)
-
Text(
text = line.dateTime,
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
fontSize = 10.sp
)
}
-
Text(
text = line.message.trimIndent(),
lineHeight = 10.sp,
@@ -218,7 +211,6 @@ class HomeLogs : Routes.Route() {
}
}
}
-
PullRefreshIndicator(
refreshing = isRefreshing,
state = pullRefreshState,
@@ -226,7 +218,6 @@ class HomeLogs : Routes.Route() {
)
}
}
-
override val floatingActionButton: @Composable () -> Unit = {
val coroutineScope = rememberCoroutineScope()
Column(
@@ -244,7 +235,6 @@ class HomeLogs : Routes.Route() {
) {
Icon(Icons.Filled.KeyboardDoubleArrowUp, contentDescription = null)
}
-
FilledIconButton(
onClick = {
coroutineScope.launch {
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRootSection.kt
index 185b6887..23a199ed 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRootSection.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRootSection.kt
@@ -1,21 +1,67 @@
package me.rhunk.snapenhance.ui.manager.pages.home
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+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.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
+import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BugReport
-import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Settings
-import androidx.compose.material3.*
+import androidx.compose.material.icons.outlined.Widgets
+import androidx.compose.material3.Button
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.LinkAnnotation
@@ -29,6 +75,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
@@ -41,21 +88,36 @@ import me.rhunk.snapenhance.common.ui.TopBarActionButton
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.common.util.ktx.openLink
-import me.rhunk.snapenhance.core.ui.Snapenhance
+
import me.rhunk.snapenhance.storage.getQuickTiles
import me.rhunk.snapenhance.storage.setQuickTiles
import me.rhunk.snapenhance.ui.manager.Routes
+import me.rhunk.snapenhance.ui.manager.data.UpdateDownloader
import me.rhunk.snapenhance.ui.manager.data.Updater
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
+import me.rhunk.snapenhance.ui.util.AlertDialogs
+import me.rhunk.snapenhance.ui.util.scaleOnPress
import java.text.DateFormat
+import kotlin.math.roundToInt
class HomeRootSection : Routes.Route() {
companion object {
val cardMargin = 10.dp
}
-
private lateinit var activityLauncherHelper: ActivityLauncherHelper
-
+ data class QaCard(val id: String, val name: String, val icon: ImageVector, val action: (Routes) -> Unit)
+ private val cardEntries by lazy {
+ val list = mutableListOf()
+ EnumQuickActions.entries.forEach { q ->
+ val name = context.translation["actions.${q.key}.name"]
+ list.add(QaCard(id = "quick.${q.key}", name = name, icon = q.icon, action = q.action))
+ }
+ EnumAction.entries.forEach { a ->
+ val name = context.translation["actions.${a.key}.name"]
+ list.add(QaCard(id = "action.${a.key}", name = name, icon = a.icon, action = { context.launchActionIntent(a) }))
+ }
+ list
+ }
private val cards by lazy {
EnumQuickActions.entries.map {
(context.translation["actions.${it.key}.name"] to it.icon) to it.action
@@ -69,11 +131,8 @@ class HomeRootSection : Routes.Route() {
}
}
}
-
@Composable
- private fun InfoCard(
- content: @Composable ColumnScope.() -> Unit,
- ) {
+ private fun InfoCard(content: @Composable ColumnScope.() -> Unit) {
OutlinedCard(
modifier = Modifier
.padding(start = cardMargin, end = cardMargin)
@@ -92,13 +151,14 @@ class HomeRootSection : Routes.Route() {
}
}
}
-
@Composable
fun ExternalLinkIcon(
modifier: Modifier = Modifier,
size: Dp = 32.dp,
imageVector: ImageVector,
+ onClick: (() -> Unit)? = null,
) {
+ val interactionSource = remember { MutableInteractionSource() }
Icon(
imageVector = imageVector,
contentDescription = null,
@@ -106,16 +166,66 @@ class HomeRootSection : Routes.Route() {
modifier = Modifier
.size(size)
.clip(RoundedCornerShape(50))
+ .scaleOnPress(interactionSource)
+ .then(
+ if (onClick != null)
+ Modifier.clickable(interactionSource = interactionSource, indication = LocalIndication.current) { onClick() }
+ else Modifier
+ )
.then(modifier)
)
}
+ private fun resolveTileKey(name: String): String {
+ val entry = cardEntries.firstOrNull { it.name == name }
+ return entry?.id ?: name
+ }
+ private fun getTileSpan(name: String): Pair {
+ val prefs = context.sharedPreferences
+ val key = resolveTileKey(name)
+ val raw = prefs.getString("quick_tile_size_$key", null) ?: "1x1"
+ val parts = raw.split('x')
+ val w = parts.getOrNull(0)?.toIntOrNull()?.coerceIn(1, 3) ?: 1
+ val h = parts.getOrNull(1)?.toIntOrNull()?.coerceIn(1, 3) ?: 1
+ return w to h
+ }
+ private fun setTileSpan(name: String, w: Int, h: Int) {
+ val prefs = context.sharedPreferences
+ val key = resolveTileKey(name)
+ prefs.edit().putString("quick_tile_size_$key", "${w.coerceIn(1,3)}x${h.coerceIn(1,3)}").apply()
+ }
+ private fun clearTileSpan(name: String) {
+ val prefs = context.sharedPreferences
+ val key = resolveTileKey(name)
+ prefs.edit().remove("quick_tile_size_$key").apply()
+ }
+
+ private fun getTileOffset(name: String): Pair {
+ val prefs = context.sharedPreferences
+ val key = resolveTileKey(name)
+ val raw = prefs.getString("quick_tile_offset_$key", null)
+ if (raw == null) return 0f to 0f
+ val parts = raw.split(',')
+ val x = parts.getOrNull(0)?.toFloatOrNull() ?: 0f
+ val y = parts.getOrNull(1)?.toFloatOrNull() ?: 0f
+ return x to y
+ }
+
+ private fun setTileOffset(name: String, x: Float, y: Float) {
+ val prefs = context.sharedPreferences
+ val key = resolveTileKey(name)
+ prefs.edit().putString("quick_tile_offset_$key", "$x,$y").apply()
+ }
+
+ private fun clearTileOffset(name: String) {
+ val prefs = context.sharedPreferences
+ val key = resolveTileKey(name)
+ prefs.edit().remove("quick_tile_offset_$key").apply()
+ }
override val title: @Composable (() -> Unit)? = {}
-
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
-
override val topBarActions: @Composable (RowScope.() -> Unit) = {
TopBarActionButton(
onClick = {
@@ -134,28 +244,35 @@ class HomeRootSection : Routes.Route() {
)
}
- @OptIn(ExperimentalLayoutApi::class)
+ @OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class, ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val avenirNext = remember {
- FontFamily(
- Font(R.font.avenir_next_medium, FontWeight.Medium)
- )
+ FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
}
-
+ val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) {
+ context.database.getQuickTiles()
+ }
+ val latestUpdate by rememberAsyncMutableState(defaultValue = null) { Updater.latestRelease }
+ var showQuickActionsMenu by remember { mutableStateOf(false) }
+ var editMode by remember { mutableStateOf(false) }
+ val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
- .verticalScroll(rememberScrollState())
+ .verticalScroll(scrollState, enabled = !editMode)
) {
- Icon(
- imageVector = Snapenhance, contentDescription = null,
+ Text(
+ "PurrfectSnap",
modifier = Modifier
.fillMaxWidth()
.padding(all = 8.dp)
.align(Alignment.CenterHorizontally),
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 48.sp,
+ fontWeight = FontWeight.Bold,
+ fontFamily = avenirNext,
+ textAlign = TextAlign.Center
)
-
Text(
text = translation.format(
"version_title",
@@ -165,45 +282,28 @@ class HomeRootSection : Routes.Route() {
fontFamily = avenirNext,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
-
Row(
- horizontalArrangement = Arrangement.spacedBy(
- 15.dp, Alignment.CenterHorizontally
- ),
+ horizontalArrangement = Arrangement.spacedBy(15.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(all = 5.dp)
) {
ExternalLinkIcon(
- modifier = Modifier.clickable {
- context.androidContext.openLink("https://t.me/snapenhance")
- },
+ onClick = { context.androidContext.openLink("https://t.me/purrfectsnap") },
imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
)
-
ExternalLinkIcon(
- modifier = Modifier.clickable {
- context.androidContext.openLink("https://github.com/rhunk/SnapEnhance")
- },
+ onClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap") },
imageVector = ImageVector.vectorResource(id = R.drawable.ic_github),
)
-
ExternalLinkIcon(
- modifier = Modifier.offset(x = (-3).dp).clickable {
- context.androidContext.openLink("https://github.com/rhunk/SnapEnhance/wiki")
- },
+ onClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap/wiki") },
+ modifier = Modifier.offset(x = (-3).dp),
size = 40.dp,
- imageVector = Icons.AutoMirrored.Default.Help,
+ imageVector = Icons.AutoMirrored.Filled.Help,
)
}
-
- val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) {
- context.database.getQuickTiles()
- }
-
- val latestUpdate by rememberAsyncMutableState(defaultValue = null) { Updater.latestRelease }
-
if (latestUpdate != null) {
Spacer(modifier = Modifier.height(10.dp))
InfoCard {
@@ -228,18 +328,65 @@ class HomeRootSection : Routes.Route() {
overflow = TextOverflow.Ellipsis,
)
}
- Button(
- modifier = Modifier.height(40.dp),
- onClick = {
- latestUpdate?.releaseUrl?.let { context.androidContext.openLink(it) }
+ val downloadState by UpdateDownloader.downloadState.collectAsState()
+ val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
+ val coroutineScope = rememberCoroutineScope()
+
+ AnimatedContent(
+ targetState = downloadState,
+ modifier = Modifier.height(40.dp)
+ ) { state ->
+ when (state) {
+ UpdateDownloader.DownloadState.IDLE -> {
+ IconButton(
+ onClick = {
+ val latest = latestUpdate ?: return@IconButton
+ if (latest.workflowId == null) {
+ context.androidContext.openLink(latest.releaseUrl)
+ return@IconButton
+ }
+ val supportedAbis = android.os.Build.SUPPORTED_ABIS
+ var abiName: String? = null
+ for (abi in supportedAbis) {
+ when (abi) {
+ "arm64-v8a" -> {
+ abiName = "armv8"
+ break
+ }
+ "armeabi-v7a" -> {
+ abiName = "armv7"
+ break
+ }
+ }
+ }
+
+ if (abiName != null) {
+ val artifactName = "purrfectsnap-${abiName}-debug"
+ val downloadUrl = "https://nightly.link/particle-box/PurrfectSnap/actions/runs/${latest.workflowId}/$artifactName.zip"
+ UpdateDownloader.downloadAndInstall(context.androidContext, downloadUrl, "$artifactName.zip", coroutineScope)
+ } else {
+ android.widget.Toast.makeText(context.androidContext, "Your device architecture is not supported for automatic updates.", android.widget.Toast.LENGTH_LONG).show()
+ }
+ },
+ modifier = Modifier.scaleOnPress(remember { MutableInteractionSource() })
+ ) {
+ Icon(imageVector = Icons.Default.Download, contentDescription = "Download")
+ }
+ }
+ UpdateDownloader.DownloadState.DOWNLOADING -> {
+ CircularProgressIndicator(progress = { downloadProgress })
+ }
+ UpdateDownloader.DownloadState.COMPLETED -> {
+ Icon(imageVector = Icons.Default.Check, contentDescription = "Completed")
+ }
+ UpdateDownloader.DownloadState.FAILED -> {
+ Icon(imageVector = Icons.Default.Close, contentDescription = "Failed")
+ }
}
- ) {
- Text(text = translation["update_button"])
}
}
}
}
-
if (BuildConfig.DEBUG) {
Spacer(modifier = Modifier.height(10.dp))
InfoCard {
@@ -271,7 +418,7 @@ class HomeRootSection : Routes.Route() {
LinkAnnotation.Clickable(
"git_hash",
linkInteractionListener = {
- context.androidContext.openLink("https://github.com/rhunk/SnapEnhance/commit/${BuildConfig.GIT_HASH}")
+ context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap/commit/${BuildConfig.GIT_HASH}")
}
)
) {
@@ -285,18 +432,14 @@ class HomeRootSection : Routes.Route() {
}
}
}
- Text(
- text = buildSummary
- )
+ Text(text = buildSummary)
Text(
fontSize = 12.sp,
text = remember {
translation.format(
"debug_build_summary_date",
- "date" to DateFormat.getDateTimeInstance()
- .format(BuildConfig.BUILD_TIMESTAMP),
- "days" to ((System.currentTimeMillis() - BuildConfig.BUILD_TIMESTAMP) / 86400000).toInt()
- .toString()
+ "date" to DateFormat.getDateTimeInstance().format(BuildConfig.BUILD_TIMESTAMP),
+ "days" to ((System.currentTimeMillis() - BuildConfig.BUILD_TIMESTAMP) / 86400000).toInt().toString()
)
},
lineHeight = 20.sp,
@@ -304,107 +447,393 @@ class HomeRootSection : Routes.Route() {
)
}
}
-
- var showQuickActionsMenu by remember { mutableStateOf(false) }
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 20.dp, end = 10.dp, top = 5.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- translation["quick_actions_title"], fontSize = 20.sp,
- modifier = Modifier.weight(1f)
- )
- Box {
- IconButton(
- onClick = { showQuickActionsMenu = !showQuickActionsMenu },
+ Spacer(modifier = Modifier.height(12.dp))
+ AnimatedContent(targetState = selectedTiles.isNotEmpty(), label = "QuickActionsTitleAnim") { hasQuickActions ->
+ if (!hasQuickActions) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 4.dp)
) {
- Icon(Icons.Default.MoreVert, contentDescription = null)
+ Surface(
+ shape = RoundedCornerShape(50),
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ tonalElevation = 2.dp,
+ shadowElevation = 4.dp,
+ modifier = Modifier.align(Alignment.Center)
+ ) {
+ Text(
+ translation["quick_actions_title"],
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp)
+ )
+ }
}
- DropdownMenu(
- expanded = showQuickActionsMenu,
- onDismissRequest = { showQuickActionsMenu = false }
+ } else {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
) {
- cards.forEach { (card, _) ->
- fun toggle(state: Boolean? = null) {
- if (state?.let { !it } ?: selectedTiles.contains(card.first)) {
- selectedTiles.remove(card.first)
+ Text(
+ translation["quick_actions_title"],
+ fontSize = 20.sp,
+ fontWeight = FontWeight.SemiBold,
+ textAlign = TextAlign.Start,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(
+ onClick = { showQuickActionsMenu = true },
+ modifier = Modifier.align(Alignment.CenterVertically)
+ ) {
+ Icon(
+ imageVector = ImageVector.vectorResource(id = R.drawable.ic_manage),
+ contentDescription = "Manage Quick Actions"
+ )
+ }
+ FilterChip(
+ selected = editMode,
+ onClick = { editMode = !editMode },
+ label = { Text(if (editMode) "Done" else "Edit") },
+ leadingIcon = { Icon(Icons.Filled.DragHandle, contentDescription = null) }
+ )
+ }
+ }
+ }
+ if (selectedTiles.isEmpty()) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(260.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Widgets,
+ contentDescription = "Quick Actions",
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ Text(
+ text = "No quick actions added yet",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ Button(
+ onClick = { showQuickActionsMenu = true },
+ modifier = Modifier.align(Alignment.CenterHorizontally)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = "Add Quick Action",
+ modifier = Modifier.size(22.dp)
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(text = "Add")
+ }
+ }
+ }
+ } else {
+ val spacing = 6.dp
+ var spanTick by remember { mutableIntStateOf(0) }
+
+ BoxWithConstraints(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = cardMargin)
+ ) {
+ val density = LocalDensity.current
+ val baseCell = remember { (maxWidth - (spacing * 2)) / 3f }
+ val baseCellPx = with(density) { baseCell.toPx() }
+
+ val (tilePositions, totalHeight) = remember(selectedTiles, spanTick) {
+ val positions = mutableMapOf()
+ var currentX = 0f
+ var currentY = 0f
+ var rowMaxHeight = 0f
+ val screenWidthPx = with(density) { maxWidth.toPx() }
+
+ selectedTiles.forEach { tileName ->
+ val card = cards.entries.find { entry -> entry.key.first == tileName }!!.key
+ val (wSpan, hSpan) = getTileSpan(card.first)
+ val tileWidthPx = with(density) { (baseCell * wSpan + spacing * (wSpan - 1)).toPx() }
+ val tileHeightPx = with(density) { (baseCell * hSpan + spacing * (hSpan - 1)).toPx() }
+
+ if (currentX + tileWidthPx > screenWidthPx) {
+ currentX = 0f
+ currentY += rowMaxHeight
+ rowMaxHeight = 0f
+ }
+
+ positions[tileName] = Offset(currentX, currentY)
+ currentX += tileWidthPx + with(density) { spacing.toPx() }
+ if (tileHeightPx > rowMaxHeight) {
+ rowMaxHeight = tileHeightPx
+ }
+ }
+ positions to (currentY + rowMaxHeight)
+ }
+
+ Box(modifier = Modifier.height(with(density) { totalHeight.toDp() })) {
+ remember(selectedTiles.size, context.translation.loadedLocale) {
+ selectedTiles.mapNotNull {
+ cards.entries.find { entry -> entry.key.first == it }
+ }
+ }.forEach { (card, action) ->
+ val interactionSource = remember { MutableInteractionSource() }
+ val _tick = spanTick
+ val (wSpan, hSpan) = getTileSpan(card.first)
+ val tileWidth = baseCell * wSpan + spacing * (wSpan - 1)
+ val tileHeight = baseCell * hSpan + spacing * (hSpan - 1)
+ val tileWidthPx = with(density) { tileWidth.toPx() }
+ val tileHeightPx = with(density) { tileHeight.toPx() }
+
+ var offsetX by remember(card.first) { mutableStateOf(0f) }
+ var offsetY by remember(card.first) { mutableStateOf(0f) }
+ var isDragging by remember { mutableStateOf(false) }
+
+ LaunchedEffect(card.first, spanTick) {
+ val (x, y) = getTileOffset(card.first)
+ if (x != 0f || y != 0f) {
+ offsetX = x
+ offsetY = y
} else {
- selectedTiles.add(0, card.first)
- }
- context.coroutineScope.launch {
- context.database.setQuickTiles(selectedTiles)
+ val pos = tilePositions[card.first]
+ if (pos != null) {
+ offsetX = pos.x
+ offsetY = pos.y
+ setTileOffset(card.first, offsetX, offsetY)
+ }
}
}
- DropdownMenuItem(onClick = { toggle() }, text = {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(all = 5.dp)
- ) {
- Checkbox(
- checked = selectedTiles.contains(card.first),
- onCheckedChange = {
- toggle(it)
+ val animatedOffsetX by animateFloatAsState(
+ targetValue = offsetX,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessLow
+ ),
+ label = "offsetX"
+ )
+ val animatedOffsetY by animateFloatAsState(
+ targetValue = offsetY,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessLow
+ ),
+ label = "offsetY"
+ )
+
+ val currentOffsetX = if (isDragging) offsetX else animatedOffsetX
+ val currentOffsetY = if (isDragging) offsetY else animatedOffsetY
+
+ val baseModifier = Modifier
+ .offset { IntOffset(currentOffsetX.roundToInt(), currentOffsetY.roundToInt()) }
+ .width(tileWidth)
+ .height(tileHeight)
+ .padding(all = 6.dp)
+
+ val editModifier = baseModifier.then(
+ Modifier.pointerInput(card.first, tileWidthPx, tileHeightPx) {
+ var originalOffsetX = 0f
+ var originalOffsetY = 0f
+ detectDragGestures(
+ onDragStart = {
+ isDragging = true
+ originalOffsetX = offsetX
+ originalOffsetY = offsetY
+ },
+ onDrag = { change, dragAmount ->
+ change.consume()
+ offsetX += dragAmount.x
+ offsetY += dragAmount.y
+ },
+ onDragEnd = {
+ isDragging = false
+ var targetTile: String? = null
+ var maxOverlap = 0f
+ val tileRect = Rect(Offset(offsetX, offsetY), Size(tileWidthPx, tileHeightPx))
+
+ for (otherTileName in selectedTiles) {
+ if (otherTileName == card.first) continue
+ val (otherOffsetX, otherOffsetY) = getTileOffset(otherTileName)
+ val (otherWSpan, otherHSpan) = getTileSpan(otherTileName)
+ val otherTileWidth = baseCell * otherWSpan + spacing * (otherWSpan - 1)
+ val otherTileHeight = baseCell * otherHSpan + spacing * (otherHSpan - 1)
+ val otherRect = Rect(Offset(otherOffsetX, otherOffsetY), Size(with(density) { otherTileWidth.toPx() }, with(density) { otherTileHeight.toPx() }))
+ val intersectRect = tileRect.intersect(otherRect)
+ val overlapArea = intersectRect.width * intersectRect.height
+ if (overlapArea > maxOverlap) {
+ maxOverlap = overlapArea
+ targetTile = otherTileName
+ }
+ }
+
+ if (targetTile != null) {
+ val (wSpan, hSpan) = getTileSpan(card.first)
+ val (targetWSpan, targetHSpan) = getTileSpan(targetTile!!)
+ if (wSpan == targetWSpan && hSpan == targetHSpan) {
+ // swap
+ val (targetOffsetX, targetOffsetY) = getTileOffset(targetTile!!)
+ setTileOffset(card.first, targetOffsetX, targetOffsetY)
+ setTileOffset(targetTile!!, originalOffsetX, originalOffsetY)
+ spanTick++ // this will trigger recomposition for all tiles
+ } else {
+ // revert
+ offsetX = originalOffsetX
+ offsetY = originalOffsetY
+ }
+ } else {
+ setTileOffset(card.first, offsetX, offsetY)
+ }
}
)
- Text(text = card.first)
}
- })
+ )
+ val viewModifier = baseModifier.then(Modifier.scaleOnPress(interactionSource))
+
+ if (editMode) {
+ ElevatedCard(
+ modifier = editModifier
+ ) {
+ Box(Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(all = 5.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ Icon(
+ imageVector = card.second, contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(50.dp)
+ )
+ Text(
+ text = card.first,
+ lineHeight = 16.sp,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ // Drag handle for resizing
+ var dxAccResize by remember(card.first, spanTick) { mutableStateOf(0f) }
+ var dyAccResize by remember(card.first, spanTick) { mutableStateOf(0f) }
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .size(28.dp)
+ .pointerInput(card.first, spanTick) {
+ detectDragGestures(
+ onDragStart = {
+ dxAccResize = 0f
+ dyAccResize = 0f
+ },
+ onDrag = { change, dragAmount ->
+ change.consume()
+ dxAccResize += dragAmount.x
+ dyAccResize += dragAmount.y
+
+ var newW = wSpan
+ var newH = hSpan
+ val step = baseCellPx / 2f
+
+ while (dxAccResize > step) {
+ newW = (wSpan + 1).coerceIn(1, 3)
+ dxAccResize -= step
+ }
+ while (dxAccResize < -step) {
+ newW = (wSpan - 1).coerceIn(1, 3)
+ dxAccResize += step
+ }
+ while (dyAccResize > step) {
+ newH = (hSpan + 1).coerceIn(1, 3)
+ dyAccResize -= step
+ }
+ while (dyAccResize < -step) {
+ newH = (hSpan - 1).coerceIn(1, 3)
+ dyAccResize += step
+ }
+
+ if (newW != wSpan || newH != hSpan) {
+ setTileSpan(card.first, newW, newH)
+ spanTick++
+ selectedTiles.forEach { clearTileOffset(it) }
+ }
+ }
+ )
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(Icons.Filled.DragHandle, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ }
+ } else {
+ ElevatedCard(
+ modifier = viewModifier,
+ onClick = { action(routes) },
+ interactionSource = interactionSource
+ ) {
+ Box(Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(all = 5.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ Icon(
+ imageVector = card.second, contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(50.dp)
+ )
+ Text(
+ text = card.first,
+ lineHeight = 16.sp,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ }
}
}
}
}
-
- FlowRow(
- modifier = Modifier
- .padding(all = cardMargin)
- .fillMaxWidth(),
- maxItemsInEachRow = 3,
- horizontalArrangement = Arrangement.SpaceEvenly,
- ) {
- val tileHeight = LocalDensity.current.run {
- remember { (context.androidContext.resources.displayMetrics.widthPixels / 3).toDp() - cardMargin / 2 }
- }
-
- remember(selectedTiles.size, context.translation.loadedLocale) {
- selectedTiles.mapNotNull {
- cards.entries.find { entry -> entry.key.first == it }
- }
- }.forEach { (card, action) ->
- ElevatedCard(
- modifier = Modifier
- .height(tileHeight)
- .weight(1f)
- .padding(all = 6.dp),
- onClick = { action(routes) }
- ) {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(all = 5.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.SpaceEvenly,
- ) {
- Icon(
- imageVector = card.second, contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.size(50.dp)
- )
- Text(
- text = card.first,
- lineHeight = 16.sp,
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- textAlign = TextAlign.Center,
- overflow = TextOverflow.Ellipsis,
- )
+ if (showQuickActionsMenu) {
+ QuickActionsDialog(
+ quickActions = cards,
+ selectedQuickActions = selectedTiles,
+ onDismiss = { showQuickActionsMenu = false },
+ onSave = { newList ->
+ val previous = selectedTiles.toList()
+ val removed = previous.filter { it !in newList }
+ removed.forEach { clearTileSpan(it); clearTileOffset(it) }
+ selectedTiles.clear()
+ selectedTiles.addAll(newList)
+ context.coroutineScope.launch {
+ context.database.setQuickTiles(selectedTiles)
}
+ showQuickActionsMenu = false
}
- }
+ )
}
+ Spacer(modifier = Modifier.height(routes.bottomPadding))
}
}
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt
index fa4f01e3..a7cd5dd0 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt
@@ -7,10 +7,16 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
+import androidx.compose.material.icons.filled.Brightness4
+import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -21,36 +27,75 @@ import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.common.action.EnumAction
import me.rhunk.snapenhance.common.bridge.InternalFileHandleType
+import me.rhunk.snapenhance.common.ui.ThemeChooserDialog
+import me.rhunk.snapenhance.common.ui.ThemeMode
+import me.rhunk.snapenhance.common.ui.ThemePreferences
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.setup.Requirements
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
import me.rhunk.snapenhance.ui.util.AlertDialogs
import me.rhunk.snapenhance.ui.util.saveFile
+import androidx.work.WorkManager
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.Constraints
+import androidx.work.NetworkType
+import me.rhunk.snapenhance.task.UpdateCheckWorker
+import java.util.concurrent.TimeUnit
class HomeSettings : Routes.Route() {
private lateinit var activityLauncherHelper: ActivityLauncherHelper
private val dialogs by lazy { AlertDialogs(context.translation) }
+ private fun scheduleUpdateCheck() {
+ val workManager = WorkManager.getInstance(context.androidContext)
+ if (context.config.root.global.updateSettings.autoUpdateCheck.get()) {
+ val frequency = context.config.root.global.updateSettings.updateCheckFrequency.get()
+ val repeatInterval = when (frequency) {
+ "daily" -> 1L
+ "weekly" -> 7L
+ "monthly" -> 30L
+ else -> 1L
+ }
+
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+
+ val workRequest = PeriodicWorkRequestBuilder(repeatInterval, TimeUnit.DAYS)
+ .setConstraints(constraints)
+ .build()
+
+ workManager.enqueueUniquePeriodicWork(
+ "snapenhance_update_check",
+ ExistingPeriodicWorkPolicy.REPLACE,
+ workRequest
+ )
+ } else {
+ workManager.cancelUniqueWork("snapenhance_update_check")
+ }
+ }
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
-
@Composable
private fun RowTitle(title: String) {
Text(text = title, modifier = Modifier.padding(16.dp), fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
-
@Composable
private fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) {
val realKey = "debug_$key"
var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, false)) }
-
+ val hapticFeedback = LocalHapticFeedback.current
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp)
.clickable {
+ if (context.config.root.global.uiSettings.hapticFeedback.get()) {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
value = !value
sharedPreferences
.edit() {
@@ -62,18 +107,19 @@ class HomeSettings : Routes.Route() {
) {
Text(text = text, modifier = Modifier.padding(end = 16.dp), fontSize = 14.sp)
Switch(checked = value, onCheckedChange = {
+ if (context.config.root.global.uiSettings.hapticFeedback.get()) {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
value = it
sharedPreferences.edit().putBoolean(realKey, it).apply()
}, modifier = Modifier.padding(end = 26.dp))
}
}
-
@Composable
private fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) {
var confirmationDialog by remember {
mutableStateOf(false)
}
-
fun takeAction() {
if (requireConfirmation) {
confirmationDialog = true
@@ -81,7 +127,6 @@ class HomeSettings : Routes.Route() {
action()
}
}
-
if (requireConfirmation && confirmationDialog) {
Dialog(onDismissRequest = { confirmationDialog = false }) {
dialogs.ConfirmDialog(title = context.translation["manager.dialogs.action_confirm.title"], onConfirm = {
@@ -92,7 +137,6 @@ class HomeSettings : Routes.Route() {
})
}
}
-
ShiftedRow(
modifier = Modifier
.fillMaxWidth()
@@ -120,7 +164,6 @@ class HomeSettings : Routes.Route() {
}
}
}
-
@Composable
private fun ShiftedRow(
modifier: Modifier = Modifier,
@@ -137,11 +180,58 @@ class HomeSettings : Routes.Route() {
@OptIn(ExperimentalMaterial3Api::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
+ val contextC = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val themeMode by ThemePreferences.getThemeModeFlow(contextC).collectAsState(initial = ThemeMode.SYSTEM)
+ var showThemeDialog by remember { mutableStateOf(false) }
+
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
+ // APP THEME (Popup)
+ Spacer(Modifier.height(20.dp))
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .clickable { showThemeDialog = true },
+ shape = MaterialTheme.shapes.medium,
+ elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(22.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Brightness4,
+ contentDescription = "Theme",
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(26.dp)
+ )
+ Spacer(modifier = Modifier.width(18.dp))
+ Column(Modifier.weight(1f)) {
+ Text("App Theme", fontWeight = FontWeight.Medium, fontSize = 16.sp)
+ Text(themeMode.displayName, color = MaterialTheme.colorScheme.primary, fontSize = 14.sp)
+ }
+ }
+ }
+ if (showThemeDialog) {
+ ThemeChooserDialog(
+ selected = themeMode,
+ onSelect = { mode ->
+ scope.launch {
+ ThemePreferences.setThemeMode(contextC, mode)
+ }
+ },
+ onDismiss = { showThemeDialog = false }
+ )
+ }
+ Spacer(Modifier.height(20.dp))
+
RowTitle(title = translation["actions_title"])
EnumAction.entries.forEach { enumAction ->
RowAction(key = enumAction.key) {
@@ -154,6 +244,117 @@ class HomeSettings : Routes.Route() {
RowAction(key = "change_language") {
context.checkForRequirements(Requirements.LANGUAGE)
}
+
+ RowTitle(title = "UI Settings")
+ ShiftedRow {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 55.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(text = "Haptic Feedback")
+ var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
+ val hapticFeedback = LocalHapticFeedback.current
+ Switch(
+ checked = hapticFeedbackEnabled,
+ onCheckedChange = {
+ if (it) {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
+ hapticFeedbackEnabled = it
+ context.config.root.global.uiSettings.hapticFeedback.set(it)
+ context.config.writeConfig()
+ },
+ modifier = Modifier.padding(end = 26.dp)
+ )
+ }
+ }
+
+ RowTitle(title = translation["updates_title"])
+ ShiftedRow {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
+ var selectedFrequency by remember { mutableStateOf(context.config.root.global.updateSettings.updateCheckFrequency.getNullable() ?: "weekly") }
+ var frequencyMenuExpanded by remember { mutableStateOf(false) }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 55.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column {
+ Text(text = translation["auto_update_check"])
+ if (autoUpdateCheck) {
+ Text(
+ text = translation["update_check_frequency_" + selectedFrequency],
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Light
+ )
+ }
+ }
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box {
+ IconButton(
+ onClick = { frequencyMenuExpanded = true },
+ enabled = autoUpdateCheck,
+ modifier = Modifier.alpha(if (autoUpdateCheck) 1f else 0f)
+ ) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = translation["update_check_frequency"]
+ )
+ }
+ if (autoUpdateCheck) {
+ DropdownMenu(
+ expanded = frequencyMenuExpanded,
+ onDismissRequest = { frequencyMenuExpanded = false }
+ ) {
+ val frequencies = remember { listOf("daily", "weekly", "monthly") }
+ frequencies.forEach { frequency ->
+ DropdownMenuItem(
+ text = { Text(text = translation["update_check_frequency_" + frequency]) },
+ onClick = {
+ selectedFrequency = frequency
+ context.config.root.global.updateSettings.updateCheckFrequency.set(frequency)
+ context.config.writeConfig()
+ scheduleUpdateCheck()
+ frequencyMenuExpanded = false
+ }
+ )
+ }
+ }
+ }
+ }
+
+ val hapticFeedback = LocalHapticFeedback.current
+ Switch(
+ checked = autoUpdateCheck,
+ onCheckedChange = {
+ if (context.config.root.global.uiSettings.hapticFeedback.get()) {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
+ autoUpdateCheck = it
+ context.config.root.global.updateSettings.autoUpdateCheck.set(it)
+ if (it && context.config.root.global.updateSettings.updateCheckFrequency.getNullable() == null) {
+ context.config.root.global.updateSettings.updateCheckFrequency.set("weekly")
+ }
+ context.config.writeConfig()
+ scheduleUpdateCheck()
+ },
+ modifier = Modifier.padding(end = 26.dp)
+ )
+ }
+ }
+ }
+ }
+
RowTitle(title = translation["message_logger_title"])
ShiftedRow {
Column(
@@ -225,7 +426,6 @@ class HomeSettings : Routes.Route() {
}
}
}
-
RowTitle(title = translation["debug_title"])
Row(
horizontalArrangement = Arrangement.SpaceBetween,
@@ -238,7 +438,6 @@ class HomeSettings : Routes.Route() {
.padding(start = 26.dp)
) {
var expanded by remember { mutableStateOf(false) }
-
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
@@ -250,7 +449,6 @@ class HomeSettings : Routes.Route() {
readOnly = true,
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable)
)
-
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
InternalFileHandleType.entries.forEach { fileType ->
DropdownMenuItem(onClick = {
@@ -285,9 +483,10 @@ class HomeSettings : Routes.Route() {
PreferenceToggle(context.sharedPreferences, key = "test_mode", text = "Test Mode (FOR DEBUGGING ONLY)")
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = "Disable Feature Loading")
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = "Disable Auto Mapper")
+ PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = "Disable Bypass Status Indicator")
}
}
- Spacer(modifier = Modifier.height(50.dp))
+ Spacer(modifier = Modifier.height(routes.bottomPadding))
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/QuickActionsDialog.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/QuickActionsDialog.kt
new file mode 100644
index 00000000..fef410df
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/QuickActionsDialog.kt
@@ -0,0 +1,102 @@
+package me.rhunk.snapenhance.ui.manager.pages.home
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.text.style.TextAlign
+
+@Composable
+fun QuickActionsDialog(
+ quickActions: Map, Any>,
+ selectedQuickActions: List,
+ onDismiss: () -> Unit,
+ onSave: (List) -> Unit
+) {
+ val selected = remember { mutableStateListOf(*selectedQuickActions.toTypedArray()) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text(
+ text = "Edit Quick Actions",
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ )
+ },
+ text = {
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ text = "Select and size your quick actions.",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .padding(bottom = 16.dp)
+ .fillMaxWidth()
+ )
+ quickActions.keys.forEach { (name, icon) ->
+ val isSelected = selected.contains(name)
+ ListItem(
+ modifier = Modifier
+ .clickable {
+ if (isSelected) selected.remove(name) else selected.add(name)
+ }
+ .fillMaxWidth(),
+ headlineContent = {
+ Text(
+ name,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = icon,
+ contentDescription = name,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ trailingContent = {
+ Checkbox(
+ checked = isSelected,
+ onCheckedChange = { isChecked ->
+ if (isChecked) selected.add(name) else selected.remove(name)
+ }
+ )
+ }
+ )
+ }
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = { onSave(selected.toList()) },
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Text("Save")
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = onDismiss,
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Text("Cancel")
+ }
+ },
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/location/BetterLocationRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/location/BetterLocationRoot.kt
index 6372c451..22cf7f0c 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/location/BetterLocationRoot.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/location/BetterLocationRoot.kt
@@ -1,6 +1,7 @@
package me.rhunk.snapenhance.ui.manager.pages.location
import android.os.Parcel
+import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -8,11 +9,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.EditLocation
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -99,7 +102,6 @@ class BetterLocationRoot : Routes.Route() {
}
} ?: friendsLocation
}
-
ElevatedCard(
shape = MaterialTheme.shapes.large,
modifier = Modifier.padding(top = 32.dp, bottom = 32.dp)
@@ -149,11 +151,24 @@ class BetterLocationRoot : Routes.Route() {
}
}
+ @Composable
+ private fun ThemedEditLocationButton(onClick: () -> Unit) {
+ FilledIconButton(
+ modifier = Modifier.size(40.dp),
+ colors = IconButtonDefaults.filledIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentColor = if (isSystemInDarkTheme()) Color.White else Color(0xFF151A1A),
+ ),
+ onClick = onClick
+ ) {
+ Icon(Icons.Default.EditLocation, contentDescription = null)
+ }
+ }
+
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coordinatesProperty = remember {
context.config.root.global.betterLocation.getPropertyPair("coordinates")
}
-
val updateDispatcher = rememberAsyncUpdateDispatcher()
val savedCoordinates = rememberAsyncMutableStateList(
defaultValue = listOf(),
@@ -164,17 +179,14 @@ class BetterLocationRoot : Routes.Route() {
var showMap by remember { mutableStateOf(false) }
var addSavedCoordinateDialog by remember { mutableStateOf(false) }
var showTeleportDialog by remember { mutableStateOf(false) }
-
val marker = remember { mutableStateOf(null) }
val mapView = remember { mutableStateOf(null) }
var spoofedCoordinates by remember(showTeleportDialog, showMap) { mutableStateOf(coordinatesProperty.value.get() as? Pair<*, *>) }
-
fun addSavedCoordinate(id: Int?, locationCoordinates: LocationCoordinates, onSuccess: suspend (id: Int) -> Unit = {}) {
context.coroutineScope.launch {
onSuccess(context.database.addOrUpdateLocationCoordinate(id, locationCoordinates))
}
}
-
if (showTeleportDialog) {
me.rhunk.snapenhance.ui.util.Dialog(
properties = DialogProperties(usePlatformDefaultWidth = false),
@@ -189,7 +201,6 @@ class BetterLocationRoot : Routes.Route() {
}
)
}
-
Column(
modifier = Modifier
.fillMaxSize()
@@ -207,7 +218,6 @@ class BetterLocationRoot : Routes.Route() {
.fillMaxWidth()
.padding(8.dp)
)
-
if (addSavedCoordinateDialog) {
me.rhunk.snapenhance.ui.util.Dialog(
onDismissRequest = { addSavedCoordinateDialog = false },
@@ -230,7 +240,6 @@ class BetterLocationRoot : Routes.Route() {
}
)
}
-
if (showMap) {
me.rhunk.snapenhance.ui.util.Dialog(
onDismissRequest = { showMap = false },
@@ -249,13 +258,12 @@ class BetterLocationRoot : Routes.Route() {
}
)
}
-
LazyColumn(
modifier = Modifier
.fillMaxSize()
- .clipToBounds()
+ .clipToBounds(),
+ contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
-
item {
@Composable
fun ConfigToggle(
@@ -345,7 +353,6 @@ class BetterLocationRoot : Routes.Route() {
val isSelected = spoofedCoordinates == mutableCoordinates.latitude to mutableCoordinates.longitude
var showDeleteDialog by remember { mutableStateOf(false) }
var showEditDialog by remember { mutableStateOf(false) }
-
fun setSpoofedCoordinates() {
spoofedCoordinates = mutableCoordinates.latitude to mutableCoordinates.longitude
coordinatesProperty.value.setAny(spoofedCoordinates)
@@ -353,7 +360,6 @@ class BetterLocationRoot : Routes.Route() {
context.config.writeConfig()
}
}
-
if (showDeleteDialog) {
me.rhunk.snapenhance.ui.util.Dialog(
onDismissRequest = { showDeleteDialog = false },
@@ -373,7 +379,6 @@ class BetterLocationRoot : Routes.Route() {
}
)
}
-
if (showEditDialog) {
me.rhunk.snapenhance.ui.util.Dialog(
onDismissRequest = { showEditDialog = false },
@@ -401,7 +406,6 @@ class BetterLocationRoot : Routes.Route() {
}
)
}
-
ElevatedCard(
onClick = {
mutableCoordinates = coordinates
@@ -447,7 +451,7 @@ class BetterLocationRoot : Routes.Route() {
FilledIconButton(onClick = {
showEditDialog = true
}) {
- Icon(Icons.Default.Edit, contentDescription = "Delete")
+ Icon(Icons.Default.Edit, contentDescription = "Edit")
}
Spacer(modifier = Modifier.width(4.dp))
FilledIconButton(onClick = {
@@ -461,4 +465,4 @@ class BetterLocationRoot : Routes.Route() {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ManageScriptReposSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ManageScriptReposSection.kt
new file mode 100644
index 00000000..fd261825
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ManageScriptReposSection.kt
@@ -0,0 +1,246 @@
+package me.rhunk.snapenhance.ui.manager.pages.scripting
+
+import androidx.compose.ui.Alignment
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Public
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.material.icons.filled.Error
+import androidx.core.net.toUri
+import com.google.gson.JsonParser
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard
+import me.rhunk.snapenhance.storage.addRepo
+import me.rhunk.snapenhance.storage.getRepositories
+import me.rhunk.snapenhance.storage.removeRepo
+import me.rhunk.snapenhance.ui.manager.Routes
+import me.rhunk.snapenhance.ui.manager.components.AestheticDialog
+import okhttp3.OkHttpClient
+
+class ManageScriptReposSection : Routes.Route() {
+ private val refreshTrigger = mutableStateOf(0)
+ private val okHttpClient by lazy { OkHttpClient() }
+
+ private fun extractRepoInfo(url: String): Pair {
+ if (url.contains("raw.githubusercontent.com")) {
+ val parts = url.removePrefix("https://raw.githubusercontent.com/").split("/")
+ if (parts.size >= 2) {
+ return parts[1] to parts[0]
+ }
+ }
+ return url.substringAfterLast("/").substringBeforeLast(".") to url.substringAfter("://").substringBefore("/")
+ }
+
+ override val floatingActionButton: @Composable () -> Unit = {
+ var showAddDialog by remember { mutableStateOf(false) }
+ var showErrorDialog by remember { mutableStateOf(false) }
+ var errorDialogMessage by remember { mutableStateOf("") }
+
+ if (showErrorDialog) {
+ AestheticDialog(
+ onDismissRequest = { showErrorDialog = false },
+ title = "Invalid Repository",
+ text = errorDialogMessage,
+ icon = Icons.Default.Error,
+ confirmButtonText = "OK",
+ onConfirm = { showErrorDialog = false }
+ )
+ }
+
+ ExtendedFloatingActionButton(onClick = { showAddDialog = true }) {
+ Text("Add Repository")
+ }
+
+ if (showAddDialog) {
+ val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
+
+ var url by remember { mutableStateOf("") }
+ var loading by remember { mutableStateOf(false) }
+
+ AlertDialog(
+ onDismissRequest = { showAddDialog = false },
+ title = { Text("Add Repository URL") },
+ text = {
+ val focusRequester = remember { FocusRequester() }
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ .onGloballyPositioned { focusRequester.requestFocus() },
+ value = url,
+ onValueChange = { url = it },
+ label = { Text("Repository URL") }
+ )
+ LaunchedEffect(Unit) {
+ context.androidContext.getUrlFromClipboard()?.let { url = it }
+ }
+ },
+ confirmButton = {
+ Button(
+ enabled = !loading && url.isNotBlank(),
+ onClick = {
+ loading = true
+ coroutineScope.launch {
+ runCatching {
+ var modifiedUrl = url
+ if (url.startsWith("https://github.com/")) {
+ val splitUrl = modifiedUrl.removePrefix("https://github.com/").split("/")
+ val repoName = splitUrl[0] + "/" + splitUrl[1]
+ okHttpClient.newCall(
+ okhttp3.Request.Builder().url("https://api.github.com/repos/$repoName").build()
+ ).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw Exception("Failed to fetch default branch: ${response.code}")
+ }
+ val json = response.body?.string() ?: throw Exception("Empty response")
+ val defaultBranch = Regex("\"default_branch\":\"([^\"]+)\"").find(json)?.groupValues?.get(1)
+ ?: throw Exception("No default_branch field")
+ modifiedUrl = "https://raw.githubusercontent.com/$repoName/$defaultBranch/"
+ }
+ }
+
+ val indexUrl = modifiedUrl.toUri().buildUpon().appendPath("index.json").build().toString()
+ val request = okhttp3.Request.Builder().url(indexUrl).build()
+ val isValid = okHttpClient.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) throw Exception("Failed to fetch index.json: ${response.code}")
+ val indexJson = response.body?.string() ?: throw Exception("Empty index.json")
+ JsonParser.parseString(indexJson).asJsonObject.has("scripts")
+ }
+
+ if (isValid) {
+ context.database.addRepo("script", modifiedUrl)
+ context.shortToast("Repository added successfully!")
+ showAddDialog = false
+ refreshTrigger.value++
+ } else {
+ errorDialogMessage = "This does not appear to be a valid Script repository."
+ showErrorDialog = true
+ }
+ }.onFailure {
+ context.log.error("Failed to add repository", it)
+ context.shortToast("Failed to add repository: ${it.message}")
+ }
+ loading = false
+ }
+ }
+ ) {
+ if (loading) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ } else {
+ Text("Add")
+ }
+ }
+ }
+ )
+ }
+ }
+
+ override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
+ val repositories by remember(refreshTrigger.value) {
+ mutableStateOf>(runBlocking { context.database.getRepositories("script") })
+ }
+
+ if (repositories.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "No repositories added",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp + routes.bottomPadding),
+ ) {
+ items(repositories) { url ->
+ val (repoName, author) = remember(url) { extractRepoInfo(url) }
+
+ ElevatedCard(
+ modifier = Modifier.padding(bottom = 8.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Public,
+ contentDescription = null,
+ modifier = Modifier.size(40.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ Text(
+ text = repoName,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp
+ )
+ Text(
+ text = author,
+ fontSize = 14.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ var showRemoveDialog by remember { mutableStateOf(false) }
+
+ Button(
+ onClick = { showRemoveDialog = true }
+ ) {
+ Text("Remove")
+ }
+
+ AnimatedVisibility(visible = showRemoveDialog) {
+ AlertDialog(
+ onDismissRequest = { showRemoveDialog = false },
+ title = { Text("Remove Repository") },
+ text = { Text("Are you sure you want to remove this repository?") },
+ confirmButton = {
+ Button(
+ onClick = {
+ context.database.removeRepo("script", url)
+ showRemoveDialog = false
+ refreshTrigger.value++
+ }
+ ) {
+ Text("Remove")
+ }
+ },
+ dismissButton = {
+ Button(onClick = { showRemoveDialog = false }) {
+ Text("Cancel")
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptCatalog.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptCatalog.kt
new file mode 100644
index 00000000..81109a1d
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptCatalog.kt
@@ -0,0 +1,305 @@
+@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
+package me.rhunk.snapenhance.ui.manager.pages.scripting
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.animation.animateContentSize
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Code
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.net.toUri
+import kotlinx.coroutines.*
+import me.rhunk.snapenhance.common.util.ktx.openLink
+import me.rhunk.snapenhance.storage.getRepositories
+import okhttp3.OkHttpClient
+import okhttp3.Request
+
+data class ScriptRepoManifest(
+ val scripts: List
+)
+data class ScriptRepoEntry(
+ val name: String,
+ val author: String? = null,
+ val description: String? = null,
+ val version: String? = null,
+ val filepath: String
+)
+
+@Composable
+fun ScriptCatalog(root: ScriptingRootSection) {
+ val context = root.context
+ val coroutineScope = rememberCoroutineScope()
+ val okHttpClient = remember { OkHttpClient() }
+ val gson = remember { context.gson }
+
+ var repositories by remember { mutableStateOf>(emptyList()) }
+ var repoIndexes by remember { mutableStateOf