Major Update!
This features a new bypass, new theme, etc
This commit is contained in:
12
.gitignore
vendored
12
.gitignore
vendored
@@ -6,4 +6,14 @@ local.properties
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
.cxx
|
||||
native/.omvll/
|
||||
cloudflare/**/.wrangler/
|
||||
cloudflare/**/node_modules/
|
||||
cloudflare/**/dist/
|
||||
cloudflare/**/.env
|
||||
cloudflare/**/.env.*
|
||||
cloudflare/**/allowed-codes.local.*
|
||||
cloudflare/**/allowed_codes.local.*
|
||||
security/allowed-codes.local.*
|
||||
security/allowed_codes.local.*
|
||||
|
||||
@@ -2,8 +2,20 @@ import com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.gradle.configurationcache.extensions.capitalized
|
||||
import com.android.build.api.artifact.MultipleArtifact
|
||||
import org.gradle.api.file.ConfigurableFileCollection
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.InputFiles
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.OutputDirectory
|
||||
import org.gradle.api.tasks.PathSensitive
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.security.KeyStore
|
||||
import java.io.File
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
@@ -12,21 +24,111 @@ plugins {
|
||||
id("com.google.devtools.ksp") version "2.2.20-2.0.3"
|
||||
}
|
||||
|
||||
abstract class GenerateDexIntegrityAsset : org.gradle.api.DefaultTask() {
|
||||
@get:InputFiles
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val dexFiles: ConfigurableFileCollection
|
||||
|
||||
@get:Input
|
||||
abstract val variantName: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val pinnedSha256: Property<String>
|
||||
|
||||
@get:org.gradle.api.tasks.InputDirectory
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val r8DexDir: DirectoryProperty
|
||||
|
||||
@get:org.gradle.api.tasks.InputDirectory
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val assetsDir: DirectoryProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val outputDir: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
fun writeIntegrityAsset() {
|
||||
val pinned = pinnedSha256.orNull?.trim().orEmpty()
|
||||
if (pinned.isNotBlank()) {
|
||||
val assetDir = outputDir.get().asFile.apply { mkdirs() }
|
||||
assetDir.resolve("ps_integrity.bin").writeText(pinned.lowercase())
|
||||
return
|
||||
}
|
||||
|
||||
fun mapToApkEntry(file: java.io.File): String {
|
||||
val segments = file.invariantSeparatorsPath.split("/")
|
||||
val idx = segments.indexOfLast { it == "assets" }
|
||||
return if (idx != -1 && idx + 1 < segments.size) {
|
||||
("assets/" + segments.drop(idx + 1).joinToString("/"))
|
||||
} else {
|
||||
file.name
|
||||
}
|
||||
}
|
||||
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val allFiles = mutableListOf<java.io.File>()
|
||||
allFiles += r8DexDir.asFileTree.files.filter { it.extension == "dex" }
|
||||
allFiles += assetsDir.get().asFile.walkTopDown().filter { it.isFile && it.extension == "dex" }.toList()
|
||||
|
||||
allFiles
|
||||
.map { mapToApkEntry(it) to it }
|
||||
.sortedBy { it.first }
|
||||
.forEach { (_, file) ->
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read <= 0) break
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
val expectedHash = digest.digest().joinToString("") { "%02x".format(it) }
|
||||
val assetDir = outputDir.get().asFile.apply { mkdirs() }
|
||||
assetDir.resolve("ps_integrity.bin").writeText(expectedHash)
|
||||
}
|
||||
}
|
||||
|
||||
fun computeKeystoreCertSha256(storeFile: File, storePass: String, keyAlias: String, keyPass: String = storePass): String? {
|
||||
if (!storeFile.exists()) return null
|
||||
return runCatching {
|
||||
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||
storeFile.inputStream().use { ks.load(it, storePass.toCharArray()) }
|
||||
val cert = ks.getCertificate(keyAlias) ?: return null
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
digest.digest(cert.encoded).joinToString("") { "%02x".format(it) }
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = rootProject.ext["applicationId"].toString()
|
||||
compileSdk = 36
|
||||
ndkVersion = "28.2.13676358"
|
||||
buildFeatures {
|
||||
aidl = true
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
val autoCertSha = providers.provider {
|
||||
computeKeystoreCertSha256(
|
||||
File(System.getProperty("user.home"), ".android/debug.keystore"),
|
||||
storePass = "android",
|
||||
keyAlias = "androiddebugkey"
|
||||
).orEmpty()
|
||||
}
|
||||
val expectedCertSha256 = providers.gradleProperty("EXPECTED_CERT_SHA256")
|
||||
.orElse(providers.gradleProperty("psExpectedCertSha256"))
|
||||
.orElse(autoCertSha)
|
||||
.orElse("")
|
||||
applicationId = rootProject.ext["applicationId"].toString()
|
||||
versionCode = rootProject.ext["appVersionCode"].toString().toInt()
|
||||
versionName = rootProject.ext["appVersionName"].toString()
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
multiDexEnabled = true
|
||||
buildConfigField("String", "EXPECTED_CERT_SHA256", "\"${expectedCertSha256.get()}\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -100,18 +202,28 @@ androidComponents {
|
||||
variantOutput.outputFileName.set(
|
||||
when {
|
||||
variant.name.startsWith("core") -> "core.apk"
|
||||
else -> "snapenhance_${rootProject.ext["appVersionName"]}-${variant.name}.apk"
|
||||
else -> "purrfectsnap_${rootProject.ext["appVersionName"]}-${variant.name}.apk"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val integrityTaskName = "generate${variant.name.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }}DexIntegrity"
|
||||
val capitalizedVariant = variant.name.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
val dexOutput = layout.buildDirectory.dir("intermediates/dex/${variant.name}")
|
||||
val integrityTask = tasks.register(integrityTaskName, GenerateDexIntegrityAsset::class.java) {
|
||||
tasks.findByName("minify${capitalizedVariant}WithR8")?.let { dependsOn(it) }
|
||||
dexFiles.from(
|
||||
fileTree(dexOutput) { include("**/*.dex") }
|
||||
)
|
||||
variantName.set(variant.name)
|
||||
r8DexDir.set(layout.buildDirectory.dir("intermediates/dex/${variant.name}/minify${capitalizedVariant}WithR8"))
|
||||
assetsDir.set(layout.projectDirectory.dir("src/main/assets"))
|
||||
outputDir.set(layout.buildDirectory.dir("generated/integrity/${variant.name}/assets"))
|
||||
pinnedSha256.set(providers.gradleProperty("psIntegrityPinnedSha256").orElse(""))
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(integrityTask, GenerateDexIntegrityAsset::outputDir)
|
||||
}
|
||||
|
||||
onVariants(selector().withFlavor("abi", "core")) {
|
||||
it.packaging.jniLibs.apply {
|
||||
pickFirsts.set(listOf("**/lib${rootProject.ext["buildHash"]}.so"))
|
||||
excludes.set(listOf("**/*.so"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -124,7 +236,9 @@ dependencies {
|
||||
|
||||
implementation(project(":core"))
|
||||
implementation(project(":common"))
|
||||
implementation(project(":native"))
|
||||
implementation(libs.androidx.documentfile)
|
||||
implementation("androidx.browser:browser:1.8.0")
|
||||
implementation(libs.gson)
|
||||
implementation(libs.smart.exception.java)
|
||||
implementation(files("libs/ffmpeg-kit-full-gpl-6.0-2.LTS.aar"))
|
||||
@@ -149,6 +263,16 @@ dependencies {
|
||||
implementation("com.google.android.material:material:1.13.0")
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
// Patching pipeline dependencies
|
||||
implementation(files("libs/apkzlib.jar"))
|
||||
implementation(files("libs/ManifestEditor-1.0.2.jar"))
|
||||
implementation(libs.apksig)
|
||||
implementation(libs.dexlib2)
|
||||
implementation(libs.jsoup)
|
||||
implementation("com.google.auto.value:auto-value-annotations:1.10.4")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
|
||||
implementation("org.json:json:20240303")
|
||||
implementation(libs.fetch) {
|
||||
exclude(group = "androidx.room", module = "room-runtime")
|
||||
}
|
||||
|
||||
6
app/proguard-rules.pro
vendored
6
app/proguard-rules.pro
vendored
@@ -1,3 +1,4 @@
|
||||
|
||||
-dontwarn de.robv.android.xposed.**
|
||||
-dontwarn org.mozilla.javascript.**
|
||||
|
||||
@@ -12,7 +13,7 @@
|
||||
-keep class androidx.compose.material3.R$* { *; }
|
||||
-keep class androidx.compose.ui.R$* { *; }
|
||||
-keep class androidx.navigation.** { *; }
|
||||
-keep class me.rhunk.snapenhance.** { *; }
|
||||
-keep class me.eternal.purrfectsnap.** { *; }
|
||||
-keep class androidx.core.content.res.ResourcesCompat { *; }
|
||||
|
||||
-keepclassmembers class * implements android.os.Parcelable {
|
||||
@@ -21,4 +22,5 @@
|
||||
# Prevent WorkManager from stripping generated Room database constructor
|
||||
-keep class androidx.work.impl.WorkDatabase_Impl { *; }
|
||||
|
||||
|
||||
-keep class android.support.annotation.** { *; }
|
||||
-dontwarn android.support.annotation.**
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
android:allowBackup="true"
|
||||
android:hasFragileUserData="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/launcher_icon">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round">
|
||||
<meta-data
|
||||
android:name="xposedmodule"
|
||||
android:value="true" />
|
||||
@@ -52,21 +53,20 @@
|
||||
<activity
|
||||
android:name=".ui.setup.SetupActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:excludeFromRecents="true" />
|
||||
android:exported="false"
|
||||
android:theme="@style/AppTheme" />
|
||||
<activity android:name=".bridge.ForceStartActivity"
|
||||
android:theme="@android:style/Theme.NoDisplay"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true" />
|
||||
android:exported="false" />
|
||||
<activity android:name=".bridge.BiometricPromptActivity"
|
||||
android:theme="@style/BiometricPromptTheme"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true" />
|
||||
android:exported="false" />
|
||||
<receiver android:name=".StreaksReminder" />
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="me.rhunk.snapenhance.fileprovider"
|
||||
android:authorities="me.eternal.purrfectsnap.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
|
||||
BIN
app/src/main/assets/lspatch/dexes/loader.dex
Normal file
BIN
app/src/main/assets/lspatch/dexes/loader.dex
Normal file
Binary file not shown.
BIN
app/src/main/assets/lspatch/dexes/metaloader.dex
Normal file
BIN
app/src/main/assets/lspatch/dexes/metaloader.dex
Normal file
Binary file not shown.
BIN
app/src/main/assets/lspatch/so/arm64-v8a/liblspatch.so
Normal file
BIN
app/src/main/assets/lspatch/so/arm64-v8a/liblspatch.so
Normal file
Binary file not shown.
BIN
app/src/main/assets/lspatch/so/armeabi-v7a/liblspatch.so
Normal file
BIN
app/src/main/assets/lspatch/so/armeabi-v7a/liblspatch.so
Normal file
Binary file not shown.
1
app/src/main/assets/lspatch/version.txt
Normal file
1
app/src/main/assets/lspatch/version.txt
Normal file
@@ -0,0 +1 @@
|
||||
0.7
|
||||
@@ -1,11 +1,11 @@
|
||||
package me.rhunk.snapenhance
|
||||
package me.eternal.purrfectsnap
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.GsonBuilder
|
||||
import me.rhunk.snapenhance.common.data.FileType
|
||||
import me.rhunk.snapenhance.common.logger.AbstractLogger
|
||||
import me.rhunk.snapenhance.common.logger.LogChannel
|
||||
import me.rhunk.snapenhance.common.logger.LogLevel
|
||||
import me.eternal.purrfectsnap.common.data.FileType
|
||||
import me.eternal.purrfectsnap.common.logger.AbstractLogger
|
||||
import me.eternal.purrfectsnap.common.logger.LogChannel
|
||||
import me.eternal.purrfectsnap.common.logger.LogLevel
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
import java.io.RandomAccessFile
|
||||
@@ -159,7 +159,8 @@ class LogManager(
|
||||
fun internalLog(tag: String, logLevel: LogLevel, message: Any?) {
|
||||
synchronized(printLogLock) {
|
||||
runCatching {
|
||||
val anonymizedMessage = message.toString().let {
|
||||
val originalMessage = message.toString()
|
||||
val anonymizedMessage = originalMessage.let {
|
||||
if (remoteSideContext.config.isInitialized() && anonymizeLogs)
|
||||
it.replace(uuidRegex, "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")
|
||||
.replace(contentUriRegex, "content://xxx")
|
||||
@@ -190,7 +191,7 @@ class LogManager(
|
||||
|
||||
private fun newLogFile() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
logFile = File(logFolder, "snapenhance_${getCurrentDateTime(pathSafe = true)}.log").also {
|
||||
logFile = File(logFolder, "purrfectsnap_${getCurrentDateTime(pathSafe = true)}.log").also {
|
||||
it.createNewFile()
|
||||
remoteSideContext.sharedPreferences.edit().putString("log_file", it.absolutePath).putLong("last_created", currentTime).apply()
|
||||
}
|
||||
@@ -202,31 +203,33 @@ class LogManager(
|
||||
}
|
||||
|
||||
fun exportLogsToZip(outputStream: OutputStream) {
|
||||
val zipOutputStream = ZipOutputStream(outputStream).apply {
|
||||
setMethod(ZipOutputStream.DEFLATED)
|
||||
}
|
||||
|
||||
// add device info to zip
|
||||
zipOutputStream.putNextEntry(ZipEntry("device_info.json"))
|
||||
val gson = GsonBuilder().setPrettyPrinting().create()
|
||||
zipOutputStream.write(gson.toJson(remoteSideContext.installationSummary).toByteArray())
|
||||
zipOutputStream.closeEntry()
|
||||
|
||||
// add config
|
||||
zipOutputStream.putNextEntry(ZipEntry("config.json"))
|
||||
zipOutputStream.write(remoteSideContext.config.exportToString(exportSensitiveData = false).toByteArray())
|
||||
zipOutputStream.closeEntry()
|
||||
|
||||
//add logFolder to zip
|
||||
logFolder.walk().forEach {
|
||||
if (it.isFile) {
|
||||
zipOutputStream.putNextEntry(ZipEntry(it.name))
|
||||
it.inputStream().copyTo(zipOutputStream)
|
||||
ZipOutputStream(outputStream).use { zipOutputStream ->
|
||||
fun putEntry(fileName: String, writer: ZipOutputStream.() -> Unit) {
|
||||
zipOutputStream.putNextEntry(ZipEntry(fileName))
|
||||
zipOutputStream.writer()
|
||||
zipOutputStream.closeEntry()
|
||||
}
|
||||
}
|
||||
|
||||
zipOutputStream.close()
|
||||
// add device info to zip
|
||||
putEntry("device_info.json") {
|
||||
val gson = GsonBuilder().setPrettyPrinting().create()
|
||||
write(gson.toJson(remoteSideContext.installationSummary).toByteArray())
|
||||
}
|
||||
|
||||
// add config
|
||||
putEntry("config.json") {
|
||||
write(remoteSideContext.config.exportToString(exportSensitiveData = false).toByteArray())
|
||||
}
|
||||
|
||||
// add log files to zip
|
||||
logFolder.walk().forEach {
|
||||
if (it.isFile) {
|
||||
putEntry(it.name) {
|
||||
write(it.readBytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun newReader(onAddLine: (LogLine) -> Unit) = LogReader(logFile!!).also {
|
||||
@@ -261,4 +264,4 @@ class LogManager(
|
||||
override fun assert(message: Any?, tag: String) {
|
||||
internalLog(tag, LogLevel.ASSERT, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package me.rhunk.snapenhance
|
||||
package me.eternal.purrfectsnap
|
||||
|
||||
import android.os.ParcelFileDescriptor
|
||||
import me.rhunk.snapenhance.bridge.AccountStorage
|
||||
import me.rhunk.snapenhance.common.util.ktx.toParcelFileDescriptor
|
||||
import me.eternal.purrfectsnap.bridge.AccountStorage
|
||||
import me.eternal.purrfectsnap.common.util.ktx.toParcelFileDescriptor
|
||||
|
||||
class RemoteAccountStorage(
|
||||
private val context: RemoteSideContext
|
||||
@@ -1,13 +1,13 @@
|
||||
package me.rhunk.snapenhance
|
||||
package me.eternal.purrfectsnap
|
||||
|
||||
import android.os.ParcelFileDescriptor
|
||||
import me.rhunk.snapenhance.bridge.storage.FileHandle
|
||||
import me.rhunk.snapenhance.bridge.storage.FileHandleManager
|
||||
import me.rhunk.snapenhance.common.bridge.FileHandleScope
|
||||
import me.rhunk.snapenhance.common.bridge.InternalFileHandleType
|
||||
import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
|
||||
import me.rhunk.snapenhance.common.logger.AbstractLogger
|
||||
import me.rhunk.snapenhance.common.util.ktx.toParcelFileDescriptor
|
||||
import me.eternal.purrfectsnap.bridge.storage.FileHandle
|
||||
import me.eternal.purrfectsnap.bridge.storage.FileHandleManager
|
||||
import me.eternal.purrfectsnap.common.bridge.FileHandleScope
|
||||
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.common.logger.AbstractLogger
|
||||
import me.eternal.purrfectsnap.common.util.ktx.toParcelFileDescriptor
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
|
||||
@@ -146,4 +146,3 @@ class RemoteFileHandleManager(
|
||||
}.isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package me.rhunk.snapenhance
|
||||
package me.eternal.purrfectsnap
|
||||
|
||||
import me.rhunk.snapenhance.bridge.location.FriendLocation
|
||||
import me.rhunk.snapenhance.bridge.location.LocationManager
|
||||
import me.eternal.purrfectsnap.bridge.location.FriendLocation
|
||||
import me.eternal.purrfectsnap.bridge.location.LocationManager
|
||||
|
||||
class RemoteLocationManager(
|
||||
private val remoteSideContext: RemoteSideContext
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance
|
||||
package me.eternal.purrfectsnap
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
@@ -21,29 +21,30 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.rhunk.snapenhance.bridge.BridgeService
|
||||
import me.rhunk.snapenhance.common.BuildConfig
|
||||
import me.rhunk.snapenhance.common.Constants
|
||||
import me.rhunk.snapenhance.common.action.EnumAction
|
||||
import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
|
||||
import me.rhunk.snapenhance.common.bridge.wrapper.LoggerWrapper
|
||||
import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper
|
||||
import me.rhunk.snapenhance.common.config.ModConfig
|
||||
import me.rhunk.snapenhance.common.logger.fatalCrash
|
||||
import me.rhunk.snapenhance.common.util.constantLazyBridge
|
||||
import me.rhunk.snapenhance.common.util.getPurgeTime
|
||||
import me.rhunk.snapenhance.e2ee.E2EEImplementation
|
||||
import me.rhunk.snapenhance.scripting.RemoteScriptManager
|
||||
import me.rhunk.snapenhance.storage.AppDatabase
|
||||
import me.rhunk.snapenhance.task.TaskManager
|
||||
import me.rhunk.snapenhance.ui.manager.MainActivity
|
||||
import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
|
||||
import me.rhunk.snapenhance.ui.manager.data.ModInfo
|
||||
import me.rhunk.snapenhance.ui.manager.data.PlatformInfo
|
||||
import me.rhunk.snapenhance.ui.manager.data.SnapchatAppInfo
|
||||
import me.rhunk.snapenhance.ui.overlay.RemoteOverlay
|
||||
import me.rhunk.snapenhance.ui.setup.Requirements
|
||||
import me.rhunk.snapenhance.ui.setup.SetupActivity
|
||||
import me.eternal.purrfectsnap.bridge.BridgeService
|
||||
import me.eternal.purrfectsnap.common.BuildConfig
|
||||
import me.eternal.purrfectsnap.common.Constants
|
||||
import me.eternal.purrfectsnap.common.action.EnumAction
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerWrapper
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.MappingsWrapper
|
||||
import me.eternal.purrfectsnap.common.config.ModConfig
|
||||
import me.eternal.purrfectsnap.common.logger.fatalCrash
|
||||
import me.eternal.purrfectsnap.common.util.constantLazyBridge
|
||||
import me.eternal.purrfectsnap.common.util.getPurgeTime
|
||||
import me.eternal.purrfectsnap.e2ee.E2EEImplementation
|
||||
import me.eternal.purrfectsnap.scripting.RemoteScriptManager
|
||||
import me.eternal.purrfectsnap.storage.AppDatabase
|
||||
import me.eternal.purrfectsnap.task.RemoteTaskInterface
|
||||
import me.eternal.purrfectsnap.task.TaskManager
|
||||
import me.eternal.purrfectsnap.ui.manager.MainActivity
|
||||
import me.eternal.purrfectsnap.ui.manager.data.InstallationSummary
|
||||
import me.eternal.purrfectsnap.ui.manager.data.ModInfo
|
||||
import me.eternal.purrfectsnap.ui.manager.data.PlatformInfo
|
||||
import me.eternal.purrfectsnap.ui.manager.data.SnapchatAppInfo
|
||||
import me.eternal.purrfectsnap.ui.overlay.RemoteOverlay
|
||||
import me.eternal.purrfectsnap.ui.setup.Requirements
|
||||
import me.eternal.purrfectsnap.ui.setup.SetupActivity
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.lang.ref.WeakReference
|
||||
import java.security.cert.CertificateFactory
|
||||
@@ -73,11 +74,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 trackerDataManager = me.eternal.purrfectsnap.storage.TrackerDataManagerImpl(database)
|
||||
val config = ModConfig(androidContext, constantLazyBridge { fileHandleManager })
|
||||
val translation = LocaleWrapper(androidContext, constantLazyBridge { fileHandleManager })
|
||||
val mappings = MappingsWrapper(constantLazyBridge { fileHandleManager })
|
||||
val taskManager = TaskManager(this)
|
||||
val taskInterface = RemoteTaskInterface(this)
|
||||
val streaksReminder = StreaksReminder(this)
|
||||
val log = LogManager(this)
|
||||
val scriptManager = RemoteScriptManager(this)
|
||||
@@ -88,6 +90,13 @@ class RemoteSideContext(
|
||||
val accountStorage = RemoteAccountStorage(this)
|
||||
val locationManager = RemoteLocationManager(this)
|
||||
|
||||
init {
|
||||
val prefs = androidContext.getSharedPreferences("prefs", 0)
|
||||
if (!prefs.contains("debug_test_mode")) {
|
||||
prefs.edit().putBoolean("debug_test_mode", true).apply()
|
||||
}
|
||||
}
|
||||
|
||||
//used to load bitmoji selfies and download previews
|
||||
val imageLoader by lazy {
|
||||
ImageLoader.Builder(androidContext)
|
||||
@@ -147,7 +156,7 @@ class RemoteSideContext(
|
||||
}
|
||||
|
||||
scriptManager.runtime.eachModule {
|
||||
callFunction("module.onSnapEnhanceLoad", androidContext)
|
||||
callFunction("module.onPurrfectSnapLoad", androidContext)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,12 +214,20 @@ class RemoteSideContext(
|
||||
|
||||
fun checkForRequirements(overrideRequirements: Int? = null): Boolean {
|
||||
var requirements = overrideRequirements ?: 0
|
||||
if (sharedPreferences.getBoolean("setup_in_progress", false)) {
|
||||
requirements = requirements or Requirements.FIRST_RUN
|
||||
}
|
||||
if (!config.wasPresent) {
|
||||
requirements = requirements or Requirements.FIRST_RUN
|
||||
}
|
||||
|
||||
config.root.downloader.saveFolder.get().let {
|
||||
if (it.isEmpty() || run {
|
||||
val allowDefaultSaveFolder = sharedPreferences.getBoolean("downloader_use_default_save_folder", false)
|
||||
if (it.isEmpty()) {
|
||||
if (!allowDefaultSaveFolder) {
|
||||
requirements = requirements or Requirements.SAVE_FOLDER
|
||||
}
|
||||
} else if (run {
|
||||
val documentFile = runCatching { DocumentFile.fromTreeUri(androidContext, Uri.parse(it)) }.getOrNull()
|
||||
documentFile == null || !documentFile.exists() || !documentFile.canWrite()
|
||||
}) {
|
||||
@@ -248,4 +265,3 @@ class RemoteSideContext(
|
||||
androidContext.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package me.rhunk.snapenhance
|
||||
package me.eternal.purrfectsnap
|
||||
|
||||
import me.rhunk.snapenhance.bridge.logger.TrackerInterface
|
||||
import me.rhunk.snapenhance.common.data.ScopedTrackerRule
|
||||
import me.rhunk.snapenhance.common.data.TrackerEventsResult
|
||||
import me.rhunk.snapenhance.common.data.TrackerRule
|
||||
import me.rhunk.snapenhance.common.data.TrackerRuleEvent
|
||||
import me.rhunk.snapenhance.common.util.toSerialized
|
||||
import me.rhunk.snapenhance.storage.getRuleTrackerScopes
|
||||
import me.rhunk.snapenhance.storage.getTrackerEvents
|
||||
import me.rhunk.snapenhance.storage.updateFriendScore
|
||||
import me.eternal.purrfectsnap.bridge.logger.TrackerInterface
|
||||
import me.eternal.purrfectsnap.common.data.ScopedTrackerRule
|
||||
import me.eternal.purrfectsnap.common.data.TrackerEventsResult
|
||||
import me.eternal.purrfectsnap.common.data.TrackerRule
|
||||
import me.eternal.purrfectsnap.common.data.TrackerRuleEvent
|
||||
import me.eternal.purrfectsnap.common.util.toSerialized
|
||||
import me.eternal.purrfectsnap.storage.getRuleTrackerScopes
|
||||
import me.eternal.purrfectsnap.storage.getTrackerEvents
|
||||
import me.eternal.purrfectsnap.storage.updateFriendScore
|
||||
|
||||
|
||||
class RemoteTracker(
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance
|
||||
package me.eternal.purrfectsnap
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance
|
||||
package me.eternal.purrfectsnap
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.NotificationChannel
|
||||
@@ -10,11 +10,11 @@ import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import kotlinx.coroutines.launch
|
||||
import me.rhunk.snapenhance.bridge.ForceStartActivity
|
||||
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
|
||||
import me.rhunk.snapenhance.storage.getFriendStreaks
|
||||
import me.rhunk.snapenhance.storage.getFriends
|
||||
import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper
|
||||
import me.eternal.purrfectsnap.bridge.ForceStartActivity
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.storage.getFriendStreaks
|
||||
import me.eternal.purrfectsnap.storage.getFriends
|
||||
import me.eternal.purrfectsnap.ui.util.coil.ImageRequestHelper
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
@@ -1,11 +1,11 @@
|
||||
package me.rhunk.snapenhance.action
|
||||
package me.eternal.purrfectsnap.action
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.PersonSearch
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
|
||||
enum class EnumQuickActions(
|
||||
val key: String,
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.bridge
|
||||
package me.eternal.purrfectsnap.bridge
|
||||
|
||||
import android.content.Intent
|
||||
import android.hardware.biometrics.BiometricManager
|
||||
@@ -8,7 +8,7 @@ import android.os.Bundle
|
||||
import android.os.CancellationSignal
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import me.rhunk.snapenhance.SharedContextHolder
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class BiometricPromptActivity: ComponentActivity() {
|
||||
@@ -1,24 +1,24 @@
|
||||
package me.rhunk.snapenhance.bridge
|
||||
package me.eternal.purrfectsnap.bridge
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.ParcelFileDescriptor
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.SharedContextHolder
|
||||
import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge
|
||||
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
|
||||
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
|
||||
import me.rhunk.snapenhance.common.data.SocialScope
|
||||
import me.rhunk.snapenhance.common.logger.LogLevel
|
||||
import me.rhunk.snapenhance.common.ui.OverlayType
|
||||
import me.rhunk.snapenhance.common.util.toParcelable
|
||||
import me.rhunk.snapenhance.download.DownloadProcessor
|
||||
import me.rhunk.snapenhance.download.FFMpegProcessor
|
||||
import me.rhunk.snapenhance.storage.*
|
||||
import me.rhunk.snapenhance.task.Task
|
||||
import me.rhunk.snapenhance.task.TaskType
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
import me.eternal.purrfectsnap.bridge.snapclient.MessagingBridge
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.data.SocialScope
|
||||
import me.eternal.purrfectsnap.common.logger.LogLevel
|
||||
import me.eternal.purrfectsnap.common.ui.OverlayType
|
||||
import me.eternal.purrfectsnap.common.util.toParcelable
|
||||
import me.eternal.purrfectsnap.download.DownloadProcessor
|
||||
import me.eternal.purrfectsnap.download.FFMpegProcessor
|
||||
import me.eternal.purrfectsnap.storage.*
|
||||
import me.eternal.purrfectsnap.task.Task
|
||||
import me.eternal.purrfectsnap.task.TaskType
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlin.system.measureTimeMillis
|
||||
@@ -213,6 +213,7 @@ class BridgeService : Service() {
|
||||
override fun getAccountStorage() = remoteSideContext.accountStorage
|
||||
override fun getFileHandleManager() = remoteSideContext.fileHandleManager
|
||||
override fun getLocationManager() = remoteSideContext.locationManager
|
||||
override fun getTaskInterface() = remoteSideContext.taskInterface
|
||||
|
||||
override fun registerMessagingBridge(bridge: MessagingBridge) {
|
||||
messagingBridge = bridge
|
||||
@@ -249,4 +250,3 @@ class BridgeService : Service() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package me.rhunk.snapenhance.bridge
|
||||
package me.eternal.purrfectsnap.bridge
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import me.rhunk.snapenhance.SharedContextHolder
|
||||
import me.rhunk.snapenhance.common.Constants
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
import me.eternal.purrfectsnap.common.Constants
|
||||
|
||||
class ForceStartActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -1,9 +1,13 @@
|
||||
package me.rhunk.snapenhance.download
|
||||
package me.eternal.purrfectsnap.download
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.widget.Toast
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.google.gson.GsonBuilder
|
||||
@@ -12,25 +16,26 @@ import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.bridge.DownloadCallback
|
||||
import me.rhunk.snapenhance.common.Constants
|
||||
import me.rhunk.snapenhance.common.ReceiversConfig
|
||||
import me.rhunk.snapenhance.common.data.FileType
|
||||
import me.rhunk.snapenhance.common.data.download.DownloadMediaType
|
||||
import me.rhunk.snapenhance.common.data.download.DownloadMetadata
|
||||
import me.rhunk.snapenhance.common.data.download.DownloadRequest
|
||||
import me.rhunk.snapenhance.common.data.download.InputMedia
|
||||
import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType
|
||||
import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
|
||||
import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver
|
||||
import me.rhunk.snapenhance.core.features.impl.downloader.decoder.AttachmentType
|
||||
import me.rhunk.snapenhance.task.PendingTask
|
||||
import me.rhunk.snapenhance.task.PendingTaskListener
|
||||
import me.rhunk.snapenhance.task.Task
|
||||
import me.rhunk.snapenhance.task.TaskStatus
|
||||
import me.rhunk.snapenhance.task.TaskType
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.bridge.DownloadCallback
|
||||
import me.eternal.purrfectsnap.common.Constants
|
||||
import me.eternal.purrfectsnap.common.ReceiversConfig
|
||||
import me.eternal.purrfectsnap.common.data.FileType
|
||||
import me.eternal.purrfectsnap.common.data.download.DownloadMediaType
|
||||
import me.eternal.purrfectsnap.common.data.download.DownloadMetadata
|
||||
import me.eternal.purrfectsnap.common.data.download.DownloadRequest
|
||||
import me.eternal.purrfectsnap.common.data.download.InputMedia
|
||||
import me.eternal.purrfectsnap.common.data.download.SplitMediaAssetType
|
||||
import me.eternal.purrfectsnap.common.util.snap.MediaDownloaderHelper
|
||||
import me.eternal.purrfectsnap.common.util.snap.RemoteMediaResolver
|
||||
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.AttachmentType
|
||||
import me.eternal.purrfectsnap.task.PendingTask
|
||||
import me.eternal.purrfectsnap.task.PendingTaskListener
|
||||
import me.eternal.purrfectsnap.task.Task
|
||||
import me.eternal.purrfectsnap.task.TaskStatus
|
||||
import me.eternal.purrfectsnap.task.TaskType
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
@@ -114,8 +119,17 @@ class DownloadProcessor (
|
||||
}
|
||||
|
||||
val fileName = metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension
|
||||
val configuredFolder = remoteSideContext.config.root.downloader.saveFolder.get().orEmpty().trim()
|
||||
if (configuredFolder.isBlank()) {
|
||||
val outputUri = saveToSystemDefault(fileName, fileType, inputFile, metadata)
|
||||
?: throw Exception("Failed to save media (no output uri)")
|
||||
pendingTask.task.extra = outputUri.toString()
|
||||
pendingTask.success()
|
||||
callbackOnSuccess(fileName)
|
||||
return
|
||||
}
|
||||
|
||||
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(remoteSideContext.config.root.downloader.saveFolder.get()))
|
||||
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
|
||||
?: throw Exception("Failed to open output folder")
|
||||
|
||||
val outputFileFolder = metadata.outputPath.let {
|
||||
@@ -191,6 +205,60 @@ class DownloadProcessor (
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToSystemDefault(
|
||||
fileName: String,
|
||||
fileType: FileType,
|
||||
inputFile: File,
|
||||
metadata: DownloadMetadata,
|
||||
): Uri? {
|
||||
val subPath = metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "")
|
||||
.replace("\\", "/")
|
||||
.trim('/')
|
||||
val baseRelative = when {
|
||||
fileType.isImage -> Environment.DIRECTORY_PICTURES
|
||||
fileType.isVideo -> Environment.DIRECTORY_MOVIES
|
||||
else -> Environment.DIRECTORY_DOWNLOADS
|
||||
}
|
||||
val relativePath = listOfNotNull(baseRelative, "PurrfectSnap", subPath.takeIf { it.isNotBlank() })
|
||||
.joinToString("/") + "/"
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val collection = when {
|
||||
fileType.isImage -> MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
fileType.isVideo -> MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
else -> MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
}
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType)
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
||||
}
|
||||
val resolver = remoteSideContext.androidContext.contentResolver
|
||||
val uri = resolver.insert(collection, values) ?: return null
|
||||
resolver.openOutputStream(uri)?.use { out ->
|
||||
inputFile.inputStream().use { it.copyTo(out) }
|
||||
} ?: return null
|
||||
uri
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val baseDir = Environment.getExternalStoragePublicDirectory(baseRelative)
|
||||
val destDir = File(baseDir, "PurrfectSnap" + (if (subPath.isNotBlank()) "/$subPath" else ""))
|
||||
destDir.mkdirs()
|
||||
val destFile = File(destDir, fileName)
|
||||
FileOutputStream(destFile).use { out ->
|
||||
inputFile.inputStream().use { it.copyTo(out) }
|
||||
}
|
||||
runCatching {
|
||||
remoteSideContext.androidContext.sendBroadcast(
|
||||
Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply {
|
||||
data = Uri.fromFile(destFile)
|
||||
}
|
||||
)
|
||||
}
|
||||
Uri.fromFile(destFile)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMediaTempFile(): File {
|
||||
return File.createTempFile("media", ".tmp")
|
||||
}
|
||||
@@ -486,4 +554,4 @@ class DownloadProcessor (
|
||||
|
||||
enqueue(downloadRequest, downloadMetadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.download
|
||||
package me.eternal.purrfectsnap.download
|
||||
|
||||
import android.media.AudioFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
@@ -7,12 +7,12 @@ import com.arthenica.ffmpegkit.FFmpegSession
|
||||
import com.arthenica.ffmpegkit.Level
|
||||
import com.arthenica.ffmpegkit.Statistics
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import me.rhunk.snapenhance.LogManager
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.common.config.impl.DownloaderConfig
|
||||
import me.rhunk.snapenhance.common.data.download.AudioStreamFormat
|
||||
import me.rhunk.snapenhance.common.logger.LogLevel
|
||||
import me.rhunk.snapenhance.task.PendingTask
|
||||
import me.eternal.purrfectsnap.LogManager
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.common.config.impl.DownloaderConfig
|
||||
import me.eternal.purrfectsnap.common.data.download.AudioStreamFormat
|
||||
import me.eternal.purrfectsnap.common.logger.LogLevel
|
||||
import me.eternal.purrfectsnap.task.PendingTask
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
package me.rhunk.snapenhance.e2ee
|
||||
package me.eternal.purrfectsnap.e2ee
|
||||
|
||||
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.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 me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.bridge.e2ee.E2eeInterface
|
||||
import me.eternal.purrfectsnap.bridge.e2ee.EncryptionResult
|
||||
import me.eternal.purrfectsnap.core.util.EvictingMap
|
||||
import org.bouncycastle.pqc.crypto.crystals.kyber.*
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.scripting
|
||||
package me.eternal.purrfectsnap.scripting
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
@@ -1,25 +1,25 @@
|
||||
package me.rhunk.snapenhance.scripting
|
||||
package me.eternal.purrfectsnap.scripting
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.bridge.scripting.AutoReloadListener
|
||||
import me.rhunk.snapenhance.bridge.scripting.IPCListener
|
||||
import me.rhunk.snapenhance.bridge.scripting.IScripting
|
||||
import me.rhunk.snapenhance.common.scripting.ScriptRuntime
|
||||
import me.rhunk.snapenhance.common.scripting.bindings.BindingSide
|
||||
import me.rhunk.snapenhance.common.scripting.impl.ConfigInterface
|
||||
import me.rhunk.snapenhance.common.scripting.impl.ConfigTransactionType
|
||||
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
|
||||
import me.rhunk.snapenhance.common.scripting.type.readModuleInfo
|
||||
import me.rhunk.snapenhance.common.util.ktx.await
|
||||
import me.rhunk.snapenhance.common.util.ktx.toParcelFileDescriptor
|
||||
import me.rhunk.snapenhance.scripting.impl.IPCListeners
|
||||
import me.rhunk.snapenhance.scripting.impl.ManagerIPC
|
||||
import me.rhunk.snapenhance.scripting.impl.ManagerScriptConfig
|
||||
import me.rhunk.snapenhance.storage.isScriptEnabled
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.bridge.scripting.AutoReloadListener
|
||||
import me.eternal.purrfectsnap.bridge.scripting.IPCListener
|
||||
import me.eternal.purrfectsnap.bridge.scripting.IScripting
|
||||
import me.eternal.purrfectsnap.common.scripting.ScriptRuntime
|
||||
import me.eternal.purrfectsnap.common.scripting.bindings.BindingSide
|
||||
import me.eternal.purrfectsnap.common.scripting.impl.ConfigInterface
|
||||
import me.eternal.purrfectsnap.common.scripting.impl.ConfigTransactionType
|
||||
import me.eternal.purrfectsnap.common.scripting.type.ModuleInfo
|
||||
import me.eternal.purrfectsnap.common.scripting.type.readModuleInfo
|
||||
import me.eternal.purrfectsnap.common.util.ktx.await
|
||||
import me.eternal.purrfectsnap.common.util.ktx.toParcelFileDescriptor
|
||||
import me.eternal.purrfectsnap.scripting.impl.IPCListeners
|
||||
import me.eternal.purrfectsnap.scripting.impl.ManagerIPC
|
||||
import me.eternal.purrfectsnap.scripting.impl.ManagerScriptConfig
|
||||
import me.eternal.purrfectsnap.storage.isScriptEnabled
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
@@ -258,4 +258,4 @@ class RemoteScriptManager(
|
||||
override fun registerAutoReloadListener(listener: AutoReloadListener?) {
|
||||
autoReloadListener = listener
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package me.rhunk.snapenhance.scripting.impl
|
||||
package me.eternal.purrfectsnap.scripting.impl
|
||||
|
||||
import android.os.DeadObjectException
|
||||
import me.rhunk.snapenhance.bridge.scripting.IPCListener
|
||||
import me.rhunk.snapenhance.common.scripting.impl.IPCInterface
|
||||
import me.rhunk.snapenhance.common.scripting.impl.Listener
|
||||
import me.eternal.purrfectsnap.bridge.scripting.IPCListener
|
||||
import me.eternal.purrfectsnap.common.scripting.impl.IPCInterface
|
||||
import me.eternal.purrfectsnap.common.scripting.impl.Listener
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
typealias IPCListeners = ConcurrentHashMap<String, MutableMap<String, MutableSet<IPCListener>>> // channel, eventName -> listeners
|
||||
@@ -1,8 +1,8 @@
|
||||
package me.rhunk.snapenhance.scripting.impl
|
||||
package me.eternal.purrfectsnap.scripting.impl
|
||||
|
||||
import com.google.gson.JsonObject
|
||||
import me.rhunk.snapenhance.common.scripting.impl.ConfigInterface
|
||||
import me.rhunk.snapenhance.scripting.RemoteScriptManager
|
||||
import me.eternal.purrfectsnap.common.scripting.impl.ConfigInterface
|
||||
import me.eternal.purrfectsnap.scripting.RemoteScriptManager
|
||||
import java.io.File
|
||||
|
||||
class ManagerScriptConfig(
|
||||
@@ -0,0 +1,10 @@
|
||||
package me.eternal.purrfectsnap.security
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object AppIntegrityVerifier {
|
||||
fun enforce(context: Context) {
|
||||
// Intentionally disabled: do not perform APK signature/hash enforcement.
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package me.eternal.purrfectsnap.security
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
object KeystoreAesGcm {
|
||||
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
private const val AES_MODE = "AES/GCM/NoPadding"
|
||||
private const val GCM_TAG_BITS = 128
|
||||
private const val IV_LEN = 12
|
||||
|
||||
fun encrypt(alias: String, plaintext: String): String {
|
||||
val key = getOrCreateKey(alias)
|
||||
val cipher = Cipher.getInstance(AES_MODE)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
val iv = cipher.iv
|
||||
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||
val out = ByteArray(iv.size + ciphertext.size)
|
||||
System.arraycopy(iv, 0, out, 0, iv.size)
|
||||
System.arraycopy(ciphertext, 0, out, iv.size, ciphertext.size)
|
||||
return Base64.encodeToString(out, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun decrypt(alias: String, encoded: String): String? {
|
||||
val raw = runCatching { Base64.decode(encoded, Base64.NO_WRAP) }.getOrNull() ?: return null
|
||||
if (raw.size <= IV_LEN) return null
|
||||
val iv = raw.copyOfRange(0, IV_LEN)
|
||||
val ciphertext = raw.copyOfRange(IV_LEN, raw.size)
|
||||
val key = getOrCreateKey(alias)
|
||||
val cipher = Cipher.getInstance(AES_MODE)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv))
|
||||
return runCatching { cipher.doFinal(ciphertext).toString(Charsets.UTF_8) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun getOrCreateKey(alias: String): SecretKey {
|
||||
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
val existing = (ks.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey
|
||||
if (existing != null) return existing
|
||||
|
||||
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
|
||||
val spec = KeyGenParameterSpec.Builder(
|
||||
alias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
.setKeySize(256)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build()
|
||||
keyGenerator.init(spec)
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package me.eternal.purrfectsnap.security
|
||||
|
||||
import android.os.Process
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
object SecurityAbort {
|
||||
fun failFast(): Nothing {
|
||||
Process.killProcess(Process.myPid())
|
||||
exitProcess(139)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package me.eternal.purrfectsnap.security
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import java.security.MessageDigest
|
||||
|
||||
object SigningCertSha256 {
|
||||
fun get(context: Context): String {
|
||||
val signatures = runCatching {
|
||||
val pm = context.packageManager
|
||||
val pkg = context.packageName
|
||||
val pkgInfo = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
|
||||
pm.getPackageInfo(pkg, PackageManager.GET_SIGNING_CERTIFICATES)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES)
|
||||
}
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
|
||||
pkgInfo.signingInfo?.apkContentsSigners?.map { it.toByteArray() }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pkgInfo.signatures?.map { it.toByteArray() }
|
||||
}
|
||||
}.getOrNull().orEmpty()
|
||||
|
||||
val first = signatures.firstOrNull() ?: return ""
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(first)
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package me.eternal.purrfectsnap.setup.patch
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.jsoup.Jsoup
|
||||
import java.net.UnknownHostException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
data class DownloadItem(
|
||||
val title: String,
|
||||
val releaseDate: String,
|
||||
val downloadPage: String
|
||||
) {
|
||||
val shortTitle: String = title.substringBefore("(").trim()
|
||||
val hash: String = (title + releaseDate + downloadPage).hashCode().absoluteValue.toString(16)
|
||||
val isBeta: Boolean = title.contains("Beta", ignoreCase = true)
|
||||
}
|
||||
|
||||
class APKMirror {
|
||||
val okhttpClient = OkHttpClient.Builder()
|
||||
.callTimeout(1, TimeUnit.HOURS)
|
||||
.connectTimeout(1, TimeUnit.HOURS)
|
||||
.readTimeout(1, TimeUnit.HOURS)
|
||||
.writeTimeout(1, TimeUnit.HOURS)
|
||||
.addInterceptor {
|
||||
it.proceed(
|
||||
it.request().newBuilder()
|
||||
.addHeader("User-Agent", System.getProperty("http.agent") ?: "Mozilla/5.0")
|
||||
.build()
|
||||
)
|
||||
}
|
||||
.build()
|
||||
|
||||
companion object {
|
||||
private const val BASE_URL = "https://www.apkmirror.com"
|
||||
private const val FETCH_BUILD_URL =
|
||||
"$BASE_URL/apk/snap-inc/snapchat/variant-%7B%22arches_slug%22%3A%5B%22arm64-v8a%22%2C%22armeabi-v7a%22%5D%2C%22dpis_slug%22%3A%5B%22nodpi%22%5D%7D/page/{page}/"
|
||||
}
|
||||
|
||||
fun fetchDownloadLink(downloadPageUri: String): String? {
|
||||
try {
|
||||
okhttpClient.newCall(
|
||||
Request.Builder()
|
||||
.url("$BASE_URL$downloadPageUri")
|
||||
.build()
|
||||
).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val bodyString = response.body?.string() ?: return null
|
||||
val finalDownloadPageUri =
|
||||
Jsoup.parse(bodyString).getElementsByClass("downloadButton").first()?.attr("href")
|
||||
?: return null
|
||||
|
||||
okhttpClient.newCall(
|
||||
Request.Builder()
|
||||
.url("$BASE_URL$finalDownloadPageUri")
|
||||
.build()
|
||||
).execute().use { response2 ->
|
||||
if (!response2.isSuccessful) return null
|
||||
val bodyString2 = response2.body?.string() ?: return null
|
||||
val document = Jsoup.parse(bodyString2)
|
||||
val downloadLink = document.getElementById("download-link")?.attr("href") ?: return null
|
||||
return BASE_URL + downloadLink
|
||||
}
|
||||
}
|
||||
} catch (e: UnknownHostException) {
|
||||
throw DNSBlockedException(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchSnapchatVersions(page: Int = 1): List<DownloadItem>? {
|
||||
try {
|
||||
val versions = mutableListOf<DownloadItem>()
|
||||
okhttpClient.newCall(
|
||||
Request.Builder()
|
||||
.url(FETCH_BUILD_URL.replace("{page}", page.toString()))
|
||||
.build()
|
||||
).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val bodyString = response.body?.string() ?: return null
|
||||
val document = Jsoup.parse(bodyString)
|
||||
document.getElementById("primary")?.getElementsByClass("appRow")?.forEach { app ->
|
||||
val title = app.getElementsByTag("h5").first()?.attr("title") ?: return@forEach
|
||||
val releaseDate = app.getElementsByClass("dateyear_utc").attr("data-utcdate") ?: return@forEach
|
||||
val downloadPage = app.getElementsByClass("downloadLink").first()?.attr("href") ?: return@forEach
|
||||
|
||||
versions.add(DownloadItem(title, releaseDate, downloadPage))
|
||||
}
|
||||
}
|
||||
return versions
|
||||
} catch (e: UnknownHostException) {
|
||||
throw DNSBlockedException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DNSBlockedException(e: Throwable) : Exception(e)
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.manager.patch.util;
|
||||
package me.eternal.purrfectsnap.setup.patch
|
||||
|
||||
import com.android.tools.build.apkzlib.sign.SigningExtension
|
||||
import com.android.tools.build.apkzlib.sign.SigningOptions
|
||||
@@ -15,11 +15,12 @@ import java.util.Enumeration
|
||||
import java.util.jar.JarEntry
|
||||
import java.util.jar.JarFile
|
||||
|
||||
|
||||
//https://github.com/LSPosed/LSPatch/blob/master/patch/src/main/java/org/lsposed/patch/util/ApkSignatureHelper.java
|
||||
// Ported from LSPosed/LSPatch helper
|
||||
object ApkSignatureHelper {
|
||||
private val APK_V2_MAGIC = charArrayOf('A', 'P', 'K', ' ', 'S', 'i', 'g', ' ',
|
||||
'B', 'l', 'o', 'c', 'k', ' ', '4', '2')
|
||||
private val APK_V2_MAGIC = charArrayOf(
|
||||
'A', 'P', 'K', ' ', 'S', 'i', 'g', ' ',
|
||||
'B', 'l', 'o', 'c', 'k', ' ', '4', '2'
|
||||
)
|
||||
|
||||
fun provideSigningExtension(keyStoreInputStream: InputStream): SigningExtension {
|
||||
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||
@@ -38,10 +39,9 @@ object ApkSignatureHelper {
|
||||
}
|
||||
|
||||
private fun toChars(mSignature: ByteArray): CharArray {
|
||||
val N = mSignature.size
|
||||
val N2 = N * 2
|
||||
val text = CharArray(N2)
|
||||
for (j in 0 until N) {
|
||||
val n = mSignature.size
|
||||
val text = CharArray(n * 2)
|
||||
for (j in 0 until n) {
|
||||
val v = mSignature[j]
|
||||
var d = v.toInt() shr 4 and 0xf
|
||||
text[j * 2] = (if (d >= 10) 'a'.code + d - 10 else '0'.code + d).toChar()
|
||||
@@ -62,7 +62,7 @@ object ApkSignatureHelper {
|
||||
}
|
||||
`is`.close()
|
||||
return je?.certificates as Array<Certificate?>?
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -83,12 +83,8 @@ object ApkSignatureHelper {
|
||||
val entries: Enumeration<*> = jarFile.entries()
|
||||
while (entries.hasMoreElements()) {
|
||||
val je = entries.nextElement() as JarEntry
|
||||
if (je.isDirectory) {
|
||||
continue
|
||||
}
|
||||
if (je.name.startsWith("META-INF/")) {
|
||||
continue
|
||||
}
|
||||
if (je.isDirectory) continue
|
||||
if (je.name.startsWith("META-INF/")) continue
|
||||
val localCerts = loadCertificates(jarFile, je, readBuffer)
|
||||
if (certs == null) {
|
||||
certs = localCerts
|
||||
@@ -110,7 +106,7 @@ object ApkSignatureHelper {
|
||||
}
|
||||
jarFile.close()
|
||||
return if (certs != null) String(toChars(certs[0]!!.encoded)) else null
|
||||
} catch (ignored: Throwable) {
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -132,7 +128,6 @@ object ApkSignatureHelper {
|
||||
throw UnsupportedEncodingException("no apk v2")
|
||||
}
|
||||
|
||||
// Read and compare size fields
|
||||
apk.seek((offset - 0x18).toLong())
|
||||
apk.readFully(buffer.array(), 0x0, 0x8)
|
||||
buffer.rewind()
|
||||
@@ -140,20 +135,17 @@ object ApkSignatureHelper {
|
||||
val block = ByteBuffer.allocate(size + 0x8)
|
||||
block.order(ByteOrder.LITTLE_ENDIAN)
|
||||
apk.seek((offset - block.capacity()).toLong())
|
||||
apk.readFully(block.array(), 0x0, block.capacity())
|
||||
apk.readFully(block.array(), 0, block.capacity())
|
||||
if (size.toLong() != block.getLong()) {
|
||||
throw UnsupportedEncodingException("no apk v2")
|
||||
}
|
||||
while (block.remaining() > 24) {
|
||||
size = block.getLong().toInt()
|
||||
if (block.getInt() == 0x7109871a) {
|
||||
// signer-sequence length, signer length, signed data length
|
||||
block.position(block.position() + 12)
|
||||
size = block.getInt() // digests-sequence length
|
||||
|
||||
// digests, certificates length
|
||||
size = block.getInt()
|
||||
block.position(block.position() + size + 0x4)
|
||||
size = block.getInt() // certificate length
|
||||
size = block.getInt()
|
||||
break
|
||||
} else {
|
||||
block.position(block.position() + size - 0x4)
|
||||
@@ -164,4 +156,4 @@ object ApkSignatureHelper {
|
||||
return String(toChars(certificate))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package me.eternal.purrfectsnap.setup.patch
|
||||
|
||||
import com.google.gson.JsonParser
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
class AutoPatchServer(
|
||||
private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
|
||||
.callTimeout(1, TimeUnit.MINUTES)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.MINUTES)
|
||||
.writeTimeout(5, TimeUnit.MINUTES)
|
||||
.addInterceptor { chain ->
|
||||
chain.proceed(
|
||||
chain.request().newBuilder()
|
||||
.addHeader("Accept", "application/vnd.github+json")
|
||||
.addHeader("User-Agent", "PurrfectSnap")
|
||||
.build()
|
||||
)
|
||||
}
|
||||
.build()
|
||||
) {
|
||||
data class LatestApk(
|
||||
val tagName: String,
|
||||
val apkName: String,
|
||||
val downloadUrl: String,
|
||||
)
|
||||
|
||||
fun fetchLatestSnapchatApk(): LatestApk? {
|
||||
val request = Request.Builder()
|
||||
.url("https://api.github.com/repos/particle-box/auto-patch-server/releases/latest")
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val json = response.body?.string() ?: return null
|
||||
val release = JsonParser.parseString(json).asJsonObject
|
||||
val tagName = release.getAsJsonPrimitive("tag_name")?.asString ?: "latest"
|
||||
|
||||
val assets = release.getAsJsonArray("assets") ?: return null
|
||||
val apkAssets = assets.mapNotNull { element ->
|
||||
val asset = element.asJsonObject
|
||||
val name = asset.getAsJsonPrimitive("name")?.asString ?: return@mapNotNull null
|
||||
val downloadUrl = asset.getAsJsonPrimitive("browser_download_url")?.asString ?: return@mapNotNull null
|
||||
if (!name.endsWith(".apk", ignoreCase = true)) return@mapNotNull null
|
||||
name to downloadUrl
|
||||
}
|
||||
|
||||
val selected = apkAssets.firstOrNull { it.first.contains("snapchat", ignoreCase = true) }
|
||||
?: apkAssets.firstOrNull()
|
||||
?: return null
|
||||
|
||||
return LatestApk(
|
||||
tagName = tagName,
|
||||
apkName = selected.first,
|
||||
downloadUrl = selected.second,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package me.eternal.purrfectsnap.setup.patch
|
||||
|
||||
// Mirrors LSPatch constant used to flag patched APKs.
|
||||
object Constants {
|
||||
const val PROXY_APP_COMPONENT_FACTORY =
|
||||
"org.lsposed.lspatch.metaloader.LSPAppComponentFactoryStub"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.manager.patch.util
|
||||
package me.eternal.purrfectsnap.setup.patch
|
||||
|
||||
import com.android.tools.smali.dexlib2.Opcodes
|
||||
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
|
||||
@@ -11,19 +11,17 @@ import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
|
||||
private fun obfuscateStrings(dexFile: DexFile, dexStrings: Map<String, String?>): DexPool {
|
||||
val dexPool = object: DexPool(dexFile.opcodes) {
|
||||
val dexPool = object : DexPool(dexFile.opcodes) {
|
||||
override fun getSectionProvider(): SectionProvider {
|
||||
val dexPool = this
|
||||
return object: DexPoolSectionProvider() {
|
||||
override fun getStringSection() = object: StringPool(dexPool) {
|
||||
return object : DexPoolSectionProvider() {
|
||||
override fun getStringSection() = object : StringPool(dexPool) {
|
||||
private val cacheMap = mutableMapOf<String, String>()
|
||||
|
||||
override fun intern(string: CharSequence) {
|
||||
dexStrings[string.toString()]?.let {
|
||||
cacheMap[string.toString()] = it
|
||||
println("mapping $string to $it")
|
||||
super.intern(it)
|
||||
return
|
||||
}
|
||||
@@ -31,15 +29,11 @@ private fun obfuscateStrings(dexFile: DexFile, dexStrings: Map<String, String?>)
|
||||
}
|
||||
|
||||
override fun getItemIndex(key: CharSequence): Int {
|
||||
return cacheMap[key.toString()]?.let {
|
||||
internedItems[it]
|
||||
} ?: super.getItemIndex(key)
|
||||
return cacheMap[key.toString()]?.let { internedItems[it] } ?: super.getItemIndex(key)
|
||||
}
|
||||
|
||||
override fun getItemIndex(key: StringReference): Int {
|
||||
return cacheMap[key.toString()]?.let {
|
||||
internedItems[it]
|
||||
} ?: super.getItemIndex(key)
|
||||
return cacheMap[key.toString()]?.let { internedItems[it] } ?: super.getItemIndex(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,10 +45,14 @@ private fun obfuscateStrings(dexFile: DexFile, dexStrings: Map<String, String?>)
|
||||
return dexPool
|
||||
}
|
||||
|
||||
fun InputStream.obfuscateDexFile(cacheFolder: File, dexStrings: Map<String, String?>)
|
||||
= this.obfuscateDexFile(cacheFolder, { true }, dexStrings)!!
|
||||
fun InputStream.obfuscateDexFile(cacheFolder: File, dexStrings: Map<String, String?>) =
|
||||
this.obfuscateDexFile(cacheFolder, { true }, dexStrings)!!
|
||||
|
||||
fun InputStream.obfuscateDexFile(cacheFolder: File, filter: (DexFile) -> Boolean, dexStrings: Map<String, String?>): File? {
|
||||
fun InputStream.obfuscateDexFile(
|
||||
cacheFolder: File,
|
||||
filter: (DexFile) -> Boolean,
|
||||
dexStrings: Map<String, String?>
|
||||
): File? {
|
||||
val dexFile = DexBackedDexFile.fromInputStream(Opcodes.forApi(29), BufferedInputStream(this))
|
||||
if (!filter(dexFile)) return null
|
||||
val outputFile = File.createTempFile("dexobf", ".dex", cacheFolder)
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.manager.patch
|
||||
package me.eternal.purrfectsnap.setup.patch
|
||||
|
||||
import android.content.Context
|
||||
import com.android.tools.build.apkzlib.zip.AlignmentRules
|
||||
@@ -8,10 +8,6 @@ import com.google.gson.Gson
|
||||
import com.wind.meditor.core.ManifestEditor
|
||||
import com.wind.meditor.property.AttributeItem
|
||||
import com.wind.meditor.property.ModificationProperty
|
||||
import me.rhunk.snapenhance.manager.patch.config.Constants.PROXY_APP_COMPONENT_FACTORY
|
||||
import me.rhunk.snapenhance.manager.patch.config.PatchConfig
|
||||
import me.rhunk.snapenhance.manager.patch.util.ApkSignatureHelper
|
||||
import me.rhunk.snapenhance.manager.patch.util.ApkSignatureHelper.provideSigningExtension
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
@@ -20,21 +16,17 @@ import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.random.Random
|
||||
|
||||
|
||||
//https://github.com/LSPosed/LSPatch/blob/master/patch/src/main/java/org/lsposed/patch/LSPatch.java
|
||||
class LSPatch(
|
||||
private val context: Context,
|
||||
private val modules: Map<String, File>, //packageName -> file
|
||||
private val modules: Map<String, File>, // packageName -> module file
|
||||
private val obfuscate: Boolean,
|
||||
private val printLog: (Any) -> Unit
|
||||
) {
|
||||
|
||||
private fun patchManifest(data: ByteArray, lspatchMetadata: Pair<String, String>): ByteArray {
|
||||
val property = ModificationProperty()
|
||||
|
||||
property.addApplicationAttribute(AttributeItem("appComponentFactory", PROXY_APP_COMPONENT_FACTORY))
|
||||
property.addApplicationAttribute(AttributeItem("appComponentFactory", Constants.PROXY_APP_COMPONENT_FACTORY))
|
||||
property.addMetaData(ModificationProperty.MetaData(lspatchMetadata.first, lspatchMetadata.second))
|
||||
|
||||
return ByteArrayOutputStream().apply {
|
||||
ManifestEditor(ByteArrayInputStream(data), this, property).processManifest()
|
||||
flush()
|
||||
@@ -46,34 +38,25 @@ class LSPatch(
|
||||
printLog("Resigning ${inputApkFile.absolutePath} to ${outputFile.absolutePath}")
|
||||
val dstZFile = ZFile.openReadWrite(outputFile, ZFileOptions())
|
||||
val inZFile = ZFile.openReadOnly(inputApkFile)
|
||||
|
||||
inZFile.entries().forEach { entry ->
|
||||
dstZFile.add(entry.centralDirectoryHeader.name, entry.open())
|
||||
}
|
||||
|
||||
// sign apk
|
||||
inZFile.entries().forEach { entry -> dstZFile.add(entry.centralDirectoryHeader.name, entry.open()) }
|
||||
runCatching {
|
||||
provideSigningExtension(context.assets.open("lspatch/keystore.jks")).register(dstZFile)
|
||||
}.onFailure {
|
||||
throw Exception("Failed to sign apk", it)
|
||||
}
|
||||
|
||||
ApkSignatureHelper.provideSigningExtension(context.assets.open("lspatch/keystore.jks")).register(dstZFile)
|
||||
}.onFailure { throw Exception("Failed to sign apk", it) }
|
||||
dstZFile.realign()
|
||||
dstZFile.close()
|
||||
inZFile.close()
|
||||
printLog("Done")
|
||||
}
|
||||
|
||||
private fun uniqueHash(): String {
|
||||
return Random.nextBytes(Random.nextInt(5, 10)).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
private fun uniqueHash(): String = Random.nextBytes(Random.nextInt(5, 10)).joinToString("") { "%02x".format(it) }
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
private fun patchApk(inputApkFile: File, outputFile: File) {
|
||||
printLog("Patching ${inputApkFile.absolutePath} to ${outputFile.absolutePath}")
|
||||
|
||||
val obfuscationCacheFolder = File(context.cacheDir, "lspatch").apply {
|
||||
val cacheRoot = context.cacheDir ?: throw IllegalStateException("context.cacheDir is null")
|
||||
val obfuscationCacheFolder = File(cacheRoot, "lspatch").apply {
|
||||
if (exists()) deleteRecursively()
|
||||
mkdirs()
|
||||
}
|
||||
@@ -86,75 +69,69 @@ class LSPatch(
|
||||
loaderFilePath = uniqueHash(),
|
||||
libNativeFilePath = mapOf(
|
||||
"arm64-v8a" to uniqueHash() + ".so",
|
||||
"armeabi-v7a" to uniqueHash() + ".so",
|
||||
"armeabi-v7a" to uniqueHash() + ".so"
|
||||
),
|
||||
originApkPath = uniqueHash(),
|
||||
cachedOriginApkPath = uniqueHash(),
|
||||
openAtApkPath = uniqueHash(),
|
||||
assetModuleFolderPath = uniqueHash(),
|
||||
assetModuleFolderPath = uniqueHash()
|
||||
) else null
|
||||
|
||||
val dstZFile = ZFile.openReadWrite(outputFile, ZFileOptions().setAlignmentRule(
|
||||
AlignmentRules.compose(
|
||||
AlignmentRules.constantForSuffix(".so", 4096),
|
||||
AlignmentRules.constantForSuffix("assets/" + (dexObfuscationConfig?.originApkPath ?: "lspatch/origin.apk"), 4096)
|
||||
val dstZFile = ZFile.openReadWrite(
|
||||
outputFile,
|
||||
ZFileOptions().setAlignmentRule(
|
||||
AlignmentRules.compose(
|
||||
AlignmentRules.constantForSuffix(".so", 4096),
|
||||
AlignmentRules.constantForSuffix("assets/" + (dexObfuscationConfig?.originApkPath ?: "lspatch/origin.apk"), 4096)
|
||||
)
|
||||
)
|
||||
))
|
||||
)
|
||||
|
||||
val origSign = ApkSignatureHelper.getApkSignInfo(inputApkFile.absolutePath)
|
||||
val patchConfig = PatchConfig(
|
||||
useManager = false,
|
||||
debuggable = false,
|
||||
overrideVersionCode = false,
|
||||
sigBypassLevel = 2,
|
||||
originalSignature = ApkSignatureHelper.getApkSignInfo(inputApkFile.absolutePath),
|
||||
originalSignature = origSign,
|
||||
appComponentFactory = "androidx.core.app.CoreComponentFactory"
|
||||
).let { Gson().toJson(it) }
|
||||
|
||||
// sign apk
|
||||
runCatching {
|
||||
provideSigningExtension(context.assets.open("lspatch/keystore.jks")).register(dstZFile)
|
||||
}.onFailure {
|
||||
throw Exception("Failed to sign apk", it)
|
||||
}
|
||||
ApkSignatureHelper.provideSigningExtension(context.assets.open("lspatch/keystore.jks")).register(dstZFile)
|
||||
}.onFailure { throw Exception("Failed to sign apk", it) }
|
||||
|
||||
printLog("Patching manifest")
|
||||
|
||||
val sourceApkFile = dstZFile.addNestedZip({ "assets/" + (dexObfuscationConfig?.originApkPath ?: "lspatch/origin.apk") }, inputApkFile, false)
|
||||
val originalManifestEntry = sourceApkFile.get("AndroidManifest.xml") ?: throw Exception("No original manifest found")
|
||||
val originalManifestEntry = sourceApkFile.get("AndroidManifest.xml") ?: throw Exception("No original manifest found in base APK")
|
||||
originalManifestEntry.open().use { inputStream ->
|
||||
val patchedManifestData = patchManifest(inputStream.readBytes(), (dexObfuscationConfig?.metadataManifestField ?: "lspatch") to Base64.encode(patchConfig.toByteArray()))
|
||||
val patchedManifestData = patchManifest(
|
||||
inputStream.readBytes(),
|
||||
(dexObfuscationConfig?.metadataManifestField ?: "lspatch") to Base64.encode(patchConfig.toByteArray())
|
||||
)
|
||||
dstZFile.add("AndroidManifest.xml", patchedManifestData.inputStream())
|
||||
}
|
||||
|
||||
//add config
|
||||
printLog("Adding config")
|
||||
dstZFile.add("assets/" + (dexObfuscationConfig?.configFilePath ?: "lspatch/config.json"), ByteArrayInputStream(patchConfig.toByteArray()))
|
||||
|
||||
// add loader dex
|
||||
printLog("Adding loader dex")
|
||||
context.assets.open("lspatch/dexes/loader.dex").use { inputStream ->
|
||||
dstZFile.add("assets/" + (dexObfuscationConfig?.loaderFilePath ?: "lspatch/loader.dex"), dexObfuscationConfig?.let {
|
||||
lspatchObfuscation.obfuscateLoader(inputStream, it).inputStream()
|
||||
} ?: inputStream)
|
||||
dstZFile.add(
|
||||
"assets/" + (dexObfuscationConfig?.loaderFilePath ?: "lspatch/loader.dex"),
|
||||
dexObfuscationConfig?.let { lspatchObfuscation.obfuscateLoader(inputStream, it).inputStream() } ?: inputStream
|
||||
)
|
||||
}
|
||||
|
||||
//add natives
|
||||
printLog("Adding natives")
|
||||
val nativeAssetPath = "snapenhance/so"
|
||||
context.assets.list(nativeAssetPath)?.forEach { abi ->
|
||||
val libPath = "$nativeAssetPath/$abi"
|
||||
// The library name is now fixed, let's find it.
|
||||
val libName = context.assets.list(libPath)?.find { it.startsWith("lib") && it.endsWith(".so") } ?: return@forEach
|
||||
val fullAssetPath = "$libPath/$libName"
|
||||
|
||||
// The destination path inside the APK must be in the `lib` folder.
|
||||
val finalApkPath = "lib/$abi/$libName"
|
||||
|
||||
printLog("Adding native library from $fullAssetPath to $finalApkPath")
|
||||
dstZFile.add(finalApkPath, context.assets.open(fullAssetPath), false)
|
||||
context.assets.list("lspatch/so")?.forEach { native ->
|
||||
dstZFile.add(
|
||||
"assets/${dexObfuscationConfig?.libNativeFilePath?.get(native) ?: "lspatch/so/$native/liblspatch.so"}",
|
||||
context.assets.open("lspatch/so/$native/liblspatch.so"),
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
//embed modules
|
||||
printLog("Embedding modules")
|
||||
modules.forEach { (packageName, module) ->
|
||||
val obfuscatedPackageName = dexObfuscationConfig?.packageName ?: packageName
|
||||
@@ -162,38 +139,33 @@ class LSPatch(
|
||||
dstZFile.add("assets/${dexObfuscationConfig?.assetModuleFolderPath ?: "lspatch/modules"}/$obfuscatedPackageName.apk", module.inputStream())
|
||||
}
|
||||
|
||||
// link apk entries
|
||||
printLog("Linking apk entries")
|
||||
|
||||
for (entry in sourceApkFile.entries()) {
|
||||
val name = entry.centralDirectoryHeader.name
|
||||
if (dexObfuscationConfig == null && name.startsWith("classes") && name.endsWith(".dex")) continue
|
||||
if (dstZFile[name] != null) continue
|
||||
if (name == "AndroidManifest.xml") continue
|
||||
if (name.startsWith("META-INF") && (name.endsWith(".SF") || name.endsWith(".MF") || name.endsWith(
|
||||
".RSA"
|
||||
))
|
||||
) continue
|
||||
if (name.startsWith("META-INF") && (name.endsWith(".SF") || name.endsWith(".MF") || name.endsWith(".RSA"))) continue
|
||||
sourceApkFile.addFileLink(name, name)
|
||||
}
|
||||
|
||||
printLog("Adding meta loader dex")
|
||||
context.assets.open("lspatch/dexes/metaloader.dex").use { inputStream ->
|
||||
dstZFile.add(dexObfuscationConfig?.let {
|
||||
val dexFileIndex = sourceApkFile.entries().count {
|
||||
it.centralDirectoryHeader.name.startsWith("classes") && it.centralDirectoryHeader.name.endsWith(".dex")
|
||||
} + 1
|
||||
"classes${dexFileIndex}.dex"
|
||||
} ?: "classes.dex", dexObfuscationConfig?.let {
|
||||
lspatchObfuscation.obfuscateMetaLoader(inputStream, it).inputStream()
|
||||
} ?: inputStream)
|
||||
dstZFile.add(
|
||||
dexObfuscationConfig?.let {
|
||||
val dexFileIndex = sourceApkFile.entries().count {
|
||||
it.centralDirectoryHeader.name.startsWith("classes") && it.centralDirectoryHeader.name.endsWith(".dex")
|
||||
} + 1
|
||||
"classes${dexFileIndex}.dex"
|
||||
} ?: "classes.dex",
|
||||
dexObfuscationConfig?.let { lspatchObfuscation.obfuscateMetaLoader(inputStream, it).inputStream() } ?: inputStream
|
||||
)
|
||||
}
|
||||
|
||||
printLog("Writing apk")
|
||||
dstZFile.realign()
|
||||
dstZFile.close()
|
||||
sourceApkFile.close()
|
||||
|
||||
printLog("Cleaning obfuscation cache")
|
||||
obfuscationCacheFolder.deleteRecursively()
|
||||
printLog("Done")
|
||||
@@ -201,8 +173,9 @@ class LSPatch(
|
||||
|
||||
fun patchSplits(inputs: List<File>): Map<String, File> {
|
||||
val outputs = mutableMapOf<String, File>()
|
||||
val extCacheDir = context.externalCacheDir ?: context.cacheDir ?: throw IllegalStateException("No valid cache dir")
|
||||
inputs.forEach { input ->
|
||||
val outputFile = File.createTempFile("patched", ".apk", context.externalCacheDir ?: context.cacheDir)
|
||||
val outputFile = File.createTempFile("patched", ".apk", extCacheDir)
|
||||
if (input.name.contains("split")) {
|
||||
resignApk(input, outputFile)
|
||||
outputs[input.name] = outputFile
|
||||
@@ -215,34 +188,32 @@ class LSPatch(
|
||||
}
|
||||
|
||||
private fun patch(input: File, outputFile: File) {
|
||||
//check if input apk is already patched
|
||||
if (!input.exists()) {
|
||||
printLog("!! Input file does not exist: ${input.absolutePath}")
|
||||
return
|
||||
}
|
||||
if (outputFile.exists()) outputFile.delete()
|
||||
|
||||
var isAlreadyPatched = false
|
||||
var inputFile = input
|
||||
|
||||
// extract origin
|
||||
printLog("Extracting origin apk")
|
||||
ZipFile(input).use { zipFile ->
|
||||
zipFile.getEntry("assets/lspatch/origin.apk")?.apply {
|
||||
inputFile = File.createTempFile("origin", ".apk")
|
||||
inputFile.outputStream().use {
|
||||
zipFile.getInputStream(this).copyTo(it)
|
||||
}
|
||||
inputFile = File.createTempFile("origin", ".apk", context.cacheDir ?: context.externalCacheDir)
|
||||
inputFile.outputStream().use { zipFile.getInputStream(this).copyTo(it) }
|
||||
isAlreadyPatched = true
|
||||
}
|
||||
}
|
||||
|
||||
if (outputFile.exists()) outputFile.delete()
|
||||
|
||||
printLog("Patching apk")
|
||||
runCatching {
|
||||
patchApk(inputFile, outputFile)
|
||||
}.onFailure {
|
||||
if (isAlreadyPatched) {
|
||||
inputFile.delete()
|
||||
}
|
||||
if (isAlreadyPatched) inputFile.delete()
|
||||
outputFile.delete()
|
||||
printLog("Failed to patch")
|
||||
printLog(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package me.eternal.purrfectsnap.setup.patch
|
||||
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
data class DexObfuscationConfig(
|
||||
val packageName: String,
|
||||
val metadataManifestField: String? = null,
|
||||
val metaLoaderFilePath: String? = null,
|
||||
val configFilePath: String? = null,
|
||||
val loaderFilePath: String? = null,
|
||||
val originApkPath: String? = null,
|
||||
val cachedOriginApkPath: String? = null,
|
||||
val openAtApkPath: String? = null,
|
||||
val assetModuleFolderPath: String? = null,
|
||||
val libNativeFilePath: Map<String, String> = mapOf(),
|
||||
)
|
||||
|
||||
class LSPatchObfuscation(
|
||||
private val cacheFolder: File,
|
||||
private val printLog: (String) -> Unit = { println(it) }
|
||||
) {
|
||||
|
||||
fun obfuscateMetaLoader(inputStream: InputStream, config: DexObfuscationConfig): File {
|
||||
return inputStream.obfuscateDexFile(
|
||||
cacheFolder,
|
||||
mapOf(
|
||||
"assets/lspatch/config.json" to "assets/${config.configFilePath}",
|
||||
"assets/lspatch/loader.dex" to "assets/${config.loaderFilePath}",
|
||||
) + (config.libNativeFilePath.takeIf { it.isNotEmpty() }?.let {
|
||||
mapOf(
|
||||
"!/assets/lspatch/so/" to "!/assets/",
|
||||
"assets/lspatch/so/" to "assets/",
|
||||
"/liblspatch.so" to "",
|
||||
"arm64-v8a" to config.libNativeFilePath["arm64-v8a"],
|
||||
"armeabi-v7a" to config.libNativeFilePath["armeabi-v7a"],
|
||||
"x86" to config.libNativeFilePath["x86"],
|
||||
"x86_64" to config.libNativeFilePath["x86_64"],
|
||||
)
|
||||
} ?: mapOf())
|
||||
)
|
||||
}
|
||||
|
||||
fun obfuscateLoader(inputStream: InputStream, config: DexObfuscationConfig): File {
|
||||
return inputStream.obfuscateDexFile(
|
||||
cacheFolder, mapOf(
|
||||
"assets/lspatch/config.json" to config.configFilePath?.let { "assets/$it" },
|
||||
"assets/lspatch/loader.dex" to config.loaderFilePath?.let { "assets/$it" },
|
||||
"assets/lspatch/metaloader.dex" to config.metaLoaderFilePath?.let { "assets/$it" },
|
||||
"assets/lspatch/origin.apk" to config.originApkPath?.let { "assets/$it" },
|
||||
"/lspatch/origin/" to config.cachedOriginApkPath?.let { "/$it/" }, // context.getCacheDir() + ==> "/lspatch/origin/" <== + sourceFile.getEntry(ORIGINAL_APK_ASSET_PATH).getCrc() + ".apk";
|
||||
"/lspatch/" to config.cachedOriginApkPath?.let { "/$it/" }, // context.getCacheDir() + "/lspatch/" + packageName + "/"
|
||||
"cache/lspatch/origin/" to config.cachedOriginApkPath?.let { "cache/$it" }, //LSPApplication => Path originPath = Paths.get(appInfo.dataDir, "cache/lspatch/origin/");
|
||||
"assets/lspatch/modules/" to config.assetModuleFolderPath?.let { "assets/$it/" }, // Constants.java => EMBEDDED_MODULES_ASSET_PATH
|
||||
"lspatch/modules" to config.assetModuleFolderPath, // LocalApplicationService.java => context.getAssets().list("lspatch/modules"),
|
||||
"lspatch/modules/" to config.assetModuleFolderPath?.let { "$it/" }, // LocalApplicationService.java => try (var is = context.getAssets().open("lspatch/modules/" + name)) {
|
||||
"lspatch" to config.metadataManifestField, // SigBypass.java => "lspatch",
|
||||
"org.lsposed.lspatch" to config.cachedOriginApkPath?.let { "$it/${config.packageName}/" }, // Constants.java => "org.lsposed.lspatch", (Used in LSPatchUpdater.kt)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.manager.patch.config
|
||||
package me.eternal.purrfectsnap.setup.patch
|
||||
|
||||
data class PatchConfig(
|
||||
val useManager: Boolean = false,
|
||||
@@ -16,4 +16,4 @@ data class PatchConfig(
|
||||
var CORE_VERSION_CODE: Int = 6649,
|
||||
var CORE_VERSION_NAME: String = "1.8.5",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package me.rhunk.snapenhance.storage
|
||||
package me.eternal.purrfectsnap.storage
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
|
||||
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
|
||||
import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.util.SQLiteDatabaseHelper
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@@ -115,4 +115,4 @@ class AppDatabase(
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package me.rhunk.snapenhance.storage
|
||||
package me.eternal.purrfectsnap.storage
|
||||
|
||||
import android.content.ContentValues
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.rhunk.snapenhance.bridge.location.LocationCoordinates
|
||||
import me.rhunk.snapenhance.common.util.ktx.getDoubleOrNull
|
||||
import me.rhunk.snapenhance.common.util.ktx.getInteger
|
||||
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
|
||||
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getDoubleOrNull
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getInteger
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
|
||||
|
||||
|
||||
fun AppDatabase.getLocationCoordinates(): List<LocationCoordinates> {
|
||||
@@ -1,12 +1,12 @@
|
||||
package me.rhunk.snapenhance.storage
|
||||
package me.eternal.purrfectsnap.storage
|
||||
|
||||
import me.rhunk.snapenhance.common.data.FriendStreaks
|
||||
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
|
||||
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
|
||||
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 me.eternal.purrfectsnap.common.data.FriendStreaks
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getInteger
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
|
||||
import java.io.Serializable
|
||||
|
||||
fun AppDatabase.getGroups(): List<MessagingGroupInfo> {
|
||||
@@ -41,7 +41,7 @@ fun AppDatabase.syncGroupInfo(conversationInfo: MessagingGroupInfo) {
|
||||
try {
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)",
|
||||
arrayOf(
|
||||
arrayOf<Any?>(
|
||||
conversationInfo.conversationId,
|
||||
conversationInfo.name,
|
||||
conversationInfo.participantsCount
|
||||
@@ -1,6 +1,6 @@
|
||||
package me.rhunk.snapenhance.storage
|
||||
package me.eternal.purrfectsnap.storage
|
||||
|
||||
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
|
||||
|
||||
fun AppDatabase.getQuickTiles(): List<String> {
|
||||
return database.rawQuery("SELECT `key` FROM quick_tiles ORDER BY position ASC", null).use { cursor ->
|
||||
@@ -1,9 +1,9 @@
|
||||
package me.rhunk.snapenhance.storage
|
||||
package me.eternal.purrfectsnap.storage
|
||||
|
||||
import android.content.ContentValues
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
|
||||
|
||||
|
||||
fun AppDatabase.getRepositories(type: String): List<String> {
|
||||
@@ -32,4 +32,3 @@ fun AppDatabase.addRepo(type: String, url: String) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.storage
|
||||
package me.eternal.purrfectsnap.storage
|
||||
|
||||
import androidx.core.database.getStringOrNull
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.storage
|
||||
package me.eternal.purrfectsnap.storage
|
||||
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -1,16 +1,16 @@
|
||||
package me.rhunk.snapenhance.storage
|
||||
package me.eternal.purrfectsnap.storage
|
||||
|
||||
import android.content.ContentValues
|
||||
import com.google.gson.JsonArray
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.rhunk.snapenhance.common.data.TrackerRule
|
||||
import me.rhunk.snapenhance.common.data.TrackerRuleAction
|
||||
import me.rhunk.snapenhance.common.data.TrackerRuleActionParams
|
||||
import me.rhunk.snapenhance.common.data.TrackerRuleEvent
|
||||
import me.rhunk.snapenhance.common.data.TrackerScopeType
|
||||
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 me.eternal.purrfectsnap.common.data.TrackerRule
|
||||
import me.eternal.purrfectsnap.common.data.TrackerRuleAction
|
||||
import me.eternal.purrfectsnap.common.data.TrackerRuleActionParams
|
||||
import me.eternal.purrfectsnap.common.data.TrackerRuleEvent
|
||||
import me.eternal.purrfectsnap.common.data.TrackerScopeType
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getInteger
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
fun AppDatabase.clearTrackerRules() {
|
||||
@@ -57,7 +57,7 @@ fun AppDatabase.addOrUpdateTrackerRuleEvent(
|
||||
suspendCoroutine<Int?> { continuation ->
|
||||
executeAsync {
|
||||
val id = if (ruleEventId != null) {
|
||||
database.execSQL("UPDATE tracker_rules_events SET params = ?, actions = ? WHERE id = ?", arrayOf<Any?>(
|
||||
database.execSQL("UPDATE tracker_rules_events SET params = ?, actions = ? WHERE id = ?", arrayOf(
|
||||
context.gson.toJson(params),
|
||||
context.gson.toJson(actions.map { it.key }),
|
||||
ruleEventId
|
||||
@@ -126,13 +126,13 @@ fun AppDatabase.getTrackerRuleByName(name: String): TrackerRule? {
|
||||
|
||||
fun AppDatabase.setTrackerRuleName(ruleId: Int, name: String) {
|
||||
executeAsync {
|
||||
database.execSQL("UPDATE tracker_rules SET name = ? WHERE id = ?", arrayOf(name, ruleId))
|
||||
database.execSQL("UPDATE tracker_rules SET name = ? WHERE id = ?", arrayOf<Any?>(name, ruleId))
|
||||
}
|
||||
}
|
||||
|
||||
fun AppDatabase.setTrackerRuleAuthor(ruleId: Int, author: String) {
|
||||
executeAsync {
|
||||
database.execSQL("UPDATE tracker_rules SET author = ? WHERE id = ?", arrayOf(author, ruleId))
|
||||
database.execSQL("UPDATE tracker_rules SET author = ? WHERE id = ?", arrayOf<Any?>(author, ruleId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,9 +234,9 @@ fun AppDatabase.updateFriendScore(userId: String, score: Long): Long {
|
||||
cursor.getLongOrNull("score")
|
||||
}
|
||||
if (currentScore != null) {
|
||||
database.execSQL("UPDATE friend_scores SET score = ? WHERE userId = ?", arrayOf(score, userId))
|
||||
database.execSQL("UPDATE friend_scores SET score = ? WHERE userId = ?", arrayOf<Any?>(score, userId))
|
||||
} else {
|
||||
database.execSQL("INSERT INTO friend_scores (userId, score) VALUES (?, ?)", arrayOf(userId, score))
|
||||
database.execSQL("INSERT INTO friend_scores (userId, score) VALUES (?, ?)", arrayOf<Any?>(userId, score))
|
||||
}
|
||||
continuation.resumeWith(Result.success(currentScore ?: -1))
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
package me.rhunk.snapenhance.storage
|
||||
package me.eternal.purrfectsnap.storage
|
||||
|
||||
import me.rhunk.snapenhance.common.data.ExportedTrackerData
|
||||
import me.rhunk.snapenhance.common.data.TrackerDataManager
|
||||
import me.rhunk.snapenhance.storage.AppDatabase
|
||||
import me.eternal.purrfectsnap.common.data.ExportedTrackerData
|
||||
import me.eternal.purrfectsnap.common.data.TrackerDataManager
|
||||
import me.eternal.purrfectsnap.storage.AppDatabase
|
||||
|
||||
class TrackerDataManagerImpl(private val db: AppDatabase) : TrackerDataManager {
|
||||
override fun getExportedTrackerData(): ExportedTrackerData {
|
||||
return ExportedTrackerData(
|
||||
type = me.rhunk.snapenhance.common.data.ExportType.BULK,
|
||||
type = me.eternal.purrfectsnap.common.data.ExportType.BULK,
|
||||
rules = db.getTrackerRulesDesc().map { rule ->
|
||||
rule.copy(
|
||||
events = db.getTrackerEvents(rule.id),
|
||||
@@ -20,7 +20,7 @@ class TrackerDataManagerImpl(private val db: AppDatabase) : TrackerDataManager {
|
||||
override fun getExportedTrackerData(ruleId: Int): ExportedTrackerData? {
|
||||
return db.getTrackerRule(ruleId)?.let {
|
||||
ExportedTrackerData(
|
||||
type = me.rhunk.snapenhance.common.data.ExportType.SINGLE,
|
||||
type = me.eternal.purrfectsnap.common.data.ExportType.SINGLE,
|
||||
rules = listOf(it.copy(
|
||||
events = db.getTrackerEvents(it.id),
|
||||
scopes = db.getRuleTrackerScopes(it.id)
|
||||
@@ -30,7 +30,7 @@ class TrackerDataManagerImpl(private val db: AppDatabase) : TrackerDataManager {
|
||||
}
|
||||
|
||||
override fun importTrackerData(data: ExportedTrackerData) {
|
||||
if (data.type == me.rhunk.snapenhance.common.data.ExportType.BULK) {
|
||||
if (data.type == me.eternal.purrfectsnap.common.data.ExportType.BULK) {
|
||||
db.clearTrackerRules()
|
||||
}
|
||||
data.rules.forEach { rule ->
|
||||
@@ -1,11 +1,12 @@
|
||||
package me.rhunk.snapenhance.task
|
||||
package me.eternal.purrfectsnap.task
|
||||
|
||||
|
||||
enum class TaskType(
|
||||
val key: String
|
||||
) {
|
||||
DOWNLOAD("download"),
|
||||
CHAT_ACTION("chat_action");
|
||||
CHAT_ACTION("chat_action"),
|
||||
SCHEDULED_SEND("scheduled_send");
|
||||
|
||||
companion object {
|
||||
fun fromKey(key: String): TaskType {
|
||||
@@ -132,4 +133,4 @@ class PendingTask(
|
||||
listeners.forEach { it.onCancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package me.eternal.purrfectsnap.task
|
||||
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.bridge.task.TaskInterface
|
||||
import me.eternal.purrfectsnap.bridge.task.TaskListener
|
||||
|
||||
class RemoteTaskInterface(
|
||||
private val context: RemoteSideContext
|
||||
) : TaskInterface.Stub() {
|
||||
private val activeTasks = context.taskManager.getActiveTasks()
|
||||
|
||||
override fun createTask(type: String, title: String, author: String, hash: String): String {
|
||||
val task = Task(
|
||||
type = TaskType.fromKey(type),
|
||||
title = title,
|
||||
author = author.takeIf { it.isNotBlank() },
|
||||
hash = hash
|
||||
)
|
||||
context.taskManager.createPendingTask(task)
|
||||
return hash
|
||||
}
|
||||
|
||||
override fun updateTaskProgress(hash: String, label: String, progress: Int) {
|
||||
activeTasks.values.find { it.task.hash == hash }?.updateProgress(label, progress)
|
||||
?: context.taskManager.getTaskByHash(hash)?.let {
|
||||
if (!it.status.isFinalStage()) {
|
||||
it.status = TaskStatus.RUNNING
|
||||
it.extra = label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancelTask(hash: String) {
|
||||
activeTasks.values.find { it.task.hash == hash }?.cancel() ?: context.taskManager.getTaskByHash(hash)?.let {
|
||||
it.status = TaskStatus.CANCELLED
|
||||
}
|
||||
}
|
||||
|
||||
override fun failTask(hash: String, reason: String) {
|
||||
activeTasks.values.find { it.task.hash == hash }?.fail(reason) ?: context.taskManager.getTaskByHash(hash)?.let {
|
||||
it.status = TaskStatus.FAILURE
|
||||
it.extra = reason
|
||||
}
|
||||
}
|
||||
|
||||
override fun successTask(hash: String) {
|
||||
activeTasks.values.find { it.task.hash == hash }?.success() ?: context.taskManager.getTaskByHash(hash)?.let {
|
||||
it.status = TaskStatus.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
override fun registerTaskListener(hash: String, listener: TaskListener) {
|
||||
activeTasks.values.find { it.task.hash == hash }?.addListener(
|
||||
PendingTaskListener(
|
||||
onSuccess = { listener.onSuccess() },
|
||||
onCancel = { listener.onCancel() },
|
||||
onProgress = { label, progress -> listener.onProgress(label ?: "", progress) },
|
||||
onStateChange = { status -> listener.onStateChange(status.key) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun unregisterTaskListener(hash: String, listener: TaskListener) {
|
||||
activeTasks.values.find { it.task.hash == hash }?.removeListener(PendingTaskListener())
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.task
|
||||
package me.eternal.purrfectsnap.task
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
@@ -6,10 +6,10 @@ import android.database.sqlite.SQLiteDatabase
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper
|
||||
import me.rhunk.snapenhance.common.util.ktx.getLong
|
||||
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.common.util.SQLiteDatabaseHelper
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getLong
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.task
|
||||
package me.eternal.purrfectsnap.task
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
@@ -13,9 +13,9 @@ 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
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.ui.manager.MainActivity
|
||||
import me.eternal.purrfectsnap.ui.manager.data.Updater
|
||||
|
||||
class UpdateCheckWorker(
|
||||
private val appContext: Context,
|
||||
@@ -35,9 +35,9 @@ class UpdateCheckWorker(
|
||||
}
|
||||
|
||||
private fun showUpdateNotification(versionName: String) {
|
||||
val channelId = "snapenhance_updates"
|
||||
val name = inputData.getString("channel_name") ?: "SnapEnhance Updates"
|
||||
val descriptionText = inputData.getString("channel_description") ?: "Notifications for SnapEnhance updates"
|
||||
val channelId = "purrfectsnap_updates"
|
||||
val name = inputData.getString("channel_name") ?: "PurrfectSnap Updates"
|
||||
val descriptionText = inputData.getString("channel_description") ?: "Notifications for PurrfectSnap updates"
|
||||
val title = inputData.getString("notification_title") ?: "PurrfectSnap Update Available"
|
||||
val text = inputData.getString("notification_text") ?: "Version %s is now available."
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@file:OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||
package me.rhunk.snapenhance.ui.manager
|
||||
package me.eternal.purrfectsnap.ui.manager
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
@@ -7,6 +7,7 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.*
|
||||
@@ -31,12 +32,14 @@ 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
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
|
||||
import me.eternal.purrfectsnap.common.ui.ThemeMode
|
||||
import me.eternal.purrfectsnap.common.ui.ThemePreferences
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.ThankYouDialog
|
||||
import android.content.IntentFilter
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -44,7 +47,7 @@ class MainActivity : ComponentActivity() {
|
||||
private lateinit var managerContext: RemoteSideContext
|
||||
|
||||
companion object {
|
||||
const val RESTART_ACTION = "me.rhunk.snapenhance.RESTART"
|
||||
const val RESTART_ACTION = "me.eternal.purrfectsnap.RESTART"
|
||||
}
|
||||
|
||||
private val restartReceiver = object : android.content.BroadcastReceiver() {
|
||||
@@ -58,6 +61,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
if (::navController.isInitialized.not()) return
|
||||
|
||||
intent.getStringExtra("route")?.let { route ->
|
||||
navController.popBackStack()
|
||||
navController.navigate(route) {
|
||||
@@ -68,9 +72,17 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (::managerContext.isInitialized) {
|
||||
managerContext.checkForRequirements()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
registerReceiver(restartReceiver, IntentFilter(RESTART_ACTION), RECEIVER_EXPORTED)
|
||||
managerContext = SharedContextHolder.remote(this).apply {
|
||||
activity = this@MainActivity
|
||||
@@ -91,6 +103,7 @@ class MainActivity : ComponentActivity() {
|
||||
it.navController = navController
|
||||
})
|
||||
}
|
||||
routes.navigation = navigation
|
||||
val startDestination = remember {
|
||||
intent.getStringExtra("route") ?: run {
|
||||
val def = managerContext.sharedPreferences.getString("manager_default_tab", "home") ?: "home"
|
||||
@@ -128,57 +141,62 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
val isFullscreen = currentRoute in fullscreenRoutes
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
if (!isFullscreen) {
|
||||
navigation.TopBar()
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (!isFullscreen) {
|
||||
Box(Modifier.padding(bottom = bottomPadding)) {
|
||||
navigation.Fab()
|
||||
ThankYouDialog()
|
||||
CompositionLocalProvider(LocalContentColor provides PurrfectPalette.iconTint) {
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient),
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
if (!isFullscreen) {
|
||||
navigation.TopBar()
|
||||
}
|
||||
}
|
||||
},
|
||||
// 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
|
||||
},
|
||||
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,
|
||||
Color(0xFF241F52)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
) {
|
||||
navigation.FloatingBottomBar()
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
) {
|
||||
navigation.FloatingBottomBar()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
package me.eternal.purrfectsnap.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.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.layout.size
|
||||
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.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.ButtonDefaults
|
||||
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.AssistChipDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.RadioButtonDefaults
|
||||
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.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
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.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.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.compose.navigation
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import kotlin.math.round
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
|
||||
@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 }
|
||||
) {
|
||||
private val translation by lazy { context.translation.getCategory("manager.navigation") }
|
||||
var openBottomBarCustomization by mutableStateOf(false)
|
||||
@Composable
|
||||
fun TopBar() {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
|
||||
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(
|
||||
text = it,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
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)
|
||||
) {
|
||||
IconButton(onClick = { if (canGoBack) navController.popBackStack() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent
|
||||
),
|
||||
actions = {
|
||||
currentRoute?.topBarActions?.invoke(this)
|
||||
if (currentRoute?.routeInfo?.id == routes.settings.routeInfo.id) {
|
||||
IconButton(onClick = { openBottomBarCustomization = true }) {
|
||||
Icon(Icons.Filled.Tune, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
fun FloatingBottomBar() {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
|
||||
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<String> {
|
||||
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<String>) {
|
||||
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] } }
|
||||
val barShape = RoundedCornerShape(28.dp)
|
||||
val barBorder = remember {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.9f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.85f)
|
||||
)
|
||||
)
|
||||
}
|
||||
val barSheen = remember {
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = 0.14f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
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 = barShape,
|
||||
color = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
|
||||
modifier = Modifier
|
||||
.then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth())
|
||||
.drawBehind {
|
||||
val radius = size.width * 0.62f
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.25f),
|
||||
Color.Transparent
|
||||
),
|
||||
center = center,
|
||||
radius = radius
|
||||
),
|
||||
radius = radius,
|
||||
center = center
|
||||
)
|
||||
}
|
||||
.shadow(
|
||||
elevation = 28.dp,
|
||||
shape = barShape,
|
||||
spotColor = PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
|
||||
ambientColor = PurrfectPalette.glowSecondary.copy(alpha = 0.26f)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(82.dp)
|
||||
.clip(barShape)
|
||||
.background(PurrfectPalette.cardOverlay)
|
||||
.border(BorderStroke(1.dp, barBorder), barShape)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(18.dp)
|
||||
.align(Alignment.TopCenter)
|
||||
.background(barSheen)
|
||||
.graphicsLayer { alpha = 0.6f }
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.graphicsLayer { alpha = 0.25f }
|
||||
.drawBehind {
|
||||
val radius = size.width * 0.42f
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.2f),
|
||||
Color.Transparent
|
||||
),
|
||||
center = center,
|
||||
radius = radius
|
||||
),
|
||||
radius = radius,
|
||||
center = center
|
||||
)
|
||||
}
|
||||
)
|
||||
Box(Modifier.fillMaxWidth().height(82.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) {
|
||||
val index = selectedRoutes.indexOf(currentRoute)
|
||||
if (index >= 0) index else null // indexOf returns -1 when not found, replace with null
|
||||
}
|
||||
|
||||
selectedIndex?.let { // Null check
|
||||
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 = 2.dp
|
||||
val indicatorWidth = (with(density) { itemWidthPx.toDp() } - horizontalInset * 2)
|
||||
.coerceAtLeast(70.dp)
|
||||
.coerceAtMost(with(density) { itemWidthPx.toDp() })
|
||||
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
|
||||
val indicatorShape = RoundedCornerShape(18.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(indicatorWidth.coerceAtLeast(0.dp))
|
||||
.offset(x = offsetX)
|
||||
.padding(vertical = 10.dp, horizontal = 2.dp)
|
||||
.graphicsLayer { scaleX = scaleXAnim; scaleY = scaleYAnim }
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.clip(indicatorShape)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.42f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.38f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.border(
|
||||
BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))),
|
||||
indicatorShape
|
||||
)
|
||||
.drawBehind {
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
|
||||
Color.Transparent
|
||||
),
|
||||
center = center,
|
||||
radius = size.minDimension
|
||||
),
|
||||
radius = size.minDimension,
|
||||
center = center
|
||||
)
|
||||
drawRoundRect(
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
cornerRadius = CornerRadius(size.height / 2, size.height / 2),
|
||||
size = size
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NavigationBar(
|
||||
containerColor = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 6.dp)
|
||||
) {
|
||||
selectedRoutes.forEach { route ->
|
||||
val isSelected = currentRoute == route
|
||||
val selectionProgress by animateFloatAsState(if (isSelected) 1f else 0f, label = "${route.routeInfo.id}-selection")
|
||||
NavigationBarItem(
|
||||
alwaysShowLabel = true,
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = route.routeInfo.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(22.dp + 2.dp * selectionProgress)
|
||||
.graphicsLayer { alpha = 0.65f + 0.35f * selectionProgress }
|
||||
)
|
||||
},
|
||||
label = {
|
||||
val label = context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"]
|
||||
val isLong = label.length > 11
|
||||
Text(
|
||||
text = label,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White.copy(alpha = 0.6f + 0.4f * selectionProgress),
|
||||
maxLines = if (isLong) 2 else 1,
|
||||
overflow = if (isLong) TextOverflow.Ellipsis else TextOverflow.Clip,
|
||||
softWrap = isLong,
|
||||
modifier = if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally)
|
||||
)
|
||||
},
|
||||
selected = isSelected,
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = Color.White,
|
||||
unselectedIconColor = Color.White.copy(alpha = 0.72f),
|
||||
selectedTextColor = Color.White,
|
||||
unselectedTextColor = Color.White.copy(alpha = 0.72f),
|
||||
indicatorColor = Color.Transparent
|
||||
),
|
||||
onClick = { route.navigateReset() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (openBottomBarCustomization) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { openBottomBarCustomization = false },
|
||||
sheetState = sheetState,
|
||||
containerColor = Color.Transparent,
|
||||
dragHandle = {}
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
shape = RoundedCornerShape(topStart = 26.dp, topEnd = 26.dp),
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
.padding(vertical = 12.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.26f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(horizontal = 18.dp, vertical = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = translation["customize_bottom_bar_title"],
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = translation["customize_bottom_bar_subtitle"],
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(top = 6.dp, bottom = 2.dp)
|
||||
.width(40.dp)
|
||||
.height(5.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.White.copy(alpha = 0.35f))
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = translation["shown_tabs_title"],
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = Color.White
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
if (selectedTabIds.isEmpty()) {
|
||||
Text(
|
||||
text = translation["no_tabs_selected_text"],
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
} else {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var draggingId by remember { mutableStateOf<String?>(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 = context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"]
|
||||
val isDragging = draggingId == id
|
||||
val rowShape = RoundedCornerShape(18.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.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 }
|
||||
)
|
||||
},
|
||||
shape = rowShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (isDragging) Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))
|
||||
else SolidColor(Color.White.copy(alpha = 0.1f))
|
||||
),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(rowShape)
|
||||
.background(
|
||||
if (isDragging) Brush.linearGradient(
|
||||
colors = listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.18f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.16f)
|
||||
)
|
||||
) else SolidColor(Color.White.copy(alpha = 0.08f))
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Filled.DragHandle, contentDescription = null, tint = Color.White.copy(alpha = 0.8f))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Icon(route.routeInfo.icon, contentDescription = null, tint = Color.White)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(text = label, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White)
|
||||
val defaultEligible = remember { setOf("tasks","features","home","social","scripts") }
|
||||
RadioButton(
|
||||
selected = defaultTabId == id,
|
||||
onClick = { if (id in defaultEligible) saveDefault(id) },
|
||||
enabled = id in defaultEligible,
|
||||
colors = RadioButtonDefaults.colors(
|
||||
selectedColor = PurrfectPalette.glowPrimary,
|
||||
unselectedColor = Color.White.copy(alpha = 0.7f),
|
||||
disabledSelectedColor = PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
|
||||
disabledUnselectedColor = Color.White.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
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, tint = Color.White) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(text = translation["available_tabs_title"], style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(horizontal = 16.dp), color = Color.White)
|
||||
Spacer(Modifier.height(6.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 = 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,
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
labelColor = Color.White,
|
||||
leadingIconContentColor = PurrfectPalette.glowSecondary
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
selectedTabIds = defaultOrder
|
||||
saveSelected(selectedTabIds)
|
||||
},
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
|
||||
border = BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)))
|
||||
) {
|
||||
Text(text = translation["reset_button"], style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
Button(
|
||||
onClick = { openBottomBarCustomization = false },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["done_button"], style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun Fab() {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }?.floatingActionButton?.invoke()
|
||||
}
|
||||
@Composable
|
||||
fun NavContent(paddingValues: PaddingValues, startDestination: String) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
routes.getRoutes().filter { it.parentRoute == null }.forEach { route ->
|
||||
val children = routes.getRoutes().filter { it.parentRoute == route }
|
||||
if (children.isEmpty()) {
|
||||
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) } }
|
||||
route.customComposables.invoke(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun FloatingActionButton() = Fab()
|
||||
@Composable fun Content(paddingValues: PaddingValues, startDestination: String) = NavContent(paddingValues, startDestination)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager
|
||||
package me.eternal.purrfectsnap.ui.manager
|
||||
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -11,27 +11,27 @@ import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.ui.manager.pages.FileImportsRoot
|
||||
import me.rhunk.snapenhance.ui.manager.pages.LoggerHistoryRoot
|
||||
import me.rhunk.snapenhance.ui.manager.pages.ManageReposSection
|
||||
import me.rhunk.snapenhance.ui.manager.pages.TasksRootSection
|
||||
import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRootSection
|
||||
import me.rhunk.snapenhance.ui.manager.pages.features.ManageRuleFeature
|
||||
import me.rhunk.snapenhance.ui.manager.pages.home.HomeLogs
|
||||
import me.rhunk.snapenhance.ui.manager.pages.home.HomeRootSection
|
||||
import me.rhunk.snapenhance.ui.manager.pages.home.HomeSettings
|
||||
import me.rhunk.snapenhance.ui.manager.pages.location.BetterLocationRoot
|
||||
import me.rhunk.snapenhance.ui.manager.pages.scripting.ScriptingRootSection
|
||||
import me.rhunk.snapenhance.ui.manager.pages.social.LoggedStories
|
||||
import me.rhunk.snapenhance.ui.manager.pages.social.ManageScope
|
||||
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
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.FileImportsRoot
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.LoggerHistoryRoot
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.ManageReposSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.ManageRuleFeature
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.location.BetterLocationRoot
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.LoggedStories
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.ManageScope
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.MessagingPreview
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.tracker.EditRule
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.tracker.FriendTrackerManagerRoot
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.tracker.FriendTrackerCatalog
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.tracker.ManageFriendTrackerReposSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ManageScriptReposSection
|
||||
|
||||
|
||||
data class RouteInfo(
|
||||
@@ -59,43 +59,44 @@ class Routes(
|
||||
}
|
||||
|
||||
lateinit var navController: NavController
|
||||
lateinit var activityLauncher: me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
|
||||
lateinit var activityLauncher: me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
var navigation: me.eternal.purrfectsnap.ui.manager.Navigation? = null
|
||||
private val routes = mutableListOf<Route>()
|
||||
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 configImportConfirmation = route(RouteInfo(CONFIG_IMPORT_CONFIRMATION_ROUTE), me.eternal.purrfectsnap.ui.manager.pages.features.ConfigImportConfirmationScreen())
|
||||
val configExportSummary = route(RouteInfo(CONFIG_EXPORT_SUMMARY_ROUTE), me.eternal.purrfectsnap.ui.manager.pages.features.ConfigExportSummaryScreen())
|
||||
|
||||
val tasks = route(RouteInfo("tasks", icon = Icons.Default.TaskAlt, primary = true), TasksRootSection())
|
||||
val tasks = route(RouteInfo("tasks", icon = Icons.Default.TaskAlt, primary = true, hasOwnTopBar = true), TasksRootSection())
|
||||
|
||||
val features = route(RouteInfo("features", icon = Icons.Default.Stars, primary = true), FeaturesRootSection())
|
||||
val manageRuleFeature = route(RouteInfo("manage_rule_feature/?rule_type={rule_type}"), ManageRuleFeature()).parent(features)
|
||||
val features = route(RouteInfo("features", icon = Icons.Default.Stars, primary = true, hasOwnTopBar = true), FeaturesRootSection())
|
||||
val manageRuleFeature = route(RouteInfo("manage_rule_feature/?rule_type={rule_type}", hasOwnTopBar = true), ManageRuleFeature()).parent(features)
|
||||
|
||||
val home = route(RouteInfo("home", icon = Icons.Default.Home, primary = true), HomeRootSection())
|
||||
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 viewLoggerHistory = route(RouteInfo(VIEW_LOGGER_HISTORY_ROUTE), LoggerHistoryRoot()).parent(home)
|
||||
val friendTracker = route(RouteInfo("friend_tracker", icon = Icons.Default.PersonSearch), FriendTrackerManagerRoot()).parent(home)
|
||||
val home = route(RouteInfo("home", icon = Icons.Default.Home, primary = true, hasOwnTopBar = true), HomeRootSection())
|
||||
val settings = route(RouteInfo("home_settings", hasOwnTopBar = true), HomeSettings()).parent(home)
|
||||
val homeLogs = route(RouteInfo("home_logs", hasOwnTopBar = true), HomeLogs()).parent(home)
|
||||
val loggerHistory = route(RouteInfo("logger_history", hasOwnTopBar = true), LoggerHistoryRoot()).parent(home)
|
||||
val viewLoggerHistory = route(RouteInfo(VIEW_LOGGER_HISTORY_ROUTE, hasOwnTopBar = true), LoggerHistoryRoot()).parent(home)
|
||||
val friendTracker = route(RouteInfo("friend_tracker", icon = Icons.Default.PersonSearch, hasOwnTopBar = true), 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 friendTrackerConfigExport = route(RouteInfo(FRIEND_TRACKER_CONFIG_EXPORT_ROUTE), me.eternal.purrfectsnap.ui.manager.pages.tracker.FriendTrackerConfigExportScreen())
|
||||
val friendTrackerConfigImport = route(RouteInfo(FRIEND_TRACKER_CONFIG_IMPORT_ROUTE), me.eternal.purrfectsnap.ui.manager.pages.tracker.FriendTrackerConfigImportScreen())
|
||||
val friendTrackerCatalog = route(RouteInfo("friend_tracker_catalog", hasOwnTopBar = true), FriendTrackerCatalog())
|
||||
val manageFriendTrackerRepos = route(RouteInfo("manage_friend_tracker_repos", hasOwnTopBar = true), ManageFriendTrackerReposSection())
|
||||
|
||||
val fileImports = route(RouteInfo("file_imports"), FileImportsRoot()).parent(home)
|
||||
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)
|
||||
val messagingPreview = route(RouteInfo("messaging_preview/?scope={scope}&id={id}"), MessagingPreview()).parent(social)
|
||||
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true, hasOwnTopBar = true), SocialRootSection())
|
||||
val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}", hasOwnTopBar = true), ManageScope()).parent(social)
|
||||
val messagingPreview = route(RouteInfo("messaging_preview/?scope={scope}&id={id}", hasOwnTopBar = true), MessagingPreview()).parent(social)
|
||||
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 scripting = route(RouteInfo("scripts", icon = Icons.Filled.DataObject, primary = true, hasOwnTopBar = true), ScriptingRootSection())
|
||||
val manageScriptRepos = route(RouteInfo("manage_script_repos", hasOwnTopBar = true), ManageScriptReposSection())
|
||||
|
||||
val betterLocation = route(RouteInfo("better_location", showInNavBar = false, primary = true), BetterLocationRoot())
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package me.eternal.purrfectsnap.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.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
|
||||
@Composable
|
||||
fun AestheticDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
title: String,
|
||||
text: String,
|
||||
icon: ImageVector,
|
||||
confirmButtonText: String,
|
||||
onConfirm: () -> Unit,
|
||||
dismissButtonText: String? = null,
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
customContent: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
loading: Boolean = false,
|
||||
opaque: Boolean = false,
|
||||
showCloseButton: Boolean = true,
|
||||
confirmEnabled: Boolean = true
|
||||
) {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { visible = true }
|
||||
|
||||
val surfaceColor = if (opaque) {
|
||||
PurrfectPalette.cardOverlayColor.copy(alpha = 1f)
|
||||
} else {
|
||||
PurrfectPalette.cardOverlayColor
|
||||
}
|
||||
|
||||
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))
|
||||
) {
|
||||
val shape = RoundedCornerShape(22.dp)
|
||||
Card(
|
||||
shape = shape,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
colors = CardDefaults.cardColors(containerColor = surfaceColor)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, shape)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(62.dp)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
|
||||
)
|
||||
),
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (text.isNotBlank()) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
customContent?.invoke(this)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally)
|
||||
) {
|
||||
if (dismissButtonText != null && onDismiss != null) {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) { Text(dismissButtonText) }
|
||||
}
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
enabled = confirmEnabled && !loading,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = Color.White
|
||||
)
|
||||
} else {
|
||||
Text(confirmButtonText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showCloseButton) {
|
||||
IconButton(
|
||||
onClick = onDismissRequest,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
) {
|
||||
Icon(Icons.Default.Close, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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 me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
|
||||
@Composable
|
||||
fun AestheticEmptyState(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
actions: (@Composable () -> Unit)? = null
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(58.dp)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
|
||||
)
|
||||
),
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (actions != null) {
|
||||
Spacer(modifier = Modifier.size(2.dp))
|
||||
actions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
|
||||
@Immutable
|
||||
data class FloatingTopBarColors(
|
||||
val container: Color,
|
||||
val border: Brush
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun rememberDefaultFloatingTopBarColors(): FloatingTopBarColors {
|
||||
return remember {
|
||||
FloatingTopBarColors(
|
||||
container = Color.White.copy(alpha = 0.07f),
|
||||
border = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FloatingTopBar(
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
onBack: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
colors: FloatingTopBarColors = rememberDefaultFloatingTopBarColors()
|
||||
) {
|
||||
val shape = RoundedCornerShape(26.dp)
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
|
||||
shape = shape,
|
||||
color = colors.container,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, colors.border)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(contentAlignment = Alignment.CenterEnd) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
content = actions
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.data
|
||||
package me.eternal.purrfectsnap.ui.manager.data
|
||||
|
||||
|
||||
data class SnapchatAppInfo(
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.data
|
||||
package me.eternal.purrfectsnap.ui.manager.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -9,7 +9,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
@@ -19,17 +19,12 @@ object UpdateDownloader {
|
||||
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)
|
||||
}
|
||||
fetch = run {
|
||||
val fetchConfiguration = FetchConfiguration.Builder(context.applicationContext)
|
||||
.setDownloadConcurrentLimit(3)
|
||||
.build()
|
||||
Fetch.getInstance(fetchConfiguration)
|
||||
}
|
||||
return fetch!!
|
||||
}
|
||||
@@ -101,7 +96,7 @@ object UpdateDownloader {
|
||||
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 uri = FileProvider.getUriForFile(context, "me.eternal.purrfectsnap.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)
|
||||
@@ -1,8 +1,8 @@
|
||||
package me.rhunk.snapenhance.ui.manager.data
|
||||
package me.eternal.purrfectsnap.ui.manager.data
|
||||
|
||||
import com.google.gson.JsonParser
|
||||
import me.rhunk.snapenhance.common.BuildConfig
|
||||
import me.rhunk.snapenhance.common.logger.AbstractLogger
|
||||
import me.eternal.purrfectsnap.common.BuildConfig
|
||||
import me.eternal.purrfectsnap.common.logger.AbstractLogger
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages
|
||||
|
||||
import android.net.Uri
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.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.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
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
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.ui.AsyncUpdateDispatcher
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.openFile
|
||||
import java.text.DateFormat
|
||||
|
||||
class FileImportsRoot: Routes.Route() {
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
private val reloadDispatcher = AsyncUpdateDispatcher()
|
||||
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
override val floatingActionButton: @Composable () -> Unit = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
Row {
|
||||
ExtendedFloatingActionButton(
|
||||
icon = {
|
||||
Icon(Icons.Default.Upload, contentDescription = null)
|
||||
},
|
||||
text = {
|
||||
Text(translation["import_file_button"])
|
||||
},
|
||||
onClick = {
|
||||
context.coroutineScope.launch {
|
||||
activityLauncherHelper.openFile { filePath ->
|
||||
val fileUri = Uri.parse(filePath)
|
||||
runCatching {
|
||||
DocumentFile.fromSingleUri(context.activity!!, fileUri)?.let { file ->
|
||||
if (!file.exists()) {
|
||||
context.shortToast(translation["file_not_found"])
|
||||
return@openFile
|
||||
}
|
||||
context.fileHandleManager.importFile(file.name!!) {
|
||||
context.androidContext.contentResolver.openInputStream(fileUri)?.use { inputStream ->
|
||||
inputStream.copyTo(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to import file", it)
|
||||
context.shortToast(translation.format("file_import_failed", "error" to it.message.toString()))
|
||||
}.onSuccess {
|
||||
context.shortToast(translation["file_imported"])
|
||||
coroutineScope.launch {
|
||||
reloadDispatcher.dispatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val files = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = reloadDispatcher) {
|
||||
context.fileHandleManager.getStoredFiles()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
|
||||
.padding(horizontal = 18.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = PurrfectPalette.cardOverlayColor
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AttachFile,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = translation["import_file_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Text(
|
||||
text = translation["manager.dialogs.file_imports.settings_select_file_hint"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
contentPadding = PaddingValues(bottom = routes.bottomPadding + 16.dp, top = 4.dp)
|
||||
) {
|
||||
item {
|
||||
if (files.isEmpty()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
|
||||
) {
|
||||
Text(
|
||||
text = translation["no_files_hint"],
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 18.dp, vertical = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(files, key = { it }) { file ->
|
||||
val fileInfo by rememberAsyncMutableState(defaultValue = null) {
|
||||
context.fileHandleManager.getFileInfo(file.name)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.16f)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AttachFile,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = file.name,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
fileInfo?.let { (size, lastModified) ->
|
||||
Text(
|
||||
text = "${Formatter.formatFileSize(context.androidContext, size)} • ${
|
||||
DateFormat.getDateTimeInstance().format(lastModified)
|
||||
}",
|
||||
lineHeight = 15.sp,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
context.coroutineScope.launch {
|
||||
if (context.fileHandleManager.deleteFile(file.name)) {
|
||||
files.remove(file)
|
||||
} else {
|
||||
context.shortToast(translation["file_delete_failed"])
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.DeleteOutline,
|
||||
contentDescription = null,
|
||||
tint = PurrfectPalette.glowSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
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 androidx.navigation.NavBackStackEntry
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.bridge.DownloadCallback
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.ConversationInfo
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggedMessage
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerWrapper
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.data.download.DownloadMetadata
|
||||
import me.eternal.purrfectsnap.common.data.download.DownloadRequest
|
||||
import me.eternal.purrfectsnap.common.data.download.MediaDownloadSource
|
||||
import me.eternal.purrfectsnap.common.data.download.createNewFilePath
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.transparentTextFieldColors
|
||||
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
|
||||
import me.eternal.purrfectsnap.common.util.ktx.longHashCode
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.DecodedAttachment
|
||||
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDecoder
|
||||
import me.eternal.purrfectsnap.download.DownloadProcessor
|
||||
import me.eternal.purrfectsnap.storage.findFriend
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import java.net.URLDecoder
|
||||
import java.text.DateFormat
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
|
||||
class LoggerHistoryRoot : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("logger_history") }
|
||||
private lateinit var loggerWrapper: LoggerWrapper
|
||||
private var selectedConversation by mutableStateOf<String?>(null)
|
||||
private var stringFilter by mutableStateOf("")
|
||||
private var reverseOrder by mutableStateOf(true)
|
||||
|
||||
private inline fun decodeMessage(message: LoggedMessage, result: (contentType: ContentType, messageReader: ProtoReader, attachments: List<DecodedAttachment>) -> Unit) {
|
||||
runCatching {
|
||||
val messageObject = JsonParser.parseString(String(message.messageData, Charsets.UTF_8)).asJsonObject
|
||||
val messageContent = messageObject.getAsJsonObject("mMessageContent")
|
||||
val messageReader = messageContent.getAsJsonArray("mContent").map { it.asByte }.toByteArray().let { ProtoReader(it) }
|
||||
result(ContentType.fromMessageContainer(messageReader) ?: ContentType.UNKNOWN, messageReader, MessageDecoder.decode(messageContent))
|
||||
}.onFailure {
|
||||
context.log.error("Failed to decode message", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadAttachment(creationTimestamp: Long, attachment: DecodedAttachment) {
|
||||
context.shortToast(translation["download_started_toast"])
|
||||
val attachmentHash = attachment.mediaUniqueId!!.longHashCode().absoluteValue.toString()
|
||||
|
||||
DownloadProcessor(
|
||||
remoteSideContext = context,
|
||||
callback = object: DownloadCallback.Default() {
|
||||
override fun onSuccess(outputPath: String?) {
|
||||
context.shortToast(translation.format("download_success_toast", "path" to outputPath.toString()))
|
||||
}
|
||||
|
||||
override fun onFailure(message: String?, throwable: String?) {
|
||||
context.shortToast(translation.format("download_failed_toast", "message" to message.toString()))
|
||||
}
|
||||
}
|
||||
).enqueue(
|
||||
DownloadRequest(
|
||||
inputMedias = arrayOf(attachment.createInputMedia()!!)
|
||||
),
|
||||
DownloadMetadata(
|
||||
mediaIdentifier = attachmentHash,
|
||||
outputPath = createNewFilePath(
|
||||
context.config.root,
|
||||
attachment.mediaUniqueId!!,
|
||||
MediaDownloadSource.MESSAGE_LOGGER,
|
||||
attachmentHash,
|
||||
creationTimestamp
|
||||
),
|
||||
iconUrl = null,
|
||||
mediaAuthor = null,
|
||||
downloadSource = MediaDownloadSource.MESSAGE_LOGGER.translate(context.translation),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun MessageView(message: LoggedMessage) {
|
||||
var contentView by remember { mutableStateOf<@Composable () -> Unit>({
|
||||
Spacer(modifier = Modifier.height(30.dp))
|
||||
}) }
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
contentView()
|
||||
|
||||
LaunchedEffect(Unit, message) {
|
||||
runCatching {
|
||||
decodeMessage(message) { contentType, messageReader, attachments ->
|
||||
@Composable
|
||||
fun ContentHeader() {
|
||||
val date = remember { DateFormat.getDateTimeInstance().format(message.sendTimestamp) }
|
||||
Text(
|
||||
translation.format("log_header_format", "username" to message.username, "type" to contentType.toString().lowercase(), "date" to date),
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
fontWeight = FontWeight.ExtraLight,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
|
||||
if (contentType == ContentType.CHAT) {
|
||||
val content = messageReader.getString(2, 1) ?: "[${translation["empty_message"]}]"
|
||||
contentView = {
|
||||
Column {
|
||||
Text(
|
||||
content,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(onLongPress = {
|
||||
context.androidContext.copyToClipboard(content)
|
||||
})
|
||||
},
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
val edits by rememberAsyncMutableState(defaultValue = emptyList()) {
|
||||
loggerWrapper.getChatEdits(selectedConversation!!, message.messageId)
|
||||
}
|
||||
edits.forEach { messageEdit ->
|
||||
val date = remember {
|
||||
DateFormat.getDateTimeInstance().format(messageEdit.timestamp)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.pointerInput(Unit) {
|
||||
detectTapGestures(onLongPress = {
|
||||
context.androidContext.copyToClipboard(messageEdit.message)
|
||||
})
|
||||
}.fillMaxWidth().padding(start = 4.dp),
|
||||
text = translation.format("edited_at_text", "message" to messageEdit.message, "date" to date),
|
||||
fontWeight = FontWeight.Light,
|
||||
fontStyle = FontStyle.Italic,
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
ContentHeader()
|
||||
}
|
||||
}
|
||||
return@runCatching
|
||||
}
|
||||
contentView = {
|
||||
Column column@{
|
||||
if (attachments.isEmpty()) return@column
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
attachments.forEachIndexed { index, attachment ->
|
||||
Button(
|
||||
onClick = {
|
||||
context.coroutineScope.launch {
|
||||
runCatching {
|
||||
downloadAttachment(message.sendTimestamp, attachment)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to download attachment", it)
|
||||
context.shortToast(translation["download_attachment_failed_toast"])
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
contentDescription = translation["download_button"],
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
Text(translation.format("chat_attachment", "index" to (index + 1).toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
ContentHeader()
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to parse message", it)
|
||||
contentView = {
|
||||
Text(translation["message_parse_failed"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { navBackStackEntry ->
|
||||
LaunchedEffect(Unit) {
|
||||
val uri = navBackStackEntry.arguments?.getString("uri")?.let {
|
||||
runCatching {
|
||||
URLDecoder.decode(it, "UTF-8").toUri()
|
||||
}.getOrNull()
|
||||
}
|
||||
loggerWrapper = LoggerWrapper(context.androidContext, uri)
|
||||
}
|
||||
|
||||
val conversationInfoCache = remember { ConcurrentHashMap<String, String?>() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
FloatingTopBar(
|
||||
title = context.translation["manager.routes.logger_history"] ?: "Logger History",
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp, bottom = 10.dp),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
) {
|
||||
fun formatConversationInfo(conversationInfo: ConversationInfo?): String? {
|
||||
if (conversationInfo == null) return null
|
||||
|
||||
return conversationInfo.groupTitle?.let {
|
||||
translation.format("list_group_format", "name" to it)
|
||||
} ?: conversationInfo.usernames.takeIf { it.size > 1 }?.let {
|
||||
translation.format("list_friend_format", "name" to ("(" + it.joinToString(", ") + ")"))
|
||||
} ?: context.database.findFriend(conversationInfo.conversationId)?.let {
|
||||
translation.format("list_friend_format", "name" to "(" + (conversationInfo.usernames + listOf(it.mutableUsername)).toSet().joinToString(", ") + ")")
|
||||
} ?: conversationInfo.usernames.firstOrNull()?.let {
|
||||
translation.format("list_friend_format", "name" to "($it)")
|
||||
}
|
||||
}
|
||||
|
||||
val selectedConversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(selectedConversation)) {
|
||||
selectedConversation?.let {
|
||||
conversationInfoCache.getOrPut(it) {
|
||||
formatConversationInfo(loggerWrapper.getConversationInfo(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = selectedConversationInfo ?: translation["select_conversation_placeholder"],
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier = Modifier
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
cursorColor = PurrfectPalette.glowSecondary
|
||||
)
|
||||
)
|
||||
|
||||
val conversations by rememberAsyncMutableState(defaultValue = emptyList<String>()) {
|
||||
loggerWrapper.getAllConversations().toMutableList()
|
||||
}
|
||||
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
conversations.forEach { conversationId ->
|
||||
DropdownMenuItem(onClick = {
|
||||
selectedConversation = conversationId
|
||||
expanded = false
|
||||
}, text = {
|
||||
val conversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(conversationId)) {
|
||||
conversationInfoCache.getOrPut(conversationId) {
|
||||
formatConversationInfo(loggerWrapper.getConversationInfo(conversationId))
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = remember(conversationInfo) { conversationInfo ?: conversationId },
|
||||
fontWeight = if (conversationId == selectedConversation) FontWeight.Bold else FontWeight.Normal,
|
||||
color = Color.White,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = stringFilter,
|
||||
onValueChange = { stringFilter = it },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search",
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = null,
|
||||
tint = PurrfectPalette.textSecondary
|
||||
)
|
||||
},
|
||||
trailingIcon = if (stringFilter.isNotBlank()) {
|
||||
{
|
||||
IconButton(onClick = { stringFilter = "" }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = translation["close_button_description"],
|
||||
tint = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
} else null,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
cursorColor = PurrfectPalette.glowSecondary
|
||||
)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
translation["reverse_order_checkbox"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
Checkbox(
|
||||
checked = reverseOrder,
|
||||
onCheckedChange = { reverseOrder = it },
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = PurrfectPalette.glowPrimary,
|
||||
checkmarkColor = Color.White,
|
||||
uncheckedColor = Color.White.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hasReachedEnd by remember(selectedConversation, stringFilter, reverseOrder) { mutableStateOf(false) }
|
||||
var lastFetchMessageTimestamp by remember(selectedConversation, stringFilter, reverseOrder) { mutableLongStateOf(if (reverseOrder) Long.MAX_VALUE else Long.MIN_VALUE) }
|
||||
val messages = remember(selectedConversation, stringFilter, reverseOrder) { mutableStateListOf<LoggedMessage>() }
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(bottom = routes.bottomPadding)
|
||||
) {
|
||||
items(messages) { message ->
|
||||
MessageView(message)
|
||||
}
|
||||
item {
|
||||
if (selectedConversation != null) {
|
||||
if (hasReachedEnd) {
|
||||
Text(translation["no_more_messages"], modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.fillMaxWidth(), textAlign = TextAlign.Center)
|
||||
} else {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.height(20.dp)
|
||||
.padding(8.dp),
|
||||
color = PurrfectPalette.glowSecondary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit, selectedConversation, stringFilter, reverseOrder) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val newMessages = loggerWrapper.fetchMessages(
|
||||
selectedConversation ?: return@withContext,
|
||||
lastFetchMessageTimestamp,
|
||||
30,
|
||||
reverseOrder
|
||||
) { messageData ->
|
||||
if (stringFilter.isEmpty()) return@fetchMessages true
|
||||
var isMatch = false
|
||||
decodeMessage(messageData) { contentType, messageReader, _ ->
|
||||
if (contentType == ContentType.CHAT) {
|
||||
val content = messageReader.getString(2, 1) ?: return@decodeMessage
|
||||
isMatch = content.contains(stringFilter, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
isMatch
|
||||
}
|
||||
if (newMessages.isEmpty()) {
|
||||
hasReachedEnd = true
|
||||
return@withContext
|
||||
}
|
||||
lastFetchMessageTimestamp = newMessages.lastOrNull()?.sendTimestamp ?: return@withContext
|
||||
withContext(Dispatchers.Main) {
|
||||
messages.addAll(newMessages)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
package me.rhunk.snapenhance.ui.manager.pages
|
||||
package me.eternal.purrfectsnap.ui.manager.pages
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -23,15 +23,15 @@ import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.rhunk.snapenhance.common.data.RepositoryIndex
|
||||
import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
|
||||
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
|
||||
import me.rhunk.snapenhance.common.util.ktx.copyToClipboard
|
||||
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.eternal.purrfectsnap.common.data.RepositoryIndex
|
||||
import me.eternal.purrfectsnap.common.ui.AsyncUpdateDispatcher
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getUrlFromClipboard
|
||||
import me.eternal.purrfectsnap.storage.addRepo
|
||||
import me.eternal.purrfectsnap.storage.getRepositories
|
||||
import me.eternal.purrfectsnap.storage.removeRepo
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class ManageReposSection: Routes.Route() {
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.pages.features
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.features
|
||||
|
||||
typealias ClickCallback = (Boolean) -> Unit
|
||||
typealias RegisterClickCallback = (ClickCallback) -> ClickCallback
|
||||
@@ -0,0 +1,381 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.features
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.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.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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 me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.saveFile
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class ConfigExportSummaryScreen : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.features.config_export") }
|
||||
|
||||
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<String, List<ImportedFeature>> {
|
||||
val featureList = mutableListOf<ImportedFeature>()
|
||||
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, translation["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) {
|
||||
if (v.isBlank()) {
|
||||
val emptyKey = "features.options.$featureKey.empty"
|
||||
val translatedEmpty = context.translation[emptyKey]
|
||||
val fallback = context.translation["features.options.empty"]?.takeUnless { it == "features.options.empty" }
|
||||
return if (!translatedEmpty.isNullOrBlank() && translatedEmpty != emptyKey && !translatedEmpty.startsWith("features.")) translatedEmpty else (fallback ?: "Empty")
|
||||
}
|
||||
val translationKey = "features.options.$featureKey.$v"
|
||||
val translated = context.translation[translationKey]
|
||||
return if (!translated.isNullOrBlank() && translated != translationKey && !translated.startsWith("features.")) translated else v
|
||||
}
|
||||
return v.toString()
|
||||
}
|
||||
return when (value) {
|
||||
is Boolean -> if (value) translation["enabled"] else translation["disabled"]
|
||||
is JSONArray -> {
|
||||
val list = mutableListOf<String>()
|
||||
for (i in 0 until value.length()) {
|
||||
list.add(innerParse(value.get(i)))
|
||||
}
|
||||
list
|
||||
}
|
||||
else -> innerParse(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<String, Boolean>() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = translation["back_button_description"],
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = translation["title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
IconButton(
|
||||
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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDownward,
|
||||
contentDescription = translation["save_button"],
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp),
|
||||
contentPadding = PaddingValues(
|
||||
top = 8.dp,
|
||||
bottom = 16.dp + routes.bottomPadding
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
items(featuresByCategory.toList()) { (category, features) ->
|
||||
val isExpanded = expandedState[category] ?: false
|
||||
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expandedState[category] = !isExpanded },
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.32f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = category,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { expandedState[category] = !isExpanded }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = translation["expand_button_description"],
|
||||
modifier = Modifier.graphicsLayer(rotationZ = rotationState),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = isExpanded) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(top = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(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),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = feature.name,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
parsedValue.forEachIndexed { itemIndex, item ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
NumberBubble(itemIndex + 1)
|
||||
Text(
|
||||
text = item.toString(),
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is String -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = (feature.indentation * 16).dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = feature.name,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = parsedValue,
|
||||
color = PurrfectPalette.glowSecondary,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (index < features.size - 1) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NumberBubble(number: Int) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 6.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.5f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(number.toString(), color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.features
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.features.config_import") }
|
||||
|
||||
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<String, List<ImportedFeature>> {
|
||||
val featureList = mutableListOf<ImportedFeature>()
|
||||
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,
|
||||
translation["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) {
|
||||
if (v.isBlank()) {
|
||||
val emptyKey = "features.options.$featureKey.empty"
|
||||
val translatedEmpty = context.translation[emptyKey]
|
||||
val fallback = context.translation["features.options.empty"]?.takeUnless { it == "features.options.empty" }
|
||||
return if (!translatedEmpty.isNullOrBlank() && translatedEmpty != emptyKey && !translatedEmpty.startsWith("features.")) translatedEmpty else (fallback ?: "Empty")
|
||||
}
|
||||
val translationKey = "features.options.$featureKey.$v"
|
||||
val translated = context.translation[translationKey]
|
||||
return if (!translated.isNullOrBlank() && translated != translationKey && !translated.startsWith("features.")) translated else v
|
||||
}
|
||||
return v.toString()
|
||||
}
|
||||
return when (value) {
|
||||
is Boolean -> if (value) translation["enabled"] else translation["disabled"]
|
||||
is JSONArray -> {
|
||||
val list = mutableListOf<String>()
|
||||
for (i in 0 until value.length()) {
|
||||
list.add(innerParse(value.get(i)))
|
||||
}
|
||||
list
|
||||
}
|
||||
else -> innerParse(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<String, Boolean>() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = translation["back_button_description"],
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = translation["title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
routes.configJsonForImport?.let { json ->
|
||||
runCatching {
|
||||
context.config.loadFromString(json)
|
||||
}.onFailure { err ->
|
||||
context.longToast(
|
||||
context.translation.format(
|
||||
"config_import_failure_toast",
|
||||
"error" to (err.message ?: "Unknown error")
|
||||
)
|
||||
)
|
||||
}
|
||||
context.shortToast(translation["config_imported_toast"])
|
||||
context.coroutineScope.launch(Dispatchers.Main) {
|
||||
routes.features.navigateReload()
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(translation["confirm_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp),
|
||||
contentPadding = PaddingValues(
|
||||
top = 8.dp,
|
||||
bottom = 16.dp + routes.bottomPadding
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
items(featuresByCategory.toList()) { (category, features) ->
|
||||
val isExpanded = expandedState[category] ?: false
|
||||
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expandedState[category] = !isExpanded },
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.32f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = category,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { expandedState[category] = !isExpanded }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = translation["expand_button_description"],
|
||||
modifier = Modifier.graphicsLayer(rotationZ = rotationState),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = isExpanded) {
|
||||
Column(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(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),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = feature.name,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
parsedValue.forEachIndexed { itemIndex, item ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
NumberBubble(itemIndex + 1)
|
||||
Text(
|
||||
text = item.toString(),
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is String -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = (feature.indentation * 16).dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = feature.name,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = parsedValue,
|
||||
color = PurrfectPalette.glowSecondary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (index < features.size - 1) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NumberBubble(number: Int) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 6.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.5f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(number.toString(), color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,402 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.features
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.GroupAdd
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.RadioButtonDefaults
|
||||
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.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.data.RuleState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
|
||||
import me.eternal.purrfectsnap.storage.clearRuleIds
|
||||
import me.eternal.purrfectsnap.storage.getRuleIds
|
||||
import me.eternal.purrfectsnap.storage.setRule
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.AddFriendDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.AddFriendDialog.Actions
|
||||
|
||||
class ManageRuleFeature : Routes.Route() {
|
||||
override val title: @Composable () -> Unit = {
|
||||
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
|
||||
val text = remember(navBackStackEntry) {
|
||||
navBackStackEntry?.arguments?.getString("rule_type")?.let { ruleType ->
|
||||
MessagingRuleType.getByName(ruleType)?.let {
|
||||
context.config.root.rules.getPropertyPair(it.key).let {
|
||||
context.translation[it.key.propertyName()]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
text?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SelectRuleTypeRadio(
|
||||
checked: Boolean,
|
||||
text: String,
|
||||
onStateChanged: (Boolean) -> Unit,
|
||||
selectedBlock: @Composable () -> Unit = {},
|
||||
) {
|
||||
val shape = RoundedCornerShape(22.dp)
|
||||
val border = if (checked) {
|
||||
Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.8f), PurrfectPalette.glowSecondary.copy(alpha = 0.7f)))
|
||||
} else {
|
||||
Brush.linearGradient(listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.12f)))
|
||||
}
|
||||
Surface(
|
||||
shape = shape,
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, border),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onStateChanged(!checked) }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, shape)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = checked,
|
||||
onClick = null,
|
||||
colors = RadioButtonDefaults.colors(
|
||||
selectedColor = PurrfectPalette.glowSecondary,
|
||||
unselectedColor = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text, color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
if (checked) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 44.dp, end = 6.dp, bottom = 2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
selectedBlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = content@{ navBackStackEntry ->
|
||||
val currentRuleType = navBackStackEntry.arguments?.getString("rule_type")?.let {
|
||||
MessagingRuleType.getByName(it)
|
||||
} ?: return@content
|
||||
|
||||
var ruleState by remember {
|
||||
mutableStateOf(context.config.root.rules.getRuleState(currentRuleType))
|
||||
}
|
||||
|
||||
val propertyKeyPair = remember {
|
||||
context.config.root.rules.getPropertyPair(currentRuleType.key)
|
||||
}
|
||||
|
||||
val updateDispatcher = rememberAsyncUpdateDispatcher()
|
||||
val currentRuleIds by rememberAsyncMutableState(defaultValue = mutableListOf(), updateDispatcher = updateDispatcher) {
|
||||
context.database.getRuleIds(currentRuleType.key)
|
||||
}
|
||||
|
||||
fun setRuleState(newState: RuleState?) {
|
||||
ruleState = newState
|
||||
propertyKeyPair.value.setAny(newState?.key ?: "null")
|
||||
context.coroutineScope.launch {
|
||||
context.config.writeConfig(dispatchConfigListener = false)
|
||||
}
|
||||
}
|
||||
|
||||
var addFriendDialog by remember { mutableStateOf(null as AddFriendDialog?) }
|
||||
|
||||
LaunchedEffect(addFriendDialog) {
|
||||
if (addFriendDialog == null) {
|
||||
updateDispatcher.dispatch()
|
||||
}
|
||||
}
|
||||
|
||||
fun showAddFriendDialog() {
|
||||
addFriendDialog = AddFriendDialog(
|
||||
context = context,
|
||||
pinnedIds = currentRuleIds,
|
||||
actionHandler = Actions(
|
||||
onFriendState = { friend, state ->
|
||||
context.database.setRule(friend.userId, currentRuleType.key, state)
|
||||
if (state) {
|
||||
currentRuleIds.add(friend.userId)
|
||||
} else {
|
||||
currentRuleIds.remove(friend.userId)
|
||||
}
|
||||
},
|
||||
onGroupState = { group, state ->
|
||||
context.database.setRule(group.conversationId, currentRuleType.key, state)
|
||||
if (state) {
|
||||
currentRuleIds.add(group.conversationId)
|
||||
} else {
|
||||
currentRuleIds.remove(group.conversationId)
|
||||
}
|
||||
},
|
||||
getFriendState = { friend ->
|
||||
currentRuleIds.contains(friend.userId)
|
||||
},
|
||||
getGroupState = { group ->
|
||||
currentRuleIds.contains(group.conversationId)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (addFriendDialog != null) {
|
||||
addFriendDialog?.Content {
|
||||
addFriendDialog = null
|
||||
}
|
||||
}
|
||||
|
||||
var confirmationDialog by remember { mutableStateOf(false) }
|
||||
if (confirmationDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { confirmationDialog = false },
|
||||
title = translation["clear_list_button"],
|
||||
text = translation["dialog_clear_confirmation_text"],
|
||||
icon = Icons.Default.DeleteSweep,
|
||||
confirmButtonText = "Clear",
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
onDismiss = { confirmationDialog = false },
|
||||
onConfirm = {
|
||||
context.database.clearRuleIds(currentRuleType.key)
|
||||
context.coroutineScope.launch(context.database.executor.asCoroutineDispatcher()) {
|
||||
updateDispatcher.dispatch()
|
||||
}
|
||||
confirmationDialog = false
|
||||
},
|
||||
opaque = true,
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
var topBarHeight by remember { mutableStateOf(96.dp) }
|
||||
FloatingTopBar(
|
||||
title = remember { context.translation[propertyKeyPair.key.propertyName()] },
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
.onGloballyPositioned {
|
||||
val newHeight = with(density) { it.size.height.toDp() }
|
||||
if (newHeight != topBarHeight) topBarHeight = newHeight
|
||||
}
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = topBarHeight + 10.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
val headerShape = RoundedCornerShape(22.dp)
|
||||
Surface(
|
||||
shape = headerShape,
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, headerShape)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = context.translation[propertyKeyPair.key.propertyDescription()],
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == null,
|
||||
text = translation["disable_state_option"],
|
||||
onStateChanged = { setRuleState(null) }
|
||||
) {
|
||||
Text(text = translation["disable_state_subtext"], fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
|
||||
val manageLabel = when (ruleState) {
|
||||
RuleState.WHITELIST -> translation["whitelist_state_button"]
|
||||
RuleState.BLACKLIST -> translation["blacklist_state_button"]
|
||||
else -> null
|
||||
}
|
||||
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == RuleState.WHITELIST,
|
||||
text = translation["whitelist_state_option"],
|
||||
onStateChanged = { setRuleState(RuleState.WHITELIST) }
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Button(
|
||||
onClick = { showAddFriendDialog() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["whitelist_state_button"])
|
||||
}
|
||||
}
|
||||
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == RuleState.BLACKLIST,
|
||||
text = translation["blacklist_state_option"],
|
||||
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Button(
|
||||
onClick = { showAddFriendDialog() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["blacklist_state_button"])
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
modifier = Modifier.size(46.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = translation["clear_list_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!manageLabel.isNullOrBlank()) {
|
||||
Text(
|
||||
text = manageLabel,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { confirmationDialog = true },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = "Clear")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(routes.bottomPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.home
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.outlined.BugReport
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.Report
|
||||
import androidx.compose.material.icons.outlined.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.LogLine
|
||||
import me.eternal.purrfectsnap.LogReader
|
||||
import me.eternal.purrfectsnap.common.logger.LogChannel
|
||||
import me.eternal.purrfectsnap.common.logger.LogLevel
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
|
||||
import me.eternal.purrfectsnap.ui.util.pullrefresh.pullRefresh
|
||||
import me.eternal.purrfectsnap.ui.util.pullrefresh.rememberPullRefreshState
|
||||
import me.eternal.purrfectsnap.ui.util.saveFile
|
||||
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
|
||||
|
||||
class HomeLogs : Routes.Route() {
|
||||
private val logListState by lazy { LazyListState(0) }
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
private val externalRefreshTick = mutableStateOf(0)
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
private fun clearLogsAndReload() {
|
||||
context.coroutineScope.launch {
|
||||
context.log.clearLogs()
|
||||
withContext(Dispatchers.Main) {
|
||||
navigateReload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportLogs() {
|
||||
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 {
|
||||
runCatching {
|
||||
context.log.exportLogsToZip(it)
|
||||
context.longToast(translation["saved_logs_success_toast"])
|
||||
}.onFailure { error ->
|
||||
context.longToast(translation["saved_logs_failure_toast"])
|
||||
context.log.error("Failed to save logs to $uri!", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val topBarActions: @Composable (RowScope.() -> Unit) = {}
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val composeContext = LocalContext.current
|
||||
var logReader by remember { mutableStateOf<LogReader?>(null) }
|
||||
val visibleLogs = remember { mutableStateListOf<LogLine>() }
|
||||
val mainExecutor = remember { context.androidContext.mainExecutor }
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
fun refreshLogs() {
|
||||
coroutineScope.launch {
|
||||
val readerResult = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
context.log.newReader { line ->
|
||||
if (shouldHideLog(line)) return@newReader
|
||||
mainExecutor.execute {
|
||||
visibleLogs.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
readerResult.onFailure {
|
||||
context.longToast("Failed to read logs!")
|
||||
}
|
||||
readerResult.getOrNull()?.let { reader ->
|
||||
logReader = reader
|
||||
val filteredLogs = withContext(Dispatchers.IO) {
|
||||
(0 until reader.lineCount).mapNotNull { index ->
|
||||
reader.getLogLine(index)?.takeUnless(::shouldHideLog)
|
||||
}
|
||||
}
|
||||
visibleLogs.clear()
|
||||
visibleLogs.addAll(filteredLogs)
|
||||
}
|
||||
delay(220)
|
||||
if (visibleLogs.isNotEmpty()) {
|
||||
val targetIndex = (visibleLogs.size - 1).coerceAtLeast(0)
|
||||
logListState.scrollToItem(targetIndex)
|
||||
}
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(externalRefreshTick.value) {
|
||||
if (externalRefreshTick.value > 0) {
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
}
|
||||
}
|
||||
val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh = {
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
})
|
||||
LaunchedEffect(Unit) {
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
.pullRefresh(pullRefreshState)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LogsFloatingBar(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
},
|
||||
onExport = { exportLogs() },
|
||||
onClear = { clearLogsAndReload() }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
if (visibleLogs.isEmpty() && logReader != null) {
|
||||
EmptyLogsState()
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
state = logListState,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
top = 12.dp,
|
||||
bottom = routes.bottomPadding + 22.dp
|
||||
)
|
||||
) {
|
||||
items(visibleLogs, key = { it.hashCode() }) { line ->
|
||||
LogEntryCard(line = line, composeContext = composeContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PullRefreshIndicator(
|
||||
refreshing = isRefreshing,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
override val floatingActionButton: @Composable () -> Unit = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
val firstVisibleItem by remember { derivedStateOf { logListState.firstVisibleItemIndex } }
|
||||
val layoutInfo by remember { derivedStateOf { logListState.layoutInfo } }
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(6.dp)
|
||||
) {
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
logListState.scrollToItem(0)
|
||||
}
|
||||
},
|
||||
enabled = firstVisibleItem != 0
|
||||
) {
|
||||
Icon(Icons.Filled.KeyboardDoubleArrowUp, contentDescription = null)
|
||||
}
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
logListState.scrollToItem((logListState.layoutInfo.totalItemsCount - 1).takeIf { it >= 0 } ?: return@launch)
|
||||
}
|
||||
},
|
||||
enabled = layoutInfo.visibleItemsInfo.lastOrNull()?.index != layoutInfo.totalItemsCount - 1
|
||||
) {
|
||||
Icon(Icons.Filled.KeyboardDoubleArrowDown, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogsFloatingBar(
|
||||
isRefreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
onExport: () -> Unit,
|
||||
onClear: () -> Unit
|
||||
) {
|
||||
var showDropDown by remember { mutableStateOf(false) }
|
||||
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = topPadding),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
Text(
|
||||
text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_logs"] ?: "Logs",
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.ExtraBold
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = PurrfectPalette.glowSecondary
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showDropDown = true }) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = PurrfectPalette.glowSecondary)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showDropDown,
|
||||
onDismissRequest = { showDropDown = false },
|
||||
offset = DpOffset(0.dp, 8.dp),
|
||||
containerColor = Color(0xFF161821),
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 12.dp,
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onClear()
|
||||
showDropDown = false
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
|
||||
text = { Text(translation["clear_logs_button"], color = Color.White) },
|
||||
colors = MenuDefaults.itemColors(
|
||||
textColor = Color.White,
|
||||
leadingIconColor = PurrfectPalette.glowPrimary
|
||||
)
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onExport()
|
||||
showDropDown = false
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
|
||||
text = { Text(translation["export_logs_button"], color = Color.White) },
|
||||
colors = MenuDefaults.itemColors(
|
||||
textColor = Color.White,
|
||||
leadingIconColor = PurrfectPalette.glowSecondary
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyLogsState() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 18.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = Color.White.copy(alpha = 0.1f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Info,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(14.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = translation["no_logs_hint"],
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "Pull to refresh or trigger an action to see new entries.",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogEntryCard(line: LogLine, composeContext: android.content.Context) {
|
||||
// Normalize overly fragmented log text (some entries were rendered with one character per line)
|
||||
val normalizedMessage = remember(line.message) {
|
||||
val cleaned = line.message.replace("\r", "")
|
||||
val fragments = cleaned.lines()
|
||||
if (fragments.size > 3 && fragments.count { it.length <= 2 } > fragments.size / 2) {
|
||||
fragments.joinToString("") { it.trim() }
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
val levelColor = logLevelColor(line.logLevel)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize()
|
||||
.pointerInput(line.hashCode()) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
composeContext.copyToClipboard(line.message)
|
||||
}
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.05f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, levelColor.copy(alpha = 0.4f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(6.dp)
|
||||
.fillMaxHeight()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(levelColor, levelColor.copy(alpha = 0.35f))
|
||||
),
|
||||
shape = RoundedCornerShape(topStart = 18.dp, bottomStart = 18.dp)
|
||||
)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = levelColor.copy(alpha = 0.18f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, levelColor.copy(alpha = 0.45f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = logLevelIcon(line.logLevel),
|
||||
contentDescription = null,
|
||||
tint = levelColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = logLevelLabel(line.logLevel),
|
||||
color = levelColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = LogChannel.fromChannel(line.tag)?.shortName ?: line.tag,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = line.dateTime,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = normalizedMessage,
|
||||
color = Color.White,
|
||||
lineHeight = 16.sp,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun logLevelColor(logLevel: LogLevel): Color = when (logLevel) {
|
||||
LogLevel.DEBUG -> PurrfectPalette.glowSecondary
|
||||
LogLevel.INFO, LogLevel.VERBOSE -> Color(0xFFA3F0C2)
|
||||
LogLevel.WARN -> Color(0xFFFFD782)
|
||||
LogLevel.ERROR, LogLevel.ASSERT -> Color(0xFFFF9CAB)
|
||||
}
|
||||
|
||||
private fun logLevelLabel(logLevel: LogLevel): String = when (logLevel) {
|
||||
LogLevel.DEBUG -> "Debug"
|
||||
LogLevel.INFO -> "Info"
|
||||
LogLevel.VERBOSE -> "Verbose"
|
||||
LogLevel.WARN -> "Warning"
|
||||
LogLevel.ERROR -> "Error"
|
||||
LogLevel.ASSERT -> "Assert"
|
||||
}
|
||||
|
||||
private fun logLevelIcon(logLevel: LogLevel) = when (logLevel) {
|
||||
LogLevel.DEBUG -> Icons.Outlined.BugReport
|
||||
LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report
|
||||
LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info
|
||||
LogLevel.WARN -> Icons.Outlined.Warning
|
||||
}
|
||||
|
||||
private fun shouldHideLog(line: LogLine): Boolean {
|
||||
val message = line.message.lowercase()
|
||||
val tag = line.tag.lowercase()
|
||||
return message.startsWith("blocked ep") ||
|
||||
message.startsWith("allowed ep") ||
|
||||
message.startsWith("blocked call") ||
|
||||
message.contains("detection keyword matched") ||
|
||||
message.startsWith("enc:v1:") ||
|
||||
message.contains("endpointsblocker") ||
|
||||
tag.contains("endpointsblocker") ||
|
||||
message.contains("securityfeatures") ||
|
||||
tag.contains("securityfeatures")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,755 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.home
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.action.EnumAction
|
||||
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.storage.getAllScopeNotes
|
||||
import me.eternal.purrfectsnap.storage.setAllScopeNotes
|
||||
import me.eternal.purrfectsnap.task.UpdateCheckWorker
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.Requirements
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.AlertDialogs
|
||||
import me.eternal.purrfectsnap.ui.util.openFile
|
||||
import me.eternal.purrfectsnap.ui.util.saveFile
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class HomeSettings : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.home_settings") }
|
||||
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 inputData = Data.Builder()
|
||||
.putString("channel_name", translation["update_notification_channel_name"])
|
||||
.putString("channel_description", translation["update_notification_channel_description"])
|
||||
.putString("notification_title", translation["update_notification_title"])
|
||||
.putString("notification_text", translation["update_notification_text"])
|
||||
.build()
|
||||
|
||||
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(repeatInterval, TimeUnit.DAYS)
|
||||
.setConstraints(constraints)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
"purrfectsnap_update_check",
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
workRequest
|
||||
)
|
||||
} else {
|
||||
workManager.cancelUniqueWork("purrfectsnap_update_check")
|
||||
}
|
||||
}
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
@Composable
|
||||
private fun RowTitle(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
private fun PremiumPreferenceToggle(
|
||||
sharedPreferences: SharedPreferences,
|
||||
key: String,
|
||||
text: String,
|
||||
defaultValue: Boolean = false
|
||||
) {
|
||||
val realKey = "debug_$key"
|
||||
var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, defaultValue)) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
LaunchedEffect(realKey) {
|
||||
if (!sharedPreferences.contains(realKey)) {
|
||||
sharedPreferences.edit().putBoolean(realKey, defaultValue).apply()
|
||||
value = defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
putBoolean(realKey, value)
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = text, modifier = Modifier.padding(start = 26.dp, end = 16.dp), fontSize = 14.sp)
|
||||
Switch(
|
||||
checked = value,
|
||||
onCheckedChange = null,
|
||||
modifier = Modifier.padding(end = 26.dp),
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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() {
|
||||
putBoolean(realKey, value)
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = text, modifier = Modifier.padding(start = 26.dp, end = 16.dp), fontSize = 14.sp)
|
||||
Switch(
|
||||
checked = value,
|
||||
onCheckedChange = null,
|
||||
modifier = Modifier.padding(end = 26.dp),
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) {
|
||||
var confirmationDialog by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
fun takeAction() {
|
||||
if (requireConfirmation) {
|
||||
confirmationDialog = true
|
||||
} else {
|
||||
action()
|
||||
}
|
||||
}
|
||||
if (requireConfirmation && confirmationDialog) {
|
||||
Dialog(onDismissRequest = { confirmationDialog = false }) {
|
||||
dialogs.ConfirmDialog(title = context.translation["manager.dialogs.action_confirm.title"], onConfirm = {
|
||||
action()
|
||||
confirmationDialog = false
|
||||
}, onDismiss = {
|
||||
confirmationDialog = false
|
||||
})
|
||||
}
|
||||
}
|
||||
ShiftedRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 55.dp)
|
||||
.clickable {
|
||||
takeAction()
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(text = context.translation["actions.$key.name"], fontSize = 16.sp, fontWeight = FontWeight.Bold, lineHeight = 20.sp)
|
||||
context.translation.getOrNull("actions.$key.description")?.let { Text(text = it, fontSize = 12.sp, fontWeight = FontWeight.Light, lineHeight = 15.sp) }
|
||||
}
|
||||
IconButton(onClick = { takeAction() },
|
||||
modifier = Modifier.padding(end = 2.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = context.translation.getOrNull("actions.$key.name"),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun ShiftedRow(
|
||||
modifier: Modifier = Modifier,
|
||||
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
|
||||
verticalAlignment: Alignment.Vertical = Alignment.Top,
|
||||
content: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.padding(start = 26.dp),
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
verticalAlignment = verticalAlignment
|
||||
) { content(this) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val contextC = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val sharedButtonColors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.12f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
|
||||
|
||||
@Composable
|
||||
fun GlassCard(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
),
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = translation["manager.routes.home_settings"],
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
IconButton(onClick = { routes.navigation?.openBottomBarCustomization = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Tune,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.85f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
GlassCard {
|
||||
RowTitle(title = translation["actions_title"])
|
||||
EnumAction.entries.forEach { enumAction ->
|
||||
RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) }
|
||||
}
|
||||
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
|
||||
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["ui_settings_title"])
|
||||
ShiftedRow {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 55.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = translation["haptic_feedback_label"])
|
||||
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),
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["updates_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.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
|
||||
) {
|
||||
Text(text = translation["auto_update_check"])
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
Switch(
|
||||
checked = autoUpdateCheck,
|
||||
onCheckedChange = {
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
autoUpdateCheck = it
|
||||
if (it && context.config.root.global.updateSettings.updateCheckFrequency.getNullable() == null) {
|
||||
selectedFrequency = "weekly"
|
||||
context.config.root.global.updateSettings.updateCheckFrequency.set("weekly")
|
||||
}
|
||||
context.config.root.global.updateSettings.autoUpdateCheck.set(it)
|
||||
context.config.writeConfig()
|
||||
scheduleUpdateCheck()
|
||||
},
|
||||
modifier = Modifier.padding(end = 26.dp),
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = autoUpdateCheck) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = frequencyMenuExpanded,
|
||||
onExpandedChange = { frequencyMenuExpanded = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 26.dp)
|
||||
) {
|
||||
TextField(
|
||||
value = translation.getOrNull("update_check_frequency_${selectedFrequency}") ?: selectedFrequency,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = frequencyMenuExpanded) },
|
||||
colors = ExposedDropdownMenuDefaults.textFieldColors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = frequencyMenuExpanded,
|
||||
onDismissRequest = { frequencyMenuExpanded = false }
|
||||
) {
|
||||
listOf("daily", "weekly", "monthly").forEach { frequency ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = translation.getOrNull("update_check_frequency_${frequency}") ?: frequency) },
|
||||
onClick = {
|
||||
selectedFrequency = frequency
|
||||
frequencyMenuExpanded = false
|
||||
context.config.root.global.updateSettings.updateCheckFrequency.set(frequency)
|
||||
context.config.writeConfig()
|
||||
scheduleUpdateCheck()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["message_logger_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) {
|
||||
context.messageLogger.getStoredMessageCount()
|
||||
}
|
||||
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) {
|
||||
context.messageLogger.getStoredStoriesCount()
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val summary = translation.format(
|
||||
"message_logger_summary",
|
||||
"messageCount" to storedMessagesCount.toString(),
|
||||
"storyCount" to storedStoriesCount.toString()
|
||||
).replace("\n", " | ")
|
||||
Text(
|
||||
summary,
|
||||
maxLines = 2,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.align(Alignment.CenterHorizontally),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching {
|
||||
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
|
||||
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream ->
|
||||
context.messageLogger.databaseFile.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to export database", it)
|
||||
context.longToast(translation.format("export_database_failed_toast", "message" to (it.localizedMessage ?: "")))
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["export_button"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching {
|
||||
activityLauncherHelper.openFile("application/octet-stream") { uri ->
|
||||
val tempFile = File(context.androidContext.cacheDir, "view_message_logger.db")
|
||||
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
|
||||
FileOutputStream(tempFile).use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
routes.viewLoggerHistory.navigate {
|
||||
put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8"))
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to open file", it)
|
||||
context.longToast("Failed to open file! ${it.localizedMessage}")
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["view_button"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching {
|
||||
context.messageLogger.purgeAll()
|
||||
storedMessagesCount = 0
|
||||
storedStoriesCount = 0
|
||||
}.onFailure {
|
||||
context.log.error("Failed to clear messages", it)
|
||||
context.longToast(translation.format("clear_messages_failed_toast", "message" to (it.localizedMessage ?: "")))
|
||||
}.onSuccess {
|
||||
context.shortToast(translation["success_toast"])
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["clear_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
onClick = { routes.loggerHistory.navigate() },
|
||||
colors = sharedOutlinedColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))
|
||||
) {
|
||||
Text(translation["view_logger_history_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["friend_notes_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = translation["friend_notes_description"],
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 5.dp),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 5.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = {
|
||||
runCatching {
|
||||
val notes = context.database.getAllScopeNotes()
|
||||
if (notes.isEmpty()) {
|
||||
context.shortToast(translation["friend_notes_no_notes_to_backup"])
|
||||
return@runCatching
|
||||
}
|
||||
val json = context.gson.toJson(notes)
|
||||
activityLauncherHelper.saveFile("friend_notes_backup.json", "application/json") { uri ->
|
||||
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use {
|
||||
it.write(json.toByteArray())
|
||||
}
|
||||
context.shortToast(translation["friend_notes_backup_success"])
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to backup notes", it)
|
||||
context.longToast(translation.format("friend_notes_backup_failure", "error" to (it.localizedMessage ?: "")))
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["backup_button"])
|
||||
}
|
||||
Button(onClick = {
|
||||
runCatching {
|
||||
activityLauncherHelper.openFile("application/json") { uri ->
|
||||
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use {
|
||||
val json = it.reader().readText()
|
||||
val notes = context.gson.fromJson<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type)
|
||||
context.database.setAllScopeNotes(notes)
|
||||
context.shortToast(translation["friend_notes_restore_success"])
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to restore notes", it)
|
||||
context.longToast(translation.format("friend_notes_restore_failure", "error" to (it.localizedMessage ?: "")))
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["restore_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["debug_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 26.dp)
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextField(
|
||||
value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
colors = ExposedDropdownMenuDefaults.textFieldColors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
InternalFileHandleType.entries.forEach { fileType ->
|
||||
DropdownMenuItem(onClick = {
|
||||
expanded = false
|
||||
selectedFileType = fileType
|
||||
}, text = {
|
||||
Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching {
|
||||
context.coroutineScope.launch {
|
||||
selectedFileType.resolve(context.androidContext).delete()
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to clear file", it)
|
||||
context.longToast(translation.format("clear_file_failed_toast", "message" to (it.localizedMessage ?: "")))
|
||||
}.onSuccess {
|
||||
context.shortToast(translation["success_toast"])
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.1f),
|
||||
contentColor = Color.White
|
||||
),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(translation["clear_button"])
|
||||
}
|
||||
}
|
||||
ShiftedRow {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
PremiumPreferenceToggle(
|
||||
context.sharedPreferences,
|
||||
key = "test_mode",
|
||||
text = translation["test_mode_label"],
|
||||
defaultValue = true
|
||||
)
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(routes.bottomPadding + 12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.home
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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 me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@Composable
|
||||
fun QuickActionsDialog(
|
||||
quickActions: Map<Pair<String, ImageVector>, Any>,
|
||||
selectedQuickActions: List<String>,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (List<String>) -> Unit,
|
||||
translation: LocaleWrapper
|
||||
) {
|
||||
val selected = remember { mutableStateListOf(*selectedQuickActions.toTypedArray()) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
val dialogShape = RoundedCornerShape(24.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
shape = dialogShape,
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 16.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, dialogShape)
|
||||
.padding(horizontal = 18.dp, vertical = 16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(54.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.AutoAwesome,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["manager.dialogs.quick_actions_dialog.title"],
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold
|
||||
)
|
||||
Text(
|
||||
text = translation["manager.dialogs.quick_actions_dialog.subtitle"],
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
quickActions.keys.forEach { (name, icon) ->
|
||||
val isSelected = selected.contains(name)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
if (isSelected) selected.remove(name) else selected.add(name)
|
||||
},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.18f),
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = name,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
name,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = if (isSelected) translation["enabled"] else translation["disabled"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { toggled ->
|
||||
if (toggled) selected.add(name) else selected.remove(name)
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(translation["button.cancel"], color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
Button(
|
||||
onClick = { onSave(selected.toList()) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(translation["button.save"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.pages.location
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.location
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.Button
|
||||
@@ -14,9 +14,9 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import me.rhunk.snapenhance.bridge.location.LocationCoordinates
|
||||
import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
|
||||
import me.rhunk.snapenhance.ui.util.AlertDialogs
|
||||
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.ui.util.AlertDialogs
|
||||
|
||||
|
||||
@Composable
|
||||
@@ -1,19 +1,26 @@
|
||||
package me.rhunk.snapenhance.ui.manager.pages.location
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.location
|
||||
|
||||
import android.os.Parcel
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Map
|
||||
import androidx.compose.material.icons.filled.Navigation
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -24,40 +31,75 @@ import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.rhunk.snapenhance.bridge.location.FriendLocation
|
||||
import me.rhunk.snapenhance.bridge.location.LocationCoordinates
|
||||
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
|
||||
import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher
|
||||
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
|
||||
import me.rhunk.snapenhance.storage.addOrUpdateLocationCoordinate
|
||||
import me.rhunk.snapenhance.storage.getLocationCoordinates
|
||||
import me.rhunk.snapenhance.storage.removeLocationCoordinate
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.util.AlertDialogs
|
||||
import me.rhunk.snapenhance.ui.util.DialogProperties
|
||||
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
|
||||
import me.eternal.purrfectsnap.bridge.location.FriendLocation
|
||||
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.storage.addOrUpdateLocationCoordinate
|
||||
import me.eternal.purrfectsnap.storage.getLocationCoordinates
|
||||
import me.eternal.purrfectsnap.storage.removeLocationCoordinate
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.AlertDialogs
|
||||
import me.eternal.purrfectsnap.ui.util.DialogProperties
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
|
||||
class BetterLocationRoot : Routes.Route() {
|
||||
private val alertDialogs by lazy { AlertDialogs(context.translation) }
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.better_location") }
|
||||
private val alertDialogs by lazy { AlertDialogs(translation) }
|
||||
|
||||
@Composable
|
||||
private fun GlassPanel(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FriendLocationItem(
|
||||
friendLocation: FriendLocation,
|
||||
dismiss: () -> Unit
|
||||
) {
|
||||
ElevatedCard(onClick = {
|
||||
context.config.root.global.betterLocation.coordinates.setAny(friendLocation.latitude to friendLocation.longitude)
|
||||
dismiss()
|
||||
}, modifier = Modifier.padding(4.dp)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
GlassPanel(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 6.dp, horizontal = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
context.config.root.global.betterLocation.coordinates.setAny(friendLocation.latitude to friendLocation.longitude)
|
||||
dismiss()
|
||||
}
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
BitmojiImage(
|
||||
context = context,
|
||||
url = BitmojiSelfie.getBitmojiSelfie(
|
||||
@@ -65,25 +107,37 @@ class BetterLocationRoot : Routes.Route() {
|
||||
friendLocation.bitmojiId,
|
||||
BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D
|
||||
),
|
||||
size = 48,
|
||||
modifier = Modifier.padding(6.dp)
|
||||
size = 50,
|
||||
modifier = Modifier.padding(2.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(friendLocation.displayName?.let { "$it (${friendLocation.username})" }
|
||||
?: friendLocation.username, fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = buildString {
|
||||
append(friendLocation.localityPieces.joinToString(", "))
|
||||
append("\n")
|
||||
append("Lat: ${friendLocation.latitude.toFloat()}, Lng: ${friendLocation.longitude.toFloat()}")
|
||||
},
|
||||
fontSize = 10.sp,
|
||||
friendLocation.displayName?.let { "$it (${friendLocation.username})" }
|
||||
?: friendLocation.username,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = friendLocation.localityPieces.joinToString(", "),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "Lat ${friendLocation.latitude.toFloat()}, Lng ${friendLocation.longitude.toFloat()}",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
lineHeight = 15.sp
|
||||
color = Color.White.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.Navigation,
|
||||
contentDescription = null,
|
||||
tint = PurrfectPalette.glowSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,50 +156,61 @@ class BetterLocationRoot : Routes.Route() {
|
||||
} ?: friendsLocation
|
||||
}
|
||||
|
||||
ElevatedCard(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = Modifier.padding(top = 32.dp, bottom = 32.dp)
|
||||
GlassPanel(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 24.dp, horizontal = 10.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
translation["teleport_to_friend_title"],
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
value = search,
|
||||
onValueChange = { search = it },
|
||||
label = { Text(translation["search_bar"]) }
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
if (friendsLocation.isEmpty()) {
|
||||
Text(
|
||||
translation["no_friends_map"],
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
fontWeight = FontWeight.Light
|
||||
)
|
||||
} else if (filteredFriendsLocation.isEmpty()) {
|
||||
Text(
|
||||
translation["no_friends_found"],
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
fontWeight = FontWeight.Light
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
translation["teleport_to_friend_title"],
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.White
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = search,
|
||||
onValueChange = { search = it },
|
||||
label = { Text(translation["search_bar"]) },
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
cursorColor = PurrfectPalette.glowSecondary
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
item {
|
||||
if (friendsLocation.isEmpty()) {
|
||||
Text(
|
||||
translation["no_friends_map"],
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
fontWeight = FontWeight.Light,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
} else if (filteredFriendsLocation.isEmpty()) {
|
||||
Text(
|
||||
translation["no_friends_found"],
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
fontWeight = FontWeight.Light,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
items(filteredFriendsLocation) { friendLocation ->
|
||||
FriendLocationItem(friendLocation, dismiss)
|
||||
}
|
||||
}
|
||||
items(filteredFriendsLocation) { friendLocation ->
|
||||
FriendLocationItem(friendLocation, dismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,7 +257,7 @@ class BetterLocationRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
if (showTeleportDialog) {
|
||||
me.rhunk.snapenhance.ui.util.Dialog(
|
||||
me.eternal.purrfectsnap.ui.util.Dialog(
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
onDismissRequest = { showTeleportDialog = false },
|
||||
content = {
|
||||
@@ -210,22 +275,27 @@ class BetterLocationRoot : Routes.Route() {
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Text(
|
||||
translation.format(
|
||||
"spoofed_coordinates_title",
|
||||
"latitude" to ((spoofedCoordinates?.first as? Double)?.toFloat() ?: "0.0").toString(),
|
||||
"longitude" to ((spoofedCoordinates?.second as? Double)?.toFloat() ?: "0.0").toString()
|
||||
),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
GlassPanel(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
translation.format(
|
||||
"spoofed_coordinates_title",
|
||||
"latitude" to ((spoofedCoordinates?.first as? Double)?.toFloat() ?: "0.0").toString(),
|
||||
"longitude" to ((spoofedCoordinates?.second as? Double)?.toFloat() ?: "0.0").toString()
|
||||
),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color.White,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
if (addSavedCoordinateDialog) {
|
||||
me.rhunk.snapenhance.ui.util.Dialog(
|
||||
me.eternal.purrfectsnap.ui.util.Dialog(
|
||||
onDismissRequest = { addSavedCoordinateDialog = false },
|
||||
content = {
|
||||
AddCoordinatesDialog(
|
||||
@@ -248,14 +318,32 @@ class BetterLocationRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
if (showMap) {
|
||||
me.rhunk.snapenhance.ui.util.Dialog(
|
||||
me.eternal.purrfectsnap.ui.util.Dialog(
|
||||
onDismissRequest = { showMap = false },
|
||||
content = {
|
||||
alertDialogs.ChooseLocationDialog(property = coordinatesProperty, marker, mapView, saveCoordinates = {
|
||||
addSavedCoordinateDialog = true
|
||||
}) {
|
||||
showMap = false
|
||||
context.config.writeConfig()
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 16.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Box(modifier = Modifier.background(PurrfectPalette.cardOverlay)) {
|
||||
alertDialogs.ChooseLocationDialog(property = coordinatesProperty, marker, mapView, saveCoordinates = {
|
||||
addSavedCoordinateDialog = true
|
||||
}) {
|
||||
showMap = false
|
||||
context.config.writeConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
@@ -290,7 +378,8 @@ class BetterLocationRoot : Routes.Route() {
|
||||
onCheckedChange = {
|
||||
state.value = it
|
||||
onCheckedChange(it)
|
||||
}
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -308,41 +397,76 @@ class BetterLocationRoot : Routes.Route() {
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(
|
||||
GlassPanel(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Button(onClick = { showMap = true }) {
|
||||
Text(translation["choose_location_button"])
|
||||
}
|
||||
Button(onClick = { showTeleportDialog = true }) {
|
||||
Text(translation["teleport_to_friend_button"])
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = { showMap = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Filled.Map, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(translation["choose_location_button"])
|
||||
}
|
||||
Button(
|
||||
onClick = { showTeleportDialog = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowSecondary.copy(alpha = 0.28f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Filled.Navigation, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(translation["teleport_to_friend_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(
|
||||
GlassPanel(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Text(
|
||||
translation["saved_coordinates_title"],
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
addSavedCoordinateDialog = true
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = translation["add_icon_description"])
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
translation["saved_coordinates_title"],
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
lineHeight = 22.sp,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
translation["saved_coordinates_subtitle"],
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
FilledIconButton(
|
||||
onClick = { addSavedCoordinateDialog = true },
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.12f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = translation["add_icon_description"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,8 +475,10 @@ class BetterLocationRoot : Routes.Route() {
|
||||
Text(
|
||||
translation["no_saved_coordinates_hint"],
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(start = 20.dp),
|
||||
fontWeight = FontWeight.Light
|
||||
modifier = Modifier
|
||||
.padding(start = 20.dp, top = 8.dp),
|
||||
fontWeight = FontWeight.Light,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -371,7 +497,7 @@ class BetterLocationRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
if (showDeleteDialog) {
|
||||
me.rhunk.snapenhance.ui.util.Dialog(
|
||||
me.eternal.purrfectsnap.ui.util.Dialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
content = {
|
||||
alertDialogs.ConfirmDialog(
|
||||
@@ -391,7 +517,7 @@ class BetterLocationRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
if (showEditDialog) {
|
||||
me.rhunk.snapenhance.ui.util.Dialog(
|
||||
me.eternal.purrfectsnap.ui.util.Dialog(
|
||||
onDismissRequest = { showEditDialog = false },
|
||||
content = {
|
||||
AddCoordinatesDialog(
|
||||
@@ -418,46 +544,48 @@ class BetterLocationRoot : Routes.Route() {
|
||||
)
|
||||
}
|
||||
|
||||
ElevatedCard(
|
||||
onClick = {
|
||||
mutableCoordinates = coordinates
|
||||
setSpoofedCoordinates()
|
||||
GeoPoint(coordinates.latitude, coordinates.longitude).also {
|
||||
marker.value?.position = it
|
||||
mapView.value?.controller?.apply {
|
||||
animateTo(it)
|
||||
setZoom(16.0)
|
||||
}
|
||||
}
|
||||
},
|
||||
GlassPanel(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
.clickable {
|
||||
mutableCoordinates = coordinates
|
||||
setSpoofedCoordinates()
|
||||
GeoPoint(coordinates.latitude, coordinates.longitude).also {
|
||||
marker.value?.position = it
|
||||
mapView.value?.controller?.apply {
|
||||
animateTo(it)
|
||||
setZoom(16.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.weight(1f)
|
||||
.padding(vertical = 2.dp)
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = remember(mutableCoordinates) { mutableCoordinates.name },
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Light,
|
||||
fontWeight = if (isSelected) FontWeight.ExtraBold else FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 20.sp,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = remember(mutableCoordinates) { "(${mutableCoordinates.latitude.toFloat()}, ${mutableCoordinates.longitude.toFloat()})" },
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Light,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 15.sp,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
FilledIconButton(onClick = {
|
||||
@@ -465,7 +593,6 @@ class BetterLocationRoot : Routes.Route() {
|
||||
}) {
|
||||
Icon(Icons.Default.Edit, contentDescription = translation["edit_icon_description"])
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
FilledIconButton(onClick = {
|
||||
showDeleteDialog = true
|
||||
}) {
|
||||
@@ -0,0 +1,409 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.scripting
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.ui.zIndex
|
||||
import androidx.core.net.toUri
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getUrlFromClipboard
|
||||
import me.eternal.purrfectsnap.storage.addRepo
|
||||
import me.eternal.purrfectsnap.storage.getRepositories
|
||||
import me.eternal.purrfectsnap.storage.removeRepo
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class ManageScriptReposSection : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.scripting.repos") }
|
||||
private val refreshTrigger = mutableStateOf(0)
|
||||
private val okHttpClient by lazy { OkHttpClient() }
|
||||
|
||||
private fun extractRepoInfo(url: String): Pair<String, String> {
|
||||
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 = translation["invalid_repo_title"],
|
||||
text = errorDialogMessage,
|
||||
icon = Icons.Default.Error,
|
||||
confirmButtonText = translation["button.ok"],
|
||||
onConfirm = { showErrorDialog = false },
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showAddDialog = true },
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Icon(Icons.Default.Public, contentDescription = null, tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(translation["add_repo_button"])
|
||||
}
|
||||
|
||||
if (showAddDialog) {
|
||||
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
|
||||
|
||||
var url by remember { mutableStateOf("") }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
|
||||
Dialog(onDismissRequest = { showAddDialog = false }) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 16.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
|
||||
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.18f)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Public,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = translation["add_repo_dialog_title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Text(
|
||||
text = translation["manager.dialogs.scripting.repo_hint"]
|
||||
?: translation["repo_url_label"]
|
||||
?: "",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onGloballyPositioned { focusRequester.requestFocus() },
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text(translation["repo_url_label"], color = PurrfectPalette.textSecondary) },
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
cursorColor = PurrfectPalette.glowSecondary,
|
||||
focusedLabelColor = PurrfectPalette.textSecondary,
|
||||
unfocusedLabelColor = PurrfectPalette.textSecondary
|
||||
)
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
context.androidContext.getUrlFromClipboard()?.let { url = it }
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
|
||||
) {
|
||||
TextButton(onClick = { showAddDialog = false }) {
|
||||
Text(translation["button.cancel"], color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
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(translation["repo_added_toast"])
|
||||
showAddDialog = false
|
||||
refreshTrigger.value++
|
||||
} else {
|
||||
errorDialogMessage = translation["invalid_repo_error"]
|
||||
showErrorDialog = true
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to add repository", it)
|
||||
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = Color.White, strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text(translation["add_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val repositories by remember(refreshTrigger.value) {
|
||||
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("script") })
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
var topBarHeight by remember { mutableStateOf(statusBarTopPadding + 96.dp) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: (translation["title"] ?: "Repositories"),
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
.onGloballyPositioned {
|
||||
val newHeight = with(density) { it.size.height.toDp() }
|
||||
if (newHeight != topBarHeight) topBarHeight = newHeight
|
||||
}
|
||||
)
|
||||
if (repositories.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AestheticEmptyState(
|
||||
icon = Icons.Default.Public,
|
||||
title = translation["no_repos_added"],
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 22.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 12.dp,
|
||||
top = topBarHeight + 12.dp,
|
||||
end = 12.dp,
|
||||
bottom = 18.dp + routes.bottomPadding
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
items(repositories) { url ->
|
||||
val (repoName, author) = remember(url) { extractRepoInfo(url) }
|
||||
var showRemoveDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.18f)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Public,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = repoName,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = author,
|
||||
fontSize = 13.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { showRemoveDialog = true },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(translation["remove_button"])
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = showRemoveDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showRemoveDialog = false },
|
||||
title = translation["remove_repo_dialog_title"],
|
||||
text = translation["remove_repo_dialog_text"],
|
||||
icon = Icons.Default.Error,
|
||||
confirmButtonText = translation["remove_button"],
|
||||
dismissButtonText = translation["button.cancel"],
|
||||
onDismiss = { showRemoveDialog = false },
|
||||
onConfirm = {
|
||||
context.database.removeRepo("script", url)
|
||||
showRemoveDialog = false
|
||||
refreshTrigger.value++
|
||||
},
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,25 @@
|
||||
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
package me.rhunk.snapenhance.ui.manager.pages.scripting
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.scripting
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
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
|
||||
@@ -19,8 +27,10 @@ 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 me.eternal.purrfectsnap.common.util.ktx.openLink
|
||||
import me.eternal.purrfectsnap.storage.getRepositories
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
@@ -163,39 +173,30 @@ fun ScriptCatalog(root: ScriptingRootSection) {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["no_repos_added"],
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = translation["repo_list_info"],
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = translation["link_text"],
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable {
|
||||
AestheticEmptyState(
|
||||
icon = Icons.Default.Public,
|
||||
title = translation["no_repos_added"],
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 22.dp),
|
||||
actions = {
|
||||
Button(
|
||||
onClick = {
|
||||
context.androidContext.openLink(
|
||||
"https://github.com/particle-box/PurrfectSnap/blob/dev/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptRepos.md"
|
||||
"https://github.com/particle-box/PurrfectSnap/blob/dev/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptRepos.md"
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(translation["link_text"])
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
@@ -208,16 +209,20 @@ fun ScriptCatalog(root: ScriptingRootSection) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (allScripts.isEmpty() && repositories.isNotEmpty()) {
|
||||
Text(
|
||||
text = translation["no_scripts_available"],
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 28.dp, horizontal = 6.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AestheticEmptyState(
|
||||
icon = Icons.Default.Code,
|
||||
title = translation["no_scripts_available"],
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(allScripts) { (repoUrl, entry) ->
|
||||
@@ -228,53 +233,90 @@ fun ScriptCatalog(root: ScriptingRootSection) {
|
||||
isAlreadyInstalled = isScriptInstalled(entry.name)
|
||||
}
|
||||
|
||||
ElevatedCard(Modifier.padding(bottom = 8.dp).animateContentSize()) {
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
val border = remember {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize(),
|
||||
shape = shape,
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(1.dp, border)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.background(PurrfectPalette.cardOverlay, shape)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Code, null, Modifier.padding(end = 12.dp)
|
||||
)
|
||||
Column(
|
||||
Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.Center
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.18f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Code,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = entry.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White
|
||||
)
|
||||
entry.author?.let {
|
||||
Text(
|
||||
text = entry.name,
|
||||
text = translation.format("by_author", "author" to it),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
entry.author?.let {
|
||||
Text(
|
||||
text = translation.format("by_author", "author" to it),
|
||||
maxLines = 1,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
entry.description?.let {
|
||||
entry.description?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("version", "version" to (entry.version ?: "N/A")),
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = translation.format("version", "version" to (entry.version ?: "N/A")),
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
Button(
|
||||
enabled = !isDownloading && !isAlreadyInstalled,
|
||||
@@ -286,14 +328,20 @@ fun ScriptCatalog(root: ScriptingRootSection) {
|
||||
isDownloading = false
|
||||
isAlreadyInstalled = isScriptInstalled(entry.name)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
disabledContentColor = PurrfectPalette.textSecondary
|
||||
)
|
||||
) {
|
||||
when {
|
||||
isAlreadyInstalled -> Text(translation["installed_button"])
|
||||
isDownloading -> CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
color = Color.White
|
||||
)
|
||||
else -> Text(translation["download_button"])
|
||||
}
|
||||
@@ -0,0 +1,945 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.scripting
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
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.documentfile.provider.DocumentFile
|
||||
import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.common.scripting.type.ModuleInfo
|
||||
import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface
|
||||
import me.eternal.purrfectsnap.common.scripting.ui.InterfaceManager
|
||||
import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface
|
||||
import me.eternal.purrfectsnap.common.ui.AsyncUpdateDispatcher
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getUrlFromClipboard
|
||||
import me.eternal.purrfectsnap.common.util.ktx.openLink
|
||||
import me.eternal.purrfectsnap.storage.isScriptEnabled
|
||||
import me.eternal.purrfectsnap.storage.setScriptEnabled
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.chooseFolder
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
|
||||
import me.eternal.purrfectsnap.ui.util.pullrefresh.pullRefresh
|
||||
import me.eternal.purrfectsnap.ui.util.pullrefresh.rememberPullRefreshState
|
||||
|
||||
class ScriptingRootSection : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.scripting") }
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
|
||||
private var selectedTab by mutableStateOf(0)
|
||||
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
suspend fun isScriptInstalledByUrl(scriptUrl: String): Boolean {
|
||||
return try {
|
||||
val installedScripts = context.scriptManager.getSyncedModules()
|
||||
installedScripts.any { module ->
|
||||
module.updateUrl?.equals(scriptUrl, ignoreCase = true) == true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadScript(scriptUrl: String, onComplete: () -> Unit) {
|
||||
context.coroutineScope.launch {
|
||||
if (isScriptInstalledByUrl(scriptUrl)) {
|
||||
context.shortToast(translation["script_already_installed"])
|
||||
return@launch
|
||||
}
|
||||
|
||||
runCatching {
|
||||
context.shortToast(translation["downloading_script"])
|
||||
val moduleInfo = context.scriptManager.importFromUrl(scriptUrl)
|
||||
context.shortToast(translation.format("script_downloaded", "name" to moduleInfo.name))
|
||||
reloadDispatcher.dispatch()
|
||||
onComplete()
|
||||
}.onFailure {
|
||||
context.log.error("Failed to download script", it)
|
||||
context.shortToast(translation["download_script_failed"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportRemoteScript(
|
||||
dismiss: () -> Unit
|
||||
) {
|
||||
var url by remember { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.androidContext.getUrlFromClipboard()?.let { url = it }
|
||||
}
|
||||
|
||||
AestheticDialog(
|
||||
onDismissRequest = dismiss,
|
||||
title = translation["import_script_from_url_title"],
|
||||
text = translation["import_script_warning"],
|
||||
icon = Icons.Default.Link,
|
||||
confirmButtonText = translation["import_button"],
|
||||
dismissButtonText = translation["button.cancel"],
|
||||
onDismiss = dismiss,
|
||||
loading = isLoading,
|
||||
confirmEnabled = url.isNotBlank(),
|
||||
showCloseButton = false,
|
||||
onConfirm = {
|
||||
isLoading = true
|
||||
context.coroutineScope.launch {
|
||||
runCatching {
|
||||
if (isScriptInstalledByUrl(url)) {
|
||||
context.shortToast(translation["script_already_installed"])
|
||||
withContext(Dispatchers.Main) {
|
||||
dismiss()
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val moduleInfo = context.scriptManager.importFromUrl(url)
|
||||
context.shortToast(translation.format("script_imported", "name" to moduleInfo.name))
|
||||
reloadDispatcher.dispatch()
|
||||
withContext(Dispatchers.Main) {
|
||||
dismiss()
|
||||
}
|
||||
return@launch
|
||||
}.onFailure {
|
||||
context.log.error("Failed to import script", it)
|
||||
context.shortToast(translation.format("import_failed", "message" to (it.message ?: "Unknown")))
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
},
|
||||
customContent = {
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text(text = translation["enter_url_label"]) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onGloballyPositioned { focusRequester.requestFocus() },
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.06f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
focusedIndicatorColor = PurrfectPalette.glowSecondary.copy(alpha = 0.5f),
|
||||
unfocusedIndicatorColor = Color.White.copy(alpha = 0.12f),
|
||||
cursorColor = PurrfectPalette.glowSecondary,
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
focusedLabelColor = Color.White,
|
||||
unfocusedLabelColor = PurrfectPalette.textSecondary
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModuleActions(
|
||||
script: ModuleInfo,
|
||||
canUpdate: Boolean,
|
||||
dismiss: () -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = dismiss) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth().padding(2.dp)) {
|
||||
val actions = remember {
|
||||
mutableMapOf<Pair<String, ImageVector>, suspend () -> Unit>().apply {
|
||||
if (canUpdate) {
|
||||
put(translation["update_module_button"] to Icons.Default.Download) {
|
||||
dismiss()
|
||||
context.shortToast(translation.format("updating_script", "name" to script.name))
|
||||
runCatching {
|
||||
val modulePath = context.scriptManager.getModulePath(script.name) ?: throw Exception(translation["module_not_found"])
|
||||
context.scriptManager.unloadScript(modulePath)
|
||||
val moduleInfo = context.scriptManager.importFromUrl(script.updateUrl!!, filepath = modulePath)
|
||||
context.shortToast(translation.format("updated_script", "name" to script.name, "version" to moduleInfo.version))
|
||||
context.database.setScriptEnabled(script.name, false)
|
||||
withContext(context.database.executor.asCoroutineDispatcher()) {
|
||||
reloadDispatcher.dispatch()
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to update module", it)
|
||||
context.shortToast(translation["update_module_failed"])
|
||||
}
|
||||
}
|
||||
}
|
||||
put(translation["edit_module_button"] to Icons.Default.Edit) {
|
||||
runCatching {
|
||||
val modulePath = context.scriptManager.getModulePath(script.name)!!
|
||||
context.androidContext.startActivity(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
data = context.scriptManager.getScriptsFolder()!!.findFile(modulePath)!!.uri
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
}
|
||||
)
|
||||
dismiss()
|
||||
}.onFailure {
|
||||
context.log.error("Failed to open module file", it)
|
||||
context.shortToast(translation["open_module_failed"])
|
||||
}
|
||||
}
|
||||
put(translation["clear_module_data_button"] to Icons.Default.Save) {
|
||||
runCatching {
|
||||
context.scriptManager.getModuleDataFolder(script.name).deleteRecursively()
|
||||
context.shortToast(translation["module_data_cleared"])
|
||||
dismiss()
|
||||
}.onFailure {
|
||||
context.log.error("Failed to clear module data", it)
|
||||
context.shortToast(translation["clear_module_data_failed"])
|
||||
}
|
||||
}
|
||||
put(translation["delete_module_button"] to Icons.Default.DeleteOutline) {
|
||||
context.scriptManager.apply {
|
||||
runCatching {
|
||||
val modulePath = getModulePath(script.name)!!
|
||||
unloadScript(modulePath)
|
||||
getScriptsFolder()?.findFile(modulePath)?.delete()
|
||||
reloadDispatcher.dispatch()
|
||||
context.shortToast(translation.format("deleted_script", "name" to script.name))
|
||||
dismiss()
|
||||
}.onFailure {
|
||||
context.log.error("Failed to delete module", it)
|
||||
context.shortToast(translation["delete_module_failed"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}.toMap()
|
||||
}
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
item {
|
||||
Text(
|
||||
text = translation["actions_title"],
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
items(actions.size) { index ->
|
||||
val action = actions.entries.elementAt(index)
|
||||
ListItem(
|
||||
modifier = Modifier
|
||||
.clickable { context.coroutineScope.launch { action.value(); dismiss() } }
|
||||
.fillMaxWidth(),
|
||||
leadingContent = {
|
||||
Icon(action.key.second, action.key.first)
|
||||
},
|
||||
headlineContent = { Text(action.key.first) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModuleItem(script: ModuleInfo) {
|
||||
var enabled by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(script)) {
|
||||
context.database.isScriptEnabled(script.name)
|
||||
}
|
||||
var openSettings by remember(script) { mutableStateOf(false) }
|
||||
var openActions by remember { mutableStateOf(false) }
|
||||
|
||||
val dispatcher = rememberAsyncUpdateDispatcher()
|
||||
val reloadCallback = remember { suspend { dispatcher.dispatch() } }
|
||||
val latestUpdate by rememberAsyncMutableState(defaultValue = null, updateDispatcher = dispatcher, keys = arrayOf(script)) {
|
||||
context.scriptManager.checkForUpdate(script)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
reloadDispatcher.addCallback(reloadCallback)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { reloadDispatcher.removeCallback(reloadCallback) }
|
||||
}
|
||||
|
||||
val cardShape = RoundedCornerShape(20.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
shape = cardShape,
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 8.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (enabled) Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))
|
||||
else SolidColor(Color.White.copy(alpha = 0.08f))
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled) { if (enabled) openSettings = !openSettings }
|
||||
.background(PurrfectPalette.cardOverlay, cardShape)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(46.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(14.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Extension,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = script.displayName ?: script.name,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = script.description ?: translation["no_description"],
|
||||
fontSize = 13.sp,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
latestUpdate?.let {
|
||||
AssistChip(
|
||||
onClick = { },
|
||||
enabled = false,
|
||||
leadingIcon = { Icon(Icons.Default.Star, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
|
||||
label = { Text(translation.format("update_available", "version" to it.version)) },
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
labelColor = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
if (openSettings && enabled) {
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
label = { Text(translation["actions_button"]) },
|
||||
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color.White.copy(alpha = 0.06f),
|
||||
labelColor = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { openActions = !openActions }) {
|
||||
Icon(Icons.Default.Build, translation["actions_button"], tint = Color.White)
|
||||
}
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = { isChecked ->
|
||||
openSettings = false
|
||||
context.coroutineScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val modulePath = context.scriptManager.getModulePath(script.name)!!
|
||||
context.scriptManager.unloadScript(modulePath)
|
||||
if (isChecked) {
|
||||
context.scriptManager.loadScript(modulePath)
|
||||
context.scriptManager.runtime.getModuleByName(script.name)
|
||||
?.callFunction("module.onPurrfectSnapLoad")
|
||||
context.shortToast(translation.format("loaded_script", "name" to script.name))
|
||||
} else {
|
||||
context.shortToast(translation.format("unloaded_script", "name" to script.name))
|
||||
}
|
||||
context.database.setScriptEnabled(script.name, isChecked)
|
||||
withContext(Dispatchers.Main) { enabled = isChecked }
|
||||
}.onFailure { throwable ->
|
||||
withContext(Dispatchers.Main) { enabled = !isChecked }
|
||||
context.log.error("Failed to ${if (isChecked) "enable" else "disable"} script", throwable)
|
||||
context.shortToast(translation.format(if (isChecked) "enable_script_failed" else "disable_script_failed"))
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
if (openSettings) {
|
||||
Divider(color = Color.White.copy(alpha = 0.08f))
|
||||
ScriptSettings(script)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (openActions) {
|
||||
ModuleActions(script = script, canUpdate = latestUpdate != null) { openActions = false }
|
||||
}
|
||||
}
|
||||
|
||||
override val floatingActionButton: @Composable () -> Unit = {}
|
||||
|
||||
@Composable
|
||||
private fun SelectFolderButton(onClick: () -> Unit) {
|
||||
val label = translation.getOrNull("select_folder_button") ?: "Select folder"
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(68.dp),
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.1f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.7f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.65f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.335f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.25f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = label,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScriptSettings(script: ModuleInfo) {
|
||||
val settingsInterface = remember {
|
||||
val module =
|
||||
context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
|
||||
(module.getBinding(InterfaceManager::class))?.buildInterface(EnumScriptInterface.SETTINGS)
|
||||
}
|
||||
if (settingsInterface == null) {
|
||||
Text(
|
||||
text = translation["no_settings_for_module"],
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
} else {
|
||||
ScriptInterface(interfaceBuilder = settingsInterface)
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val scriptingFolder by rememberAsyncMutableState(
|
||||
defaultValue = null,
|
||||
updateDispatcher = reloadDispatcher
|
||||
) { context.scriptManager.getScriptsFolder() }
|
||||
val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"])
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var showToast by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(scriptingFolder) {
|
||||
if (scriptingFolder == null && selectedTab != 0) {
|
||||
selectedTab = 0
|
||||
}
|
||||
}
|
||||
|
||||
if (showImportDialog) {
|
||||
ImportRemoteScript { showImportDialog = false }
|
||||
}
|
||||
if (showToast) {
|
||||
LaunchedEffect(showToast) {
|
||||
context.shortToast(translation["select_scripts_folder_toast"])
|
||||
showToast = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
ScriptingHeader(
|
||||
titles = tabTitles,
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { index ->
|
||||
if (index == 1 && scriptingFolder == null) {
|
||||
showToast = true
|
||||
} else {
|
||||
selectedTab = index
|
||||
}
|
||||
},
|
||||
onImport = {
|
||||
if (scriptingFolder == null) showToast = true else showImportDialog = true
|
||||
},
|
||||
onOpenFolder = {
|
||||
if (scriptingFolder == null) showToast = true else scriptingFolder?.let { context.androidContext.openLink(it.uri.toString()) }
|
||||
},
|
||||
onManageRepos = { routes.manageScriptRepos.navigate() },
|
||||
onDocs = { context.androidContext.openLink("https://github.com/SnapEnhance/scripting-docs") },
|
||||
folderSelected = scriptingFolder != null
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
when (selectedTab) {
|
||||
0 -> InstalledTabContent(
|
||||
scriptingFolder = scriptingFolder
|
||||
)
|
||||
1 -> CatalogTabContent(
|
||||
scriptingFolder = scriptingFolder
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstalledTabContent(
|
||||
scriptingFolder: DocumentFile?
|
||||
) {
|
||||
val scriptModules by rememberAsyncMutableState(
|
||||
defaultValue = emptyList(),
|
||||
updateDispatcher = reloadDispatcher
|
||||
) { context.scriptManager.sync(); context.scriptManager.getSyncedModules() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
refreshing = true
|
||||
withContext(Dispatchers.IO) {
|
||||
reloadDispatcher.dispatch()
|
||||
refreshing = false
|
||||
}
|
||||
}
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = {
|
||||
refreshing = true
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
reloadDispatcher.dispatch()
|
||||
refreshing = false
|
||||
}
|
||||
})
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pullRefresh(pullRefreshState),
|
||||
contentPadding = PaddingValues(bottom = routes.bottomPadding + 28.dp, start = 8.dp, end = 8.dp, top = 12.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
if (scriptingFolder == null && !refreshing) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.White.copy(alpha = 0.05f),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 22.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(58.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(Color.White.copy(alpha = 0.08f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.FolderOpen,
|
||||
contentDescription = null,
|
||||
tint = PurrfectPalette.glowSecondary,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = translation["no_scripts_folder_selected_title"],
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = translation["select_scripts_folder_toast"],
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
SelectFolderButton(
|
||||
onClick = {
|
||||
activityLauncherHelper.chooseFolder {
|
||||
context.config.root.scripting.moduleFolder.set(it)
|
||||
context.config.writeConfig()
|
||||
coroutineScope.launch { reloadDispatcher.dispatch() }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (scriptModules.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(220.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AestheticEmptyState(
|
||||
icon = Icons.Default.DataObject,
|
||||
title = translation["no_scripts_found_title"],
|
||||
subtitle = translation["use_catalog_to_add_scripts"],
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(scriptModules.size, key = { scriptModules[it].hashCode() }) { index ->
|
||||
ModuleItem(scriptModules[index])
|
||||
}
|
||||
}
|
||||
PullRefreshIndicator(
|
||||
refreshing = refreshing,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
var scriptingWarning by remember {
|
||||
mutableStateOf(context.sharedPreferences.run {
|
||||
getBoolean("scripting_warning", true).also {
|
||||
edit().putBoolean("scripting_warning", false).apply()
|
||||
}
|
||||
})
|
||||
}
|
||||
if (scriptingWarning) {
|
||||
var timeout by remember { mutableIntStateOf(10) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (timeout > 0) {
|
||||
delay(1000)
|
||||
timeout--
|
||||
}
|
||||
}
|
||||
AestheticDialog(
|
||||
onDismissRequest = { if (timeout == 0) scriptingWarning = false },
|
||||
title = context.translation["manager.dialogs.scripting_warning.title"],
|
||||
text = context.translation["manager.dialogs.scripting_warning.content"],
|
||||
icon = Icons.Default.Warning,
|
||||
confirmButtonText = translation["button.ok"] ?: "OK",
|
||||
onConfirm = { if (timeout == 0) scriptingWarning = false },
|
||||
loading = timeout > 0,
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.25f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.18f)
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = timeout.toString(),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogTabContent(
|
||||
scriptingFolder: DocumentFile?
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
if (scriptingFolder == null) {
|
||||
AestheticEmptyState(
|
||||
icon = Icons.Default.FolderOpen,
|
||||
title = translation["no_scripts_folder_selected_title"],
|
||||
subtitle = translation["select_scripts_folder_toast"],
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 22.dp)
|
||||
)
|
||||
} else {
|
||||
ScriptCatalog(this@ScriptingRootSection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScriptingHeader(
|
||||
titles: List<String>,
|
||||
selectedTab: Int,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
onImport: () -> Unit,
|
||||
onOpenFolder: () -> Unit,
|
||||
onManageRepos: () -> Unit,
|
||||
onDocs: () -> Unit,
|
||||
folderSelected: Boolean
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["manager.routes.scripts"] ?: "Scripts",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onDocs) {
|
||||
Icon(Icons.Default.CollectionsBookmark, contentDescription = translation["documentation_button"], tint = Color.White)
|
||||
}
|
||||
IconButton(onClick = onManageRepos) {
|
||||
Icon(Icons.Default.Public, contentDescription = translation["manage_repos_button"], tint = Color.White)
|
||||
}
|
||||
IconButton(onClick = onOpenFolder) {
|
||||
Icon(Icons.Default.FolderOpen, contentDescription = translation["open_scripts_folder_button"], tint = Color.White)
|
||||
}
|
||||
IconButton(onClick = onImport, enabled = folderSelected) {
|
||||
Icon(Icons.Default.Link, contentDescription = translation["import_from_url_button"], tint = if (folderSelected) Color.White else Color.White.copy(alpha = 0.4f))
|
||||
}
|
||||
}
|
||||
}
|
||||
ScriptingTabSwitcher(
|
||||
titles = titles,
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = onTabSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScriptingTabSwitcher(
|
||||
titles: List<String>,
|
||||
selectedTab: Int,
|
||||
onTabSelected: (Int) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
titles.forEachIndexed { index, title ->
|
||||
val isSelected = selectedTab == index
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (isSelected) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.06f),
|
||||
border = if (isSelected) BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))
|
||||
) else BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable { onTabSelected(index) }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (index == 0) Icons.Default.Extension else Icons.Default.Public,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val topBarActions: @Composable() (RowScope.() -> Unit) = {}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.social
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.common.ReceiversConfig
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
|
||||
|
||||
class AddFriendDialog(
|
||||
private val context: RemoteSideContext,
|
||||
private val actionHandler: Actions,
|
||||
private val pinnedIds: List<String>? = null
|
||||
) {
|
||||
class Actions(
|
||||
val onFriendState: (friend: MessagingFriendInfo, state: Boolean) -> Unit,
|
||||
val onGroupState: (group: MessagingGroupInfo, state: Boolean) -> Unit,
|
||||
val getFriendState: (friend: MessagingFriendInfo) -> Boolean,
|
||||
val getGroupState: (group: MessagingGroupInfo) -> Boolean,
|
||||
)
|
||||
|
||||
private val stateCache = mutableMapOf<String, Boolean>()
|
||||
private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")}
|
||||
|
||||
@Composable
|
||||
private fun ListCardEntry(
|
||||
id: String,
|
||||
bitmoji: String? = null,
|
||||
name: String,
|
||||
participantsCount: Int? = null,
|
||||
getCurrentState: () -> Boolean,
|
||||
onState: (Boolean) -> Unit = {},
|
||||
) {
|
||||
var currentState by rememberAsyncMutableState(defaultValue = stateCache[id] ?: false) {
|
||||
getCurrentState().also { stateCache[id] = it }
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val cardShape = RoundedCornerShape(18.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
.clickable {
|
||||
currentState = !currentState
|
||||
stateCache[id] = currentState
|
||||
coroutineScope.launch(Dispatchers.IO) { onState(currentState) }
|
||||
},
|
||||
shape = cardShape,
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (currentState) Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))
|
||||
else SolidColor(Color.White.copy(alpha = 0.08f))
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(PurrfectPalette.cardOverlay, cardShape)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
BitmojiImage(
|
||||
context = this@AddFriendDialog.context,
|
||||
url = bitmoji,
|
||||
modifier = Modifier
|
||||
.padding(end = 2.dp),
|
||||
size = 40,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
participantsCount?.let {
|
||||
Text(
|
||||
text = translation.format("participants_text", "count" to it.toString()),
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = currentState,
|
||||
onCheckedChange = {
|
||||
currentState = it
|
||||
stateCache[id] = currentState
|
||||
coroutineScope.launch(Dispatchers.IO) { onState(currentState) }
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedTrackColor = PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
|
||||
uncheckedThumbColor = Color.White.copy(alpha = 0.8f),
|
||||
uncheckedTrackColor = Color.White.copy(alpha = 0.2f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DialogHeader(searchKeyword: MutableState<String>) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 18.dp)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = translation["title"],
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = translation["search_hint"],
|
||||
fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.85f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
TextField(
|
||||
value = searchKeyword.value,
|
||||
onValueChange = { searchKeyword.value = it },
|
||||
placeholder = {
|
||||
Text(text = translation["search_hint"])
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Search, contentDescription = translation["search_icon_description"])
|
||||
},
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = PurrfectPalette.cardOverlayColor.copy(alpha = 0.9f),
|
||||
unfocusedContainerColor = PurrfectPalette.cardOverlayColor.copy(alpha = 0.8f),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
cursorColor = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun Content(dismiss: () -> Unit = { }) {
|
||||
var cachedFriends by remember { mutableStateOf(null as List<MessagingFriendInfo>?) }
|
||||
var cachedGroups by remember { mutableStateOf(null as List<MessagingGroupInfo>?) }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var timeoutJob: Job? = null
|
||||
var hasFetchError by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.database.receiveMessagingDataCallback = { friends, groups ->
|
||||
cachedFriends = friends.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.userId) }
|
||||
} else friends
|
||||
}
|
||||
cachedGroups = groups.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.conversationId) }
|
||||
} else groups
|
||||
}
|
||||
timeoutJob?.cancel()
|
||||
hasFetchError = false
|
||||
}
|
||||
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also {
|
||||
runCatching {
|
||||
context.androidContext.sendBroadcast(it)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to send broadcast", it)
|
||||
hasFetchError = true
|
||||
}
|
||||
}
|
||||
timeoutJob = coroutineScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
delay(20000)
|
||||
hasFetchError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
me.eternal.purrfectsnap.ui.util.Dialog(
|
||||
onDismissRequest = {
|
||||
timeoutJob?.cancel()
|
||||
dismiss()
|
||||
},
|
||||
properties = me.eternal.purrfectsnap.ui.util.DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
val dialogShape = RoundedCornerShape(24.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
shape = dialogShape,
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Column {
|
||||
if (cachedGroups == null || cachedFriends == null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp, vertical = 22.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasFetchError) {
|
||||
Text(
|
||||
text = translation["fetch_error"],
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 10.dp, top = 10.dp),
|
||||
color = Color.White
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.size(32.dp),
|
||||
strokeWidth = 3.dp,
|
||||
color = PurrfectPalette.glowSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
return@Surface
|
||||
}
|
||||
|
||||
val searchKeyword = remember { mutableStateOf("") }
|
||||
|
||||
val filteredGroups = cachedGroups!!.takeIf { searchKeyword.value.isNotBlank() }?.filter {
|
||||
it.name.contains(searchKeyword.value, ignoreCase = true)
|
||||
} ?: cachedGroups!!
|
||||
|
||||
val filteredFriends = cachedFriends!!.takeIf { searchKeyword.value.isNotBlank() }?.filter {
|
||||
it.mutableUsername.contains(searchKeyword.value, ignoreCase = true) ||
|
||||
it.displayName?.contains(searchKeyword.value, ignoreCase = true) == true
|
||||
} ?: cachedFriends!!
|
||||
|
||||
DialogHeader(searchKeyword)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
) {
|
||||
item {
|
||||
if (filteredGroups.isNotEmpty()) {
|
||||
Text(
|
||||
text = translation["category_groups"],
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 8.dp, top = 8.dp),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(filteredGroups.size) {
|
||||
val group = filteredGroups[it]
|
||||
ListCardEntry(
|
||||
id = group.conversationId,
|
||||
name = group.name,
|
||||
participantsCount = group.participantsCount,
|
||||
getCurrentState = { actionHandler.getGroupState(group) }
|
||||
) { state ->
|
||||
actionHandler.onGroupState(group, state)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
if (filteredFriends.isNotEmpty()) {
|
||||
Text(
|
||||
text = translation["category_friends"],
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 8.dp, top = 14.dp),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(filteredFriends.size) { index ->
|
||||
val friend = filteredFriends[index]
|
||||
|
||||
ListCardEntry(
|
||||
id = friend.userId,
|
||||
bitmoji = friend.takeIf { it.bitmojiId != null }?.let {
|
||||
BitmojiSelfie.getBitmojiSelfie(it.selfieId, it.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D)
|
||||
},
|
||||
name = friend.displayName?.takeIf { name -> name.isNotBlank() } ?: friend.mutableUsername,
|
||||
getCurrentState = { actionHandler.getFriendState(friend) }
|
||||
) { state ->
|
||||
actionHandler.onFriendState(friend, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.pages.social
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.social
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.Image
|
||||
@@ -25,16 +25,16 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import me.rhunk.snapenhance.bridge.DownloadCallback
|
||||
import me.rhunk.snapenhance.common.data.FileType
|
||||
import me.rhunk.snapenhance.common.data.StoryData
|
||||
import me.rhunk.snapenhance.common.data.download.*
|
||||
import me.rhunk.snapenhance.common.util.ktx.longHashCode
|
||||
import me.rhunk.snapenhance.download.DownloadProcessor
|
||||
import me.rhunk.snapenhance.storage.getFriendInfo
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.util.Dialog
|
||||
import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper
|
||||
import me.eternal.purrfectsnap.bridge.DownloadCallback
|
||||
import me.eternal.purrfectsnap.common.data.FileType
|
||||
import me.eternal.purrfectsnap.common.data.StoryData
|
||||
import me.eternal.purrfectsnap.common.data.download.*
|
||||
import me.eternal.purrfectsnap.common.util.ktx.longHashCode
|
||||
import me.eternal.purrfectsnap.download.DownloadProcessor
|
||||
import me.eternal.purrfectsnap.storage.getFriendInfo
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.coil.ImageRequestHelper
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
@@ -151,7 +151,7 @@ class LoggedStories : Routes.Route() {
|
||||
setDataAndType(
|
||||
FileProvider.getUriForFile(
|
||||
context.androidContext,
|
||||
"me.rhunk.snapenhance.fileprovider",
|
||||
"me.eternal.purrfectsnap.fileprovider",
|
||||
targetFile
|
||||
),
|
||||
FileType.fromFile(targetFile).mimeType
|
||||
@@ -267,4 +267,4 @@ class LoggedStories : Routes.Route() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package me.rhunk.snapenhance.ui.manager.pages.social
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.social
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.DeleteForever
|
||||
@@ -10,50 +14,42 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.rhunk.snapenhance.common.data.FriendStreaks
|
||||
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
|
||||
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
|
||||
import me.rhunk.snapenhance.common.data.MessagingRuleType
|
||||
import me.rhunk.snapenhance.common.data.SocialScope
|
||||
import me.rhunk.snapenhance.common.ui.AutoClearKeyboardFocus
|
||||
import me.rhunk.snapenhance.common.ui.EditNoteTextField
|
||||
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
|
||||
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
|
||||
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
|
||||
import me.rhunk.snapenhance.storage.*
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.util.AlertDialogs
|
||||
import me.rhunk.snapenhance.ui.util.Dialog
|
||||
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
|
||||
import me.eternal.purrfectsnap.common.data.FriendStreaks
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.data.SocialScope
|
||||
import me.eternal.purrfectsnap.common.ui.AutoClearKeyboardFocus
|
||||
import me.eternal.purrfectsnap.common.ui.EditNoteTextField
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.storage.*
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.AlertDialogs
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
class ManageScope: Routes.Route() {
|
||||
override val title: @Composable () -> Unit = {
|
||||
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
|
||||
val text by rememberAsyncMutableState<String?>(null, keys = arrayOf(navBackStackEntry)) {
|
||||
val scope = navBackStackEntry?.arguments?.getString("scope")?.let { SocialScope.getByName(it) }
|
||||
val id = navBackStackEntry?.arguments?.getString("id")
|
||||
if (scope == null || id == null) return@rememberAsyncMutableState null
|
||||
|
||||
when (scope) {
|
||||
SocialScope.FRIEND -> context.database.getFriendInfo(id)?.displayName
|
||||
SocialScope.GROUP -> context.database.getGroupInfo(id)?.name
|
||||
}
|
||||
}
|
||||
text?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
}
|
||||
|
||||
private val dialogs by lazy { AlertDialogs(context.translation) }
|
||||
|
||||
private fun deleteScope(scope: SocialScope, id: String, coroutineScope: CoroutineScope) {
|
||||
@@ -68,47 +64,66 @@ class ManageScope: Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{
|
||||
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
|
||||
var deleteConfirmDialog by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (deleteConfirmDialog) {
|
||||
val scope = navBackStackEntry?.arguments?.getString("scope")?.let { SocialScope.getByName(it) } ?: return@topBarActions
|
||||
val id = navBackStackEntry?.arguments?.getString("id")!!
|
||||
|
||||
Dialog(onDismissRequest = {
|
||||
deleteConfirmDialog = false
|
||||
}) {
|
||||
remember { AlertDialogs(context.translation) }.ConfirmDialog(
|
||||
title = translation.format("delete_scope_confirm_dialog_title", "scope" to context.translation["scopes.${scope.key}"]),
|
||||
onDismiss = { deleteConfirmDialog = false },
|
||||
onConfirm = {
|
||||
deleteScope(scope, id, coroutineScope); deleteConfirmDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { deleteConfirmDialog = true },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.DeleteForever,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = content@{ navBackStackEntry ->
|
||||
val scope = SocialScope.getByName(navBackStackEntry.arguments?.getString("scope")!!)
|
||||
val id = navBackStackEntry.arguments?.getString("id")!!
|
||||
val density = LocalDensity.current
|
||||
var topBarHeight by remember { mutableStateOf(96.dp) }
|
||||
var deleteConfirmDialog by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
val titleText by rememberAsyncMutableState<String?>(null, keys = arrayOf(id, scope)) {
|
||||
when (scope) {
|
||||
SocialScope.FRIEND -> context.database.getFriendInfo(id)?.displayName
|
||||
SocialScope.GROUP -> context.database.getGroupInfo(id)?.name
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteConfirmDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { deleteConfirmDialog = false },
|
||||
title = translation.format("delete_scope_confirm_dialog_title", "scope" to context.translation["scopes.${scope.key}"]),
|
||||
text = "",
|
||||
icon = Icons.Rounded.DeleteForever,
|
||||
confirmButtonText = translation["delete_button"] ?: "Delete",
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
onDismiss = { deleteConfirmDialog = false },
|
||||
onConfirm = {
|
||||
deleteScope(scope, id, coroutineScope)
|
||||
deleteConfirmDialog = false
|
||||
},
|
||||
opaque = true,
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = titleText ?: "Manage",
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
actions = {
|
||||
IconButton(onClick = { deleteConfirmDialog = true }) {
|
||||
Icon(Icons.Rounded.DeleteForever, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
.onGloballyPositioned {
|
||||
val newHeight = with(density) { it.size.height.toDp() }
|
||||
if (newHeight != topBarHeight) topBarHeight = newHeight
|
||||
}
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = topBarHeight + 8.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
var bottomComposable by remember {
|
||||
mutableStateOf(null as (@Composable () -> Unit)?)
|
||||
}
|
||||
@@ -156,11 +171,13 @@ class ManageScope: Routes.Route() {
|
||||
Text(
|
||||
text = translation["not_found"],
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(routes.bottomPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,14 +239,17 @@ class ManageScope: Routes.Route() {
|
||||
} else context.translation["rules.properties.${ruleType.key}.name"],
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 5.dp, end = 5.dp)
|
||||
.padding(start = 5.dp, end = 5.dp),
|
||||
color = Color.White
|
||||
)
|
||||
Switch(checked = ruleEnabled,
|
||||
Switch(
|
||||
checked = ruleEnabled,
|
||||
enabled = if (ruleType.listMode) ruleState != null else true,
|
||||
onCheckedChange = {
|
||||
context.database.setRule(id, ruleType.key, it)
|
||||
ruleEnabled = it
|
||||
}
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -238,18 +258,35 @@ class ManageScope: Routes.Route() {
|
||||
|
||||
@Composable
|
||||
private fun ContentCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
ElevatedCard(
|
||||
val shape = RoundedCornerShape(22.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.then(modifier),
|
||||
shape = shape,
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.fillMaxWidth()
|
||||
.then(modifier)
|
||||
) {
|
||||
content()
|
||||
CompositionLocalProvider(LocalContentColor provides Color.White) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, shape)
|
||||
.padding(12.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,8 +299,8 @@ class ManageScope: Routes.Route() {
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.offset(x = 20.dp)
|
||||
.padding(bottom = 10.dp)
|
||||
.padding(start = 18.dp, top = 10.dp, bottom = 6.dp),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
@@ -384,8 +421,10 @@ class ManageScope: Routes.Route() {
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(5.dp)
|
||||
.fillMaxWidth(),
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.background(Color.White.copy(alpha = 0.04f), RoundedCornerShape(22.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie(
|
||||
@@ -396,13 +435,15 @@ class ManageScope: Routes.Route() {
|
||||
text = friend.displayName ?: friend.mutableUsername,
|
||||
maxLines = 1,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = friend.mutableUsername,
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light
|
||||
fontWeight = FontWeight.Light,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
|
||||
@@ -415,7 +456,7 @@ class ManageScope: Routes.Route() {
|
||||
routes.loggedStories.navigate {
|
||||
put("id", id)
|
||||
}
|
||||
}) {
|
||||
}, colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f), contentColor = Color.White)) {
|
||||
Text(translation["logged_stories_button"])
|
||||
}
|
||||
}
|
||||
@@ -456,10 +497,14 @@ class ManageScope: Routes.Route() {
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(end = 10.dp)
|
||||
)
|
||||
Switch(checked = shouldNotify, onCheckedChange = {
|
||||
context.database.setFriendStreaksNotify(id, it)
|
||||
shouldNotify = it
|
||||
})
|
||||
Switch(
|
||||
checked = shouldNotify,
|
||||
onCheckedChange = {
|
||||
context.database.setFriendStreaksNotify(id, it)
|
||||
shouldNotify = it
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -474,18 +519,29 @@ class ManageScope: Routes.Route() {
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.fillMaxWidth(),
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.background(Color.White.copy(alpha = 0.04f), RoundedCornerShape(22.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = group.name, maxLines = 1, fontSize = 20.sp, fontWeight = FontWeight.Bold
|
||||
text = group.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = translation.format(
|
||||
"participants_text", "count" to group.participantsCount.toString()
|
||||
), maxLines = 1, fontSize = 12.sp, fontWeight = FontWeight.Light
|
||||
),
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,960 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.social
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.rounded.BookmarkAdded
|
||||
import androidx.compose.material.icons.rounded.BookmarkBorder
|
||||
import androidx.compose.material.icons.rounded.DeleteForever
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.material.icons.rounded.RemoveRedEye
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.bridge.snapclient.MessagingBridge
|
||||
import me.eternal.purrfectsnap.bridge.snapclient.SessionStartListener
|
||||
import me.eternal.purrfectsnap.bridge.snapclient.types.Message
|
||||
import me.eternal.purrfectsnap.common.Constants
|
||||
import me.eternal.purrfectsnap.common.ReceiversConfig
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.data.SocialScope
|
||||
import me.eternal.purrfectsnap.common.messaging.MessagingConstraints
|
||||
import me.eternal.purrfectsnap.common.messaging.MessagingTask
|
||||
import me.eternal.purrfectsnap.common.messaging.MessagingTaskConstraint
|
||||
import me.eternal.purrfectsnap.common.messaging.MessagingTaskType
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
|
||||
import me.eternal.purrfectsnap.storage.getFriendInfo
|
||||
import me.eternal.purrfectsnap.storage.getGroupInfo
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
|
||||
class MessagingPreview: Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.social.messaging_preview.messaging_preview") }
|
||||
private lateinit var coroutineScope: CoroutineScope
|
||||
private lateinit var previewScrollState: LazyListState
|
||||
|
||||
private val contentTypeTranslation by lazy { context.translation.getCategory("content_type") }
|
||||
private val messagingBridge: MessagingBridge? get() = context.bridgeService?.messagingBridge
|
||||
|
||||
private var messages = mutableStateListOf<Message>()
|
||||
private var conversationId by mutableStateOf<String?>(null)
|
||||
private val selectedMessages = mutableStateListOf<Long>() // client message id
|
||||
|
||||
private fun toggleSelectedMessage(messageId: Long) {
|
||||
if (selectedMessages.contains(messageId)) selectedMessages.remove(messageId)
|
||||
else selectedMessages.add(messageId)
|
||||
}
|
||||
|
||||
private fun tr(key: String, fallback: String? = null): String {
|
||||
fun normalizeCandidate(candidate: String?): String? {
|
||||
if (candidate.isNullOrBlank()) return null
|
||||
if (candidate == key) return null
|
||||
if (candidate.endsWith(".$key")) return null
|
||||
return candidate
|
||||
}
|
||||
|
||||
return normalizeCandidate(translation.getOrNull(key))
|
||||
?: normalizeCandidate(context.translation.getOrNull("manager.sections.social.messaging_preview.$key"))
|
||||
?: normalizeCandidate(context.translation.getOrNull("manager.social.messaging_preview.$key"))
|
||||
?: fallback
|
||||
?: key
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionsSheetItem(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
icon: ImageVector,
|
||||
danger: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(18.dp)
|
||||
val border = if (danger) {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
Color(0xFFFF5C8A).copy(alpha = 0.7f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = shape,
|
||||
color = PurrfectPalette.cardOverlayColor.copy(alpha = 0.85f),
|
||||
border = BorderStroke(1.dp, border),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(42.dp)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
(if (danger) Color(0xFFFF5C8A) else PurrfectPalette.glowPrimary).copy(alpha = 0.32f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.18f)
|
||||
)
|
||||
),
|
||||
RoundedCornerShape(14.dp)
|
||||
)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(14.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConstraintsSelectionDialog(
|
||||
onChoose: (Array<ContentType>) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val selectedTypes = remember { mutableStateListOf<ContentType>() }
|
||||
var selectAllState by remember { mutableStateOf(false) }
|
||||
val availableTypes = remember { arrayOf(
|
||||
ContentType.CHAT,
|
||||
ContentType.NOTE,
|
||||
ContentType.SNAP,
|
||||
ContentType.STICKER,
|
||||
ContentType.EXTERNAL_MEDIA
|
||||
) }
|
||||
|
||||
fun toggleContentType(contentType: ContentType) {
|
||||
if (selectAllState) return
|
||||
if (selectedTypes.contains(contentType)) {
|
||||
selectedTypes.remove(contentType)
|
||||
} else {
|
||||
selectedTypes.add(contentType)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(PurrfectPalette.cardOverlayColor)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(22.dp)),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.Transparent
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(15.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp)
|
||||
) {
|
||||
Text(context.translation["manager.dialogs.messaging_action.title"], color = Color.White)
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
availableTypes.forEach { contentType ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(2.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(onTap = { toggleContentType(contentType) })
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = selectedTypes.contains(contentType),
|
||||
enabled = !selectAllState,
|
||||
onCheckedChange = { toggleContentType(contentType) },
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = PurrfectPalette.glowSecondary,
|
||||
uncheckedColor = Color.White.copy(alpha = 0.85f),
|
||||
checkmarkColor = Color.Black
|
||||
)
|
||||
)
|
||||
Text(text = contentTypeTranslation[contentType.name], color = Color.White)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Switch(
|
||||
checked = selectAllState,
|
||||
onCheckedChange = {
|
||||
selectAllState = it
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
Text(text = context.translation["manager.dialogs.messaging_action.select_all_button"], color = Color.White)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
Button(
|
||||
onClick = { onDismiss() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(context.translation["button.cancel"], color = Color.White)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
onChoose(
|
||||
if (selectAllState) ContentType.entries.toTypedArray()
|
||||
else selectedTypes.toTypedArray()
|
||||
)
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(context.translation["button.ok"], color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConversationPreview(
|
||||
messages: List<Message>,
|
||||
scope: SocialScope,
|
||||
scopeId: String,
|
||||
myUserId: String?,
|
||||
friendDisplayName: String?,
|
||||
fetchNewMessages: () -> Unit,
|
||||
) {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
selectedMessages.clear()
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
reverseLayout = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
state = previewScrollState,
|
||||
contentPadding = PaddingValues(top = 10.dp, bottom = routes.bottomPadding + 18.dp)
|
||||
) {
|
||||
items(messages, key = { it.serverMessageId }) {message ->
|
||||
val messageReader = remember(message.contentType) { ProtoReader(message.content) }
|
||||
val contentType = ContentType.fromMessageContainer(messageReader)
|
||||
val senderId = message.senderId
|
||||
val isMine = senderId != null && senderId == myUserId
|
||||
|
||||
val isSelected = selectedMessages.contains(message.clientMessageId)
|
||||
val borderBrush = if (isSelected) {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.85f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.75f)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
Color.White.copy(alpha = 0.08f),
|
||||
Color.White.copy(alpha = 0.08f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val senderDisplayName by rememberAsyncMutableState<String?>(null, keys = arrayOf(senderId, myUserId, scope.key, scopeId, friendDisplayName)) {
|
||||
when {
|
||||
senderId == null -> "Unknown"
|
||||
senderId == myUserId -> "You"
|
||||
scope == SocialScope.FRIEND -> friendDisplayName
|
||||
?: context.database.getFriendInfo(scopeId)?.displayName
|
||||
?: context.database.getFriendInfo(scopeId)?.mutableUsername
|
||||
?: "Friend"
|
||||
else -> context.database.getFriendInfo(senderId)?.displayName
|
||||
?: context.database.getFriendInfo(senderId)?.mutableUsername
|
||||
?: "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
val contentTypeLabel = remember(contentType) {
|
||||
contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } ?: "Unknown"
|
||||
}
|
||||
val bodyText = remember(message.contentType) { messageReader.getString(2, 1)?.trim().orEmpty() }
|
||||
|
||||
if (contentType == ContentType.STATUS) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(Color.White.copy(alpha = 0.08f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(999.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "[$contentTypeLabel] ${bodyText.ifBlank { "—" }}",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
return@items
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
horizontalArrangement = if (isMine) Arrangement.End else Arrangement.Start
|
||||
) {
|
||||
val bubbleShape = if (isMine) {
|
||||
RoundedCornerShape(topStart = 22.dp, topEnd = 22.dp, bottomStart = 22.dp, bottomEnd = 8.dp)
|
||||
} else {
|
||||
RoundedCornerShape(topStart = 22.dp, topEnd = 22.dp, bottomStart = 8.dp, bottomEnd = 22.dp)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.86f)
|
||||
.widthIn(max = 360.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onLongPress = { toggleSelectedMessage(message.clientMessageId) },
|
||||
onTap = {
|
||||
if (selectedMessages.isNotEmpty()) toggleSelectedMessage(message.clientMessageId)
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = senderDisplayName ?: "Unknown",
|
||||
color = Color.White.copy(alpha = 0.82f),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.shadow(
|
||||
elevation = if (isSelected) 10.dp else 6.dp,
|
||||
shape = bubbleShape,
|
||||
clip = true,
|
||||
ambientColor = PurrfectPalette.glowSecondary.copy(alpha = 0.18f),
|
||||
spotColor = PurrfectPalette.glowPrimary.copy(alpha = 0.16f),
|
||||
)
|
||||
.clip(bubbleShape)
|
||||
.background(
|
||||
brush = if (isMine) {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.30f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.16f)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
Color.White.copy(alpha = 0.10f),
|
||||
Color.White.copy(alpha = 0.06f)
|
||||
)
|
||||
)
|
||||
},
|
||||
shape = bubbleShape
|
||||
)
|
||||
.border(BorderStroke(1.dp, borderBrush), bubbleShape)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
if (contentType != ContentType.CHAT) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(Color.White.copy(alpha = 0.10f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(999.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = contentTypeLabel,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = bodyText.ifBlank { contentTypeLabel },
|
||||
color = Color.White,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
if (messages.isEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(40.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(tr("no_message_hint", "No messages"), color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (messages.isNotEmpty()) {
|
||||
fetchNewMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun LoadingRow() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(40.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.padding()
|
||||
.size(30.dp),
|
||||
strokeWidth = 3.dp,
|
||||
color = PurrfectPalette.glowSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { navBackStackEntry ->
|
||||
val scope = remember { SocialScope.getByName(navBackStackEntry.arguments?.getString("scope")!!) }
|
||||
val id = remember { navBackStackEntry.arguments?.getString("id")!! }
|
||||
|
||||
previewScrollState = rememberLazyListState()
|
||||
coroutineScope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
var topBarHeight by remember { mutableStateOf(96.dp) }
|
||||
|
||||
val titleText by rememberAsyncMutableState<String?>(null, keys = arrayOf(scope.key, id)) {
|
||||
when (scope) {
|
||||
SocialScope.FRIEND -> context.database.getFriendInfo(id)?.displayName
|
||||
?: context.database.getFriendInfo(id)?.mutableUsername
|
||||
SocialScope.GROUP -> context.database.getGroupInfo(id)?.name
|
||||
}
|
||||
}
|
||||
|
||||
var lastMessageId by remember { mutableLongStateOf(Long.MAX_VALUE) }
|
||||
var isBridgeConnected by remember { mutableStateOf(false) }
|
||||
var hasBridgeError by remember { mutableStateOf(false) }
|
||||
var actionsOpen by remember { mutableStateOf(false) }
|
||||
var selectConstraintsDialog by remember { mutableStateOf(false) }
|
||||
var activeTask by remember { mutableStateOf(null as MessagingTask?) }
|
||||
var activeJob by remember { mutableStateOf(null as Job?) }
|
||||
val processMessageCount = remember { mutableIntStateOf(0) }
|
||||
|
||||
fun runCurrentTask() {
|
||||
activeJob = coroutineScope.launch(Dispatchers.IO) {
|
||||
activeTask?.run()
|
||||
withContext(Dispatchers.Main) {
|
||||
activeTask = null
|
||||
activeJob = null
|
||||
}
|
||||
}.also { job ->
|
||||
job.invokeOnCompletion {
|
||||
if (it != null) {
|
||||
context.log.verbose("Failed to process messages: ${it.message}")
|
||||
return@invokeOnCompletion
|
||||
}
|
||||
val toastText = translation.getOrNull("processed_message_toast")?.let {
|
||||
translation.format("processed_message_toast", "count" to processMessageCount.intValue.toString())
|
||||
} ?: translation.getOrNull("processed_messages_toast")?.let {
|
||||
translation.format("processed_messages_toast", "count" to processMessageCount.intValue.toString())
|
||||
} ?: "Processed ${processMessageCount.intValue} messages"
|
||||
context.longToast(toastText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun launchMessagingTask(
|
||||
taskType: MessagingTaskType,
|
||||
constraints: List<MessagingTaskConstraint> = listOf(),
|
||||
onSuccess: (Message) -> Unit = {},
|
||||
) {
|
||||
if (messagingBridge == null) {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge"
|
||||
)
|
||||
return
|
||||
}
|
||||
actionsOpen = false
|
||||
processMessageCount.intValue = 0
|
||||
activeTask = MessagingTask(
|
||||
messagingBridge!!,
|
||||
conversationId!!,
|
||||
taskType,
|
||||
constraints,
|
||||
overrideClientMessageIds = selectedMessages.takeIf { it.isNotEmpty() }?.toList(),
|
||||
processedMessageCount = processMessageCount,
|
||||
onSuccess = onSuccess,
|
||||
onFailure = { message, reason ->
|
||||
context.log.verbose("Failed to process message ${message.clientMessageId}: $reason")
|
||||
}
|
||||
)
|
||||
selectedMessages.clear()
|
||||
}
|
||||
|
||||
if (selectConstraintsDialog && activeTask != null) {
|
||||
Dialog(onDismissRequest = {
|
||||
selectConstraintsDialog = false
|
||||
activeTask = null
|
||||
}) {
|
||||
ConstraintsSelectionDialog(
|
||||
onChoose = { contentTypes ->
|
||||
launchMessagingTask(
|
||||
taskType = activeTask!!.taskType,
|
||||
constraints = activeTask!!.constraints + MessagingConstraints.CONTENT_TYPE(contentTypes),
|
||||
onSuccess = activeTask!!.onSuccess
|
||||
)
|
||||
runCurrentTask()
|
||||
selectConstraintsDialog = false
|
||||
},
|
||||
onDismiss = {
|
||||
selectConstraintsDialog = false
|
||||
activeTask = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (activeJob != null) {
|
||||
Dialog(onDismissRequest = {
|
||||
activeJob?.cancel()
|
||||
activeJob = null
|
||||
activeTask = null
|
||||
}) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(15.dp)
|
||||
.border(1.dp, MaterialTheme.colorScheme.onSurface, RoundedCornerShape(20.dp)),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp)
|
||||
) {
|
||||
val processedText = translation.getOrNull("processed_messages_text")?.let {
|
||||
translation.format("processed_messages_text", "count" to processMessageCount.intValue.toString())
|
||||
} ?: "Processed ${processMessageCount.intValue}"
|
||||
Text(processedText)
|
||||
if (activeTask?.hasFixedGoal() == true) {
|
||||
LinearProgressIndicator(
|
||||
progress = { processMessageCount.intValue.toFloat() / selectedMessages.size.toFloat() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.padding()
|
||||
.size(30.dp),
|
||||
strokeWidth = 3.dp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchNewMessages() {
|
||||
coroutineScope.launch(Dispatchers.IO) cs@{
|
||||
runCatching {
|
||||
val queriedMessages = messagingBridge!!.fetchConversationWithMessagesPaginated(
|
||||
conversationId!!,
|
||||
20,
|
||||
lastMessageId
|
||||
)?.reversed() ?: throw IllegalStateException("Failed to fetch messages. Bridge returned null")
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
messages.addAll(queriedMessages)
|
||||
lastMessageId = queriedMessages.lastOrNull()?.clientMessageId ?: lastMessageId
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to fetch messages", it)
|
||||
context.shortToast(
|
||||
translation.getOrNull("message_fetch_failed") ?: "Failed to fetch messages"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onMessagingBridgeReady(scope: SocialScope, scopeId: String) {
|
||||
context.log.verbose("onMessagingBridgeReady: $scope $scopeId")
|
||||
|
||||
runCatching {
|
||||
conversationId = (if (scope == SocialScope.FRIEND) messagingBridge!!.getOneToOneConversationId(scopeId) else scopeId) ?: throw IllegalStateException("Failed to get conversation id")
|
||||
if (runCatching { !messagingBridge!!.isSessionStarted }.getOrDefault(true)) {
|
||||
context.androidContext.packageManager.getLaunchIntentForPackage(
|
||||
Constants.SNAPCHAT_PACKAGE_NAME
|
||||
)?.let {
|
||||
val mainIntent = Intent.makeMainActivity(it.component).apply {
|
||||
putExtra(ReceiversConfig.MESSAGING_PREVIEW_EXTRA, true)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.androidContext.startActivity(mainIntent)
|
||||
}
|
||||
messagingBridge!!.registerSessionStartListener(object: SessionStartListener.Stub() {
|
||||
override fun onConnected() {
|
||||
fetchNewMessages()
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
fetchNewMessages()
|
||||
}.onFailure {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_init_failed") ?: "Failed to initialize messaging bridge"
|
||||
)
|
||||
context.log.error("Failed to initialize messaging bridge", it)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
messages.clear()
|
||||
conversationId = null
|
||||
|
||||
isBridgeConnected = context.hasMessagingBridge()
|
||||
if (isBridgeConnected) {
|
||||
withContext(Dispatchers.IO) {
|
||||
onMessagingBridgeReady(scope, id)
|
||||
}
|
||||
} else {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also {
|
||||
context.androidContext.sendBroadcast(it)
|
||||
}
|
||||
withTimeout(10000) {
|
||||
while (!context.hasMessagingBridge()) {
|
||||
delay(100)
|
||||
}
|
||||
isBridgeConnected = true
|
||||
onMessagingBridgeReady(scope, id)
|
||||
}
|
||||
}.invokeOnCompletion {
|
||||
if (it != null) {
|
||||
hasBridgeError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = titleText ?: translation["title"] ?: "Preview",
|
||||
subtitle = if (selectedMessages.isNotEmpty()) {
|
||||
"${selectedMessages.size} selected"
|
||||
} else {
|
||||
tr("subtitle", "Hold to select")
|
||||
.substringBefore("•")
|
||||
.substringBefore("·")
|
||||
.substringBefore("|")
|
||||
.trim()
|
||||
},
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
actions = {
|
||||
AnimatedVisibility(
|
||||
visible = selectedMessages.isNotEmpty(),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
IconButton(onClick = { selectedMessages.clear() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = translation.getOrNull("close_button_description"),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { if (messages.isNotEmpty()) actionsOpen = true }) {
|
||||
Icon(imageVector = Icons.Rounded.MoreVert, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
.onGloballyPositioned {
|
||||
val newHeight = with(density) { it.size.height.toDp() }
|
||||
if (newHeight != topBarHeight) topBarHeight = newHeight
|
||||
}
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = topBarHeight + 6.dp)
|
||||
.padding(horizontal = 14.dp)
|
||||
) {
|
||||
if (hasBridgeError) {
|
||||
Text(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
if (!isBridgeConnected && !hasBridgeError) {
|
||||
LoadingRow()
|
||||
}
|
||||
|
||||
if (isBridgeConnected && !hasBridgeError) {
|
||||
ConversationPreview(
|
||||
messages = messages,
|
||||
scope = scope,
|
||||
scopeId = id,
|
||||
myUserId = messagingBridge?.myUserId,
|
||||
friendDisplayName = titleText,
|
||||
fetchNewMessages = ::fetchNewMessages
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (actionsOpen) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { actionsOpen = false },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = Color.Transparent,
|
||||
dragHandle = null
|
||||
) {
|
||||
val hasSelection = selectedMessages.isNotEmpty()
|
||||
val selectionSubtitle = if (hasSelection) {
|
||||
"${selectedMessages.size} selected"
|
||||
} else {
|
||||
"Choose message types"
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
brush = PurrfectPalette.cardOverlay,
|
||||
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
||||
)
|
||||
.border(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.5f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.3f)
|
||||
)
|
||||
),
|
||||
RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.size(width = 42.dp, height = 5.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(Color.White.copy(alpha = 0.14f))
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(
|
||||
text = tr("actions_title", "Conversation Actions"),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = selectionSubtitle,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
val saveKey = if (hasSelection) "save_selection_option" else "save_all_option"
|
||||
val unsaveKey = if (hasSelection) "unsave_selection_option" else "unsave_all_option"
|
||||
val markKey = if (hasSelection) "mark_selection_as_seen_option" else "mark_all_as_seen_option"
|
||||
val deleteKey = if (hasSelection) "delete_selection_option" else "delete_all_option"
|
||||
|
||||
ActionsSheetItem(
|
||||
title = tr(saveKey, if (hasSelection) "Save Selection" else "Save All"),
|
||||
subtitle = if (hasSelection) "Save selected messages" else "Save by content type",
|
||||
icon = Icons.Rounded.BookmarkAdded
|
||||
) {
|
||||
launchMessagingTask(MessagingTaskType.SAVE)
|
||||
if (hasSelection) runCurrentTask() else selectConstraintsDialog = true
|
||||
}
|
||||
ActionsSheetItem(
|
||||
title = tr(unsaveKey, if (hasSelection) "Unsave Selection" else "Unsave All"),
|
||||
subtitle = if (hasSelection) "Unsave selected messages" else "Unsave by content type",
|
||||
icon = Icons.Rounded.BookmarkBorder
|
||||
) {
|
||||
launchMessagingTask(MessagingTaskType.UNSAVE)
|
||||
if (hasSelection) runCurrentTask() else selectConstraintsDialog = true
|
||||
}
|
||||
ActionsSheetItem(
|
||||
title = tr(markKey, if (hasSelection) "Mark selected as seen" else "Mark all as seen"),
|
||||
subtitle = "Marks snaps as seen",
|
||||
icon = Icons.Rounded.RemoveRedEye
|
||||
) {
|
||||
if (messagingBridge == null) {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge"
|
||||
)
|
||||
return@ActionsSheetItem
|
||||
}
|
||||
launchMessagingTask(
|
||||
MessagingTaskType.READ,
|
||||
listOf(
|
||||
MessagingConstraints.NO_USER_ID(messagingBridge!!.myUserId),
|
||||
MessagingConstraints.CONTENT_TYPE(arrayOf(ContentType.SNAP))
|
||||
)
|
||||
)
|
||||
runCurrentTask()
|
||||
}
|
||||
ActionsSheetItem(
|
||||
title = tr(deleteKey, if (hasSelection) "Delete Selection" else "Delete All"),
|
||||
subtitle = if (hasSelection) "Delete selected messages" else "Delete by content type",
|
||||
icon = Icons.Rounded.DeleteForever,
|
||||
danger = true
|
||||
) {
|
||||
if (messagingBridge == null) {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge"
|
||||
)
|
||||
return@ActionsSheetItem
|
||||
}
|
||||
launchMessagingTask(
|
||||
MessagingTaskType.DELETE,
|
||||
listOf(
|
||||
MessagingConstraints.USER_ID(messagingBridge!!.myUserId),
|
||||
{ contentType != ContentType.STATUS.id }
|
||||
)
|
||||
) { message ->
|
||||
coroutineScope.launch { message.contentType = ContentType.STATUS.id }
|
||||
}
|
||||
if (hasSelection) runCurrentTask() else selectConstraintsDialog = true
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.social
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.RemoveRedEye
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
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.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.data.SocialScope
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.storage.*
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
|
||||
|
||||
class SocialRootSection : Routes.Route() {
|
||||
private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
|
||||
private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
|
||||
|
||||
private fun updateScopeLists() {
|
||||
context.coroutineScope.launch {
|
||||
friendList = context.database.getFriends(descOrder = true)
|
||||
groupList = context.database.getGroups()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScopeList(scope: SocialScope) {
|
||||
val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 10.dp, end = 10.dp, bottom = routes.bottomPadding + 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
//check if scope list is empty
|
||||
val listSize = when (scope) {
|
||||
SocialScope.GROUP -> groupList.size
|
||||
SocialScope.FRIEND -> friendList.size
|
||||
}
|
||||
|
||||
if (listSize == 0) {
|
||||
item {
|
||||
EmptyState(scope)
|
||||
}
|
||||
}
|
||||
|
||||
items(listSize) { index ->
|
||||
val id = when (scope) {
|
||||
SocialScope.GROUP -> groupList[index].conversationId
|
||||
SocialScope.FRIEND -> friendList[index].userId
|
||||
}
|
||||
|
||||
SocialCard(
|
||||
scope = scope,
|
||||
index = index,
|
||||
onManage = {
|
||||
routes.manageScope.navigate {
|
||||
put("id", id)
|
||||
put("scope", scope.key)
|
||||
}
|
||||
},
|
||||
onPreview = {
|
||||
routes.messagingPreview.navigate {
|
||||
put("id", id)
|
||||
put("scope", scope.key)
|
||||
}
|
||||
},
|
||||
remainingHours = remainingHours
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val floatingActionButton: @Composable () -> Unit = {
|
||||
var addFriendDialog by remember { mutableStateOf(null as AddFriendDialog?) }
|
||||
|
||||
if (addFriendDialog != null) {
|
||||
addFriendDialog?.Content {
|
||||
addFriendDialog = null
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
updateScopeLists()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
addFriendDialog = AddFriendDialog(
|
||||
context,
|
||||
AddFriendDialog.Actions(
|
||||
onFriendState = { friend, state ->
|
||||
if (state) {
|
||||
context.bridgeService?.triggerScopeSync(
|
||||
SocialScope.FRIEND,
|
||||
friend.userId
|
||||
)
|
||||
} else {
|
||||
context.database.deleteFriend(friend.userId)
|
||||
}
|
||||
},
|
||||
onGroupState = { group, state ->
|
||||
if (state) {
|
||||
context.bridgeService?.triggerScopeSync(
|
||||
SocialScope.GROUP,
|
||||
group.conversationId
|
||||
)
|
||||
} else {
|
||||
context.database.deleteGroup(group.conversationId)
|
||||
}
|
||||
},
|
||||
getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
|
||||
getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
|
||||
),
|
||||
pinnedIds = (friendList.map { it.userId } + groupList.map { it.conversationId }).reversed(),
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.shadow(14.dp, RoundedCornerShape(20.dp), clip = false),
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 0.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.background(
|
||||
brush = Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)),
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.22f), RoundedCornerShape(18.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Add,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val titles = remember {
|
||||
listOf(translation["friends_tab"], translation["groups_tab"])
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState { titles.size }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
updateScopeLists()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
SocialHeader(
|
||||
titles = titles,
|
||||
pagerState = pagerState,
|
||||
onTabSelected = { index ->
|
||||
coroutineScope.launch { pagerState.animateScrollToPage(index) }
|
||||
},
|
||||
friendCount = friendList.size,
|
||||
groupCount = groupList.size
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
HorizontalPager(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
state = pagerState
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> ScopeList(SocialScope.FRIEND)
|
||||
1 -> ScopeList(SocialScope.GROUP)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SocialCard(
|
||||
scope: SocialScope,
|
||||
index: Int,
|
||||
onManage: () -> Unit,
|
||||
onPreview: () -> Unit,
|
||||
remainingHours: Int
|
||||
) {
|
||||
val cardGradient = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.16f)
|
||||
)
|
||||
)
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 88.dp)
|
||||
.border(1.dp, cardGradient, RoundedCornerShape(20.dp)),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
onClick = onManage,
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(20.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
when (scope) {
|
||||
SocialScope.GROUP -> {
|
||||
val group = groupList[index]
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Groups,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = group.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
Text(
|
||||
text = translation["groups_tab"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SocialScope.FRIEND -> {
|
||||
val friend = friendList[index]
|
||||
val streaks by rememberAsyncMutableState(defaultValue = friend.streaks) {
|
||||
context.database.getFriendStreaks(friend.userId)
|
||||
}
|
||||
|
||||
BitmojiImage(
|
||||
context = context,
|
||||
url = BitmojiSelfie.getBitmojiSelfie(
|
||||
friend.selfieId,
|
||||
friend.bitmojiId,
|
||||
BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D
|
||||
)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = friend.displayName ?: friend.mutableUsername,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
Text(
|
||||
text = friend.mutableUsername,
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
streaks?.takeIf { it.notify }?.let { streaks ->
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.streak_icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(18.dp),
|
||||
tint = if (streaks.isAboutToExpire(remainingHours))
|
||||
Color(0xFFFF6B9B)
|
||||
else PurrfectPalette.glowSecondary
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = translation.format(
|
||||
"streaks_expiration_short",
|
||||
"hours" to (((streaks.expirationTimestamp - System.currentTimeMillis()) / 3600000).toInt().takeIf { it > 0 } ?: 0)
|
||||
.toString()
|
||||
),
|
||||
maxLines = 1,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
onClick = onPreview,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.2f)
|
||||
)
|
||||
),
|
||||
RoundedCornerShape(16.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.RemoveRedEye,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SocialHeader(
|
||||
titles: List<String>,
|
||||
pagerState: androidx.compose.foundation.pager.PagerState,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
friendCount: Int,
|
||||
groupCount: Int
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["manager.routes.social"] ?: "Social",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
StatPill(label = "Friends", value = friendCount)
|
||||
StatPill(label = "Groups", value = groupCount)
|
||||
}
|
||||
}
|
||||
SocialTabSwitcher(
|
||||
titles = titles,
|
||||
pagerState = pagerState,
|
||||
onTabSelected = onTabSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SocialTabSwitcher(
|
||||
titles: List<String>,
|
||||
pagerState: androidx.compose.foundation.pager.PagerState,
|
||||
onTabSelected: (Int) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
titles.forEachIndexed { index, title ->
|
||||
val selected = pagerState.currentPage == index
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (selected) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.06f),
|
||||
border = if (selected) BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))) else BorderStroke(
|
||||
1.dp,
|
||||
Color.White.copy(alpha = 0.12f)
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable { onTabSelected(index) }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (index == 0) Icons.Filled.People else Icons.Filled.Groups,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(scope: SocialScope) {
|
||||
val title = when (scope) {
|
||||
SocialScope.FRIEND -> translation.getOrNull("friends_empty_title") ?: translation["empty_hint"]
|
||||
SocialScope.GROUP -> translation.getOrNull("groups_empty_title") ?: translation["empty_hint"]
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = Color.White.copy(alpha = 0.05f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (scope == SocialScope.FRIEND) Icons.Filled.People else Icons.Filled.Groups,
|
||||
contentDescription = null,
|
||||
tint = PurrfectPalette.glowSecondary,
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
Text(
|
||||
text = translation["social_empty_hint"] ?: "Tap the + button to sync friends or groups.",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatPill(label: String, value: Int) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = value.toString(),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,781 @@
|
||||
@file:OptIn(
|
||||
androidx.compose.material3.ExperimentalMaterial3Api::class,
|
||||
androidx.compose.foundation.layout.ExperimentalLayoutApi::class
|
||||
)
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.tracker
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
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.compose.ui.window.DialogProperties
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.data.*
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.storage.*
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.AddFriendDialog
|
||||
|
||||
class EditRule : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.friend_tracker") }
|
||||
|
||||
@Composable
|
||||
private fun RuleCard(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(16.dp),
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val shape = RoundedCornerShape(22.dp)
|
||||
val border = remember {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.5f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.42f)
|
||||
)
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = shape,
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 14.dp,
|
||||
border = BorderStroke(1.dp, border)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, shape)
|
||||
.padding(contentPadding)
|
||||
) {
|
||||
Column(content = content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionCheckbox(
|
||||
text: String,
|
||||
checked: MutableState<Boolean>,
|
||||
onChanged: (Boolean) -> Unit = {}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.clickable {
|
||||
checked.value = !checked.value
|
||||
onChanged(checked.value)
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
modifier = Modifier.height(30.dp),
|
||||
checked = checked.value,
|
||||
onCheckedChange = { checked.value = it; onChanged(it) },
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = PurrfectPalette.glowPrimary,
|
||||
uncheckedColor = Color.White.copy(alpha = 0.8f),
|
||||
checkmarkColor = Color.Black
|
||||
)
|
||||
)
|
||||
Text(text, fontSize = 12.sp, color = Color.White)
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun ConditionCheckboxes(
|
||||
params: TrackerRuleActionParams
|
||||
) {
|
||||
ActionCheckbox(
|
||||
text = translation["condition_only_inside_conversation"],
|
||||
checked = remember { mutableStateOf(params.onlyInsideConversation) },
|
||||
onChanged = { params.onlyInsideConversation = it }
|
||||
)
|
||||
ActionCheckbox(
|
||||
text = translation["condition_only_outside_conversation"],
|
||||
checked = remember { mutableStateOf(params.onlyOutsideConversation) },
|
||||
onChanged = { params.onlyOutsideConversation = it }
|
||||
)
|
||||
ActionCheckbox(
|
||||
text = translation["condition_only_when_app_active"],
|
||||
checked = remember { mutableStateOf(params.onlyWhenAppActive) },
|
||||
onChanged = { params.onlyWhenAppActive = it }
|
||||
)
|
||||
ActionCheckbox(
|
||||
text = translation["condition_only_when_app_inactive"],
|
||||
checked = remember { mutableStateOf(params.onlyWhenAppInactive) },
|
||||
onChanged = { params.onlyWhenAppInactive = it }
|
||||
)
|
||||
ActionCheckbox(
|
||||
text = translation["condition_no_push_notification_when_app_active"],
|
||||
checked = remember { mutableStateOf(params.noPushNotificationWhenAppActive) },
|
||||
onChanged = { params.noPushNotificationWhenAppActive = it }
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
fun AddEventDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onEventAdd: (TrackerRuleEvent) -> Unit
|
||||
) {
|
||||
val expanded = remember { mutableStateOf(false) }
|
||||
val currentEventType = remember { mutableStateOf(TrackerEventType.CONVERSATION_ENTER.key) }
|
||||
val addEventActions = remember { mutableStateOf(emptySet<TrackerRuleAction>()) }
|
||||
val addEventActionParams = remember { TrackerRuleActionParams() }
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = DialogProperties(dismissOnClickOutside = true, usePlatformDefaultWidth = false)
|
||||
) {
|
||||
RuleCard(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp)
|
||||
.widthIn(max = 420.dp),
|
||||
contentPadding = PaddingValues(18.dp)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
translation["add_event_dialog_title"],
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded.value,
|
||||
onExpandedChange = { expanded.value = !expanded.value },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
val eventLabel = context.translation["tracker_events.${currentEventType.value}"]
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.menuAnchor()
|
||||
.widthIn(min = 240.dp),
|
||||
value = eventLabel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center, color = Color.White),
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded.value) },
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = PurrfectPalette.cardOverlayColor,
|
||||
unfocusedContainerColor = PurrfectPalette.cardOverlayColor,
|
||||
focusedIndicatorColor = Color.White.copy(alpha = 0.18f),
|
||||
unfocusedIndicatorColor = Color.White.copy(alpha = 0.14f),
|
||||
cursorColor = Color.Transparent,
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded.value,
|
||||
onDismissRequest = { expanded.value = false },
|
||||
modifier = Modifier.widthIn(min = 240.dp),
|
||||
containerColor = Color(0xFF121528),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
TrackerEventType.entries.forEach { eventType ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
currentEventType.value = eventType.key
|
||||
expanded.value = false
|
||||
},
|
||||
text = { Text(context.translation["tracker_events.${eventType.key}"], color = Color.White) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
translation["triggers_title"],
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
TrackerRuleAction.entries.forEach { action ->
|
||||
ActionCheckbox(
|
||||
context.translation["tracker_actions.${action.key}"],
|
||||
checked = remember { mutableStateOf(addEventActions.value.contains(action)) }
|
||||
) {
|
||||
if (it) addEventActions.value += action else addEventActions.value -= action
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
translation["conditions_title"],
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
ConditionCheckboxes(addEventActionParams)
|
||||
Button(
|
||||
onClick = {
|
||||
onEventAdd(
|
||||
TrackerRuleEvent(
|
||||
id = -1,
|
||||
enabled = true,
|
||||
eventType = currentEventType.value,
|
||||
params = addEventActionParams.copy(),
|
||||
actions = addEventActions.value.toList()
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) { Text(translation["add_button"]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
override val title: @Composable () -> Unit = {}
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { navBackStackEntry ->
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val currentRuleId = navBackStackEntry.arguments?.getString("rule_id")?.toIntOrNull()
|
||||
val events = rememberAsyncMutableStateList<TrackerRuleEvent>(defaultValue = emptyList()) {
|
||||
currentRuleId?.let { ruleId -> context.database.getTrackerEvents(ruleId) } ?: emptyList()
|
||||
}
|
||||
val eventsToDelete = remember { mutableStateListOf<TrackerRuleEvent>() }
|
||||
var currentScopeType by remember { mutableStateOf(TrackerScopeType.BLACKLIST) }
|
||||
val scopes = rememberAsyncMutableStateList<String>(defaultValue = emptyList()) {
|
||||
currentRuleId?.let { ruleId ->
|
||||
context.database.getRuleTrackerScopes(ruleId).also { map ->
|
||||
currentScopeType = if (map.isEmpty()) TrackerScopeType.WHITELIST else map.values.first()
|
||||
}.map { entry -> entry.key }
|
||||
} ?: emptyList()
|
||||
}
|
||||
val ruleName = rememberAsyncMutableState<String>(defaultValue = "", keys = arrayOf(currentRuleId)) {
|
||||
currentRuleId?.let { ruleId -> context.database.getTrackerRule(ruleId)?.name ?: translation["default_rule_name"] } ?: translation["default_rule_name"]
|
||||
}
|
||||
val authorName = rememberAsyncMutableState<String>(defaultValue = "", keys = arrayOf(currentRuleId)) {
|
||||
currentRuleId?.let { ruleId -> context.database.getTrackerRule(ruleId)?.author ?: "" } ?: ""
|
||||
}
|
||||
val initialRuleState by remember(ruleName.value.isNotBlank() || events.isNotEmpty() || scopes.isNotEmpty()) {
|
||||
mutableStateOf(
|
||||
mapOf(
|
||||
"name" to ruleName.value,
|
||||
"author" to authorName.value,
|
||||
"scopes" to scopes.toList(),
|
||||
"events" to events.toList(),
|
||||
"scopeType" to currentScopeType
|
||||
)
|
||||
)
|
||||
}
|
||||
val isDirty by remember {
|
||||
derivedStateOf {
|
||||
initialRuleState["name"] != ruleName.value ||
|
||||
initialRuleState["author"] != authorName.value ||
|
||||
initialRuleState["scopes"] != scopes.toList() ||
|
||||
initialRuleState["events"] != events.toList() ||
|
||||
initialRuleState["scopeType"] != currentScopeType
|
||||
}
|
||||
}
|
||||
var deleteConfirmation by remember { mutableStateOf(false) }
|
||||
var showDuplicateNameDialog by remember { mutableStateOf(false) }
|
||||
var showEventsEmptyDialog by remember { mutableStateOf(false) }
|
||||
var showDiscardDialog by remember { mutableStateOf(false) }
|
||||
var addFriendDialog by remember { mutableStateOf<AddFriendDialog?>(null) }
|
||||
var addEventDialogVisible by remember { mutableStateOf(false) }
|
||||
if (showDiscardDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showDiscardDialog = false },
|
||||
title = translation["discard_changes_dialog_title"],
|
||||
text = translation["discard_changes_dialog_text"],
|
||||
icon = Icons.Default.Warning,
|
||||
confirmButtonText = translation["discard_button"],
|
||||
onConfirm = {
|
||||
showDiscardDialog = false
|
||||
routes.navController.popBackStack()
|
||||
},
|
||||
dismissButtonText = translation["button.cancel"],
|
||||
onDismiss = { showDiscardDialog = false },
|
||||
opaque = true,
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
BackHandler(enabled = isDirty) {
|
||||
showDiscardDialog = true
|
||||
}
|
||||
if (showEventsEmptyDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showEventsEmptyDialog = false },
|
||||
title = translation["cannot_save_rule_dialog_title"],
|
||||
text = translation["cannot_save_rule_dialog_text"],
|
||||
icon = Icons.Default.Info,
|
||||
confirmButtonText = translation["button.ok"],
|
||||
onConfirm = { showEventsEmptyDialog = false },
|
||||
opaque = true,
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
if (showDuplicateNameDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showDuplicateNameDialog = false },
|
||||
title = translation["duplicate_rule_name_dialog_title"],
|
||||
text = translation["duplicate_rule_name_dialog_text"],
|
||||
icon = Icons.Default.Warning,
|
||||
confirmButtonText = translation["button.ok"],
|
||||
onConfirm = { showDuplicateNameDialog = false }
|
||||
)
|
||||
}
|
||||
if (deleteConfirmation) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { deleteConfirmation = false },
|
||||
title = translation["delete_rule_dialog_title"],
|
||||
text = translation["delete_rule_dialog_text"],
|
||||
icon = Icons.Default.DeleteOutline,
|
||||
confirmButtonText = translation["delete_button"],
|
||||
onConfirm = {
|
||||
if (currentRuleId != null) context.database.deleteTrackerRule(currentRuleId)
|
||||
routes.navController.popBackStack()
|
||||
},
|
||||
dismissButtonText = translation["button.cancel"],
|
||||
onDismiss = { deleteConfirmation = false },
|
||||
opaque = true,
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.navigationBarsPadding(),
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
val topShape = RoundedCornerShape(26.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
shape = topShape,
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
shadowElevation = 12.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
if (isDirty) {
|
||||
showDiscardDialog = true
|
||||
} else {
|
||||
routes.navController.popBackStack()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = translation["back_button_description"], tint = Color.White)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
translation[if (currentRuleId == null) "new_rule_title" else "edit_rule_title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Text(
|
||||
translation["rule_subtitle"] ?: "",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
if (events.isEmpty()) {
|
||||
showEventsEmptyDialog = true
|
||||
return@IconButton
|
||||
}
|
||||
if (currentRuleId == null && context.database.getTrackerRuleByName(ruleName.value.trim()) != null) {
|
||||
showDuplicateNameDialog = true
|
||||
return@IconButton
|
||||
}
|
||||
val ruleId = currentRuleId ?: context.database.newTrackerRule()
|
||||
eventsToDelete.forEach { event -> context.database.deleteTrackerRuleEvent(event.id.takeIf { it > -1 } ?: return@forEach) }
|
||||
events.forEach { event ->
|
||||
context.database.addOrUpdateTrackerRuleEvent(
|
||||
event.id.takeIf { it > -1 },
|
||||
ruleId,
|
||||
event.eventType,
|
||||
event.params,
|
||||
event.actions
|
||||
)
|
||||
}
|
||||
context.database.setTrackerRuleName(ruleId, ruleName.value.trim())
|
||||
context.database.setTrackerRuleAuthor(ruleId, authorName.value.trim())
|
||||
context.database.setRuleTrackerScopes(ruleId, currentScopeType, scopes)
|
||||
routes.navController.popBackStack()
|
||||
}) { Icon(Icons.Filled.Save, contentDescription = translation["save_button_description"], tint = Color.White) }
|
||||
if (currentRuleId != null) {
|
||||
IconButton(onClick = { deleteConfirmation = true }) {
|
||||
Icon(Icons.Default.DeleteOutline, contentDescription = translation["delete_button_description"], tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
.padding(padding)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
RuleCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
translation["general_section_title"],
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
TextField(
|
||||
value = ruleName.value,
|
||||
onValueChange = { ruleName.value = it },
|
||||
label = { Text(translation["rule_name_label"], color = PurrfectPalette.textSecondary) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.04f),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
cursorColor = PurrfectPalette.glowPrimary,
|
||||
focusedLabelColor = Color.White,
|
||||
unfocusedLabelColor = PurrfectPalette.textSecondary
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(color = Color.White)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextField(
|
||||
value = authorName.value,
|
||||
onValueChange = { authorName.value = it },
|
||||
label = { Text(translation["author_name_label"], color = PurrfectPalette.textSecondary) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.04f),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
cursorColor = PurrfectPalette.glowPrimary,
|
||||
focusedLabelColor = Color.White,
|
||||
unfocusedLabelColor = PurrfectPalette.textSecondary
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(color = Color.White)
|
||||
)
|
||||
}
|
||||
RuleCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
translation["scope_section_title"],
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
val friendDialogActions = remember {
|
||||
AddFriendDialog.Actions(
|
||||
onFriendState = { friend, state ->
|
||||
if (state) scopes.add(friend.userId) else scopes.remove(friend.userId)
|
||||
},
|
||||
onGroupState = { group, state ->
|
||||
if (state) scopes.add(group.conversationId) else scopes.remove(group.conversationId)
|
||||
},
|
||||
getFriendState = { friend -> friend.userId in scopes },
|
||||
getGroupState = { group -> group.conversationId in scopes }
|
||||
)
|
||||
}
|
||||
val scopeOptions = listOf(
|
||||
0 to translation["scope_all"],
|
||||
1 to translation["scope_whitelist"],
|
||||
2 to translation["scope_blacklist"]
|
||||
)
|
||||
val selectedScopeIndex = when {
|
||||
scopes.isEmpty() -> 0
|
||||
currentScopeType == TrackerScopeType.WHITELIST -> 1
|
||||
else -> 2
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp, bottom = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
scopeOptions.forEach { (index, label) ->
|
||||
val selected = selectedScopeIndex == index
|
||||
val optionShape = RoundedCornerShape(16.dp)
|
||||
val optionBrush = remember {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = if (selected) 0.22f else 0.12f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = if (selected) 0.18f else 0.1f)
|
||||
)
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 46.dp),
|
||||
shape = optionShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (selected) PurrfectPalette.glowPrimary.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.12f)
|
||||
),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = if (selected) 10.dp else 0.dp
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(optionBrush, optionShape)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
when (index) {
|
||||
0 -> scopes.clear()
|
||||
1 -> {
|
||||
currentScopeType = TrackerScopeType.WHITELIST
|
||||
if (scopes.isEmpty()) {
|
||||
addFriendDialog = AddFriendDialog(context, friendDialogActions, pinnedIds = scopes)
|
||||
}
|
||||
}
|
||||
2 -> {
|
||||
currentScopeType = TrackerScopeType.BLACKLIST
|
||||
if (scopes.isEmpty()) {
|
||||
addFriendDialog = AddFriendDialog(context, friendDialogActions, pinnedIds = scopes)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scopes.isNotEmpty()) {
|
||||
val selectorShape = RoundedCornerShape(18.dp)
|
||||
val selectorBrush = remember {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
|
||||
)
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
onClick = {
|
||||
addFriendDialog = AddFriendDialog(context, friendDialogActions, pinnedIds = scopes)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
shape = selectorShape,
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(selectorBrush, selectorShape)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
translation.format("select_friends_groups_button", "count" to scopes.size.toString()),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
addFriendDialog?.Content { addFriendDialog = null }
|
||||
}
|
||||
RuleCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
) {
|
||||
Column(Modifier.animateContentSize()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
translation["events_section_title"],
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
IconButton(
|
||||
onClick = { addEventDialogVisible = true },
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = translation["add_event_button"], modifier = Modifier.size(28.dp), tint = Color.White)
|
||||
}
|
||||
}
|
||||
if (addEventDialogVisible) {
|
||||
AddEventDialog(
|
||||
onDismissRequest = { addEventDialogVisible = false },
|
||||
onEventAdd = { event ->
|
||||
events.add(0, event)
|
||||
addEventDialogVisible = false
|
||||
}
|
||||
)
|
||||
}
|
||||
if (events.isEmpty()) {
|
||||
Text(
|
||||
translation["no_events_text"],
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
events.forEach { event ->
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize()
|
||||
.padding(vertical = 4.dp)
|
||||
.clickable { expanded = !expanded },
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
|
||||
) {
|
||||
Column(Modifier.padding(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
context.translation["tracker_events.${event.eventType}"],
|
||||
lineHeight = 20.sp,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = event.actions.joinToString(", ") { context.translation["tracker_actions.${it.key}"] },
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
lineHeight = 14.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedIconButton(
|
||||
onClick = {
|
||||
if (event.id > -1) {
|
||||
eventsToDelete.add(event)
|
||||
}
|
||||
events.remove(event)
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.DeleteOutline, contentDescription = translation["delete_button_description"], tint = Color.White)
|
||||
}
|
||||
}
|
||||
if (expanded) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||
ConditionCheckboxes(event.params)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(routes.bottomPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
package me.rhunk.snapenhance.ui.manager.pages.tracker
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.tracker
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -22,18 +30,26 @@ import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.automirrored.filled.Rule
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
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.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.rhunk.snapenhance.storage.getRepositories
|
||||
import me.rhunk.snapenhance.storage.getTrackerRuleByName
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.storage.getRepositories
|
||||
import me.eternal.purrfectsnap.storage.getTrackerRuleByName
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
@@ -53,7 +69,7 @@ class FriendTrackerCatalog : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.friend_tracker_catalog") }
|
||||
|
||||
@Composable
|
||||
private fun AvailableRulesTab() {
|
||||
private fun AvailableRulesTab(topBarHeight: Dp) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val okHttpClient = remember { OkHttpClient() }
|
||||
val gson = remember { context.gson }
|
||||
@@ -113,6 +129,33 @@ class FriendTrackerCatalog : Routes.Route() {
|
||||
manifest.rules.map { repoUrl to it }
|
||||
}
|
||||
|
||||
if (repositories.isEmpty() && !isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AestheticEmptyState(
|
||||
icon = Icons.Default.Public,
|
||||
title = translation["no_repos_added"],
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 22.dp),
|
||||
actions = {
|
||||
Button(
|
||||
onClick = { routes.manageFriendTrackerRepos.navigate() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(translation["manage_repos_description"] ?: (context.translation["manager.routes.manage_friend_tracker_repos"] ?: "Manage repositories"))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fun importRule(repoUrl: String, entry: FriendTrackerRepoEntry) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val rawUrl = if (repoUrl.endsWith("/")) repoUrl + entry.path else repoUrl + "/" + entry.path
|
||||
@@ -153,31 +196,40 @@ class FriendTrackerCatalog : Routes.Route() {
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp + routes.bottomPadding)
|
||||
) {
|
||||
item {
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 8.dp,
|
||||
top = topBarHeight + 12.dp,
|
||||
end = 8.dp,
|
||||
bottom = 8.dp + routes.bottomPadding
|
||||
)
|
||||
) {
|
||||
item {
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (allRules.isEmpty() && repositories.isNotEmpty()) {
|
||||
Text(
|
||||
text = translation["no_rules_available"],
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 28.dp, horizontal = 6.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AestheticEmptyState(
|
||||
icon = Icons.AutoMirrored.Filled.Rule,
|
||||
title = translation["no_rules_available"],
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(allRules) { (repoUrl, entry) ->
|
||||
@@ -189,54 +241,85 @@ class FriendTrackerCatalog : Routes.Route() {
|
||||
value = exists
|
||||
}
|
||||
|
||||
ElevatedCard(Modifier.padding(bottom = 8.dp).animateContentSize()) {
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize(),
|
||||
shape = shape,
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.background(PurrfectPalette.cardOverlay, shape)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Rule, null, Modifier.padding(end = 12.dp)
|
||||
)
|
||||
Column(
|
||||
Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.Center
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.18f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Rule,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = entry.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White
|
||||
)
|
||||
entry.author?.let {
|
||||
Text(
|
||||
text = entry.name,
|
||||
text = translation.format("by_author", "author" to it),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
entry.author?.let {
|
||||
Text(
|
||||
text = translation.format("by_author", "author" to it),
|
||||
maxLines = 1,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
entry.description?.let {
|
||||
entry.description?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
importRule(repoUrl, entry)
|
||||
},
|
||||
enabled = !isImported
|
||||
onClick = { importRule(repoUrl, entry) },
|
||||
enabled = !isImported,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
disabledContentColor = PurrfectPalette.textSecondary
|
||||
)
|
||||
) {
|
||||
Text(if (isImported) translation["imported_button"] else translation["import_button"])
|
||||
}
|
||||
@@ -247,14 +330,32 @@ class FriendTrackerCatalog : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
override val title: @Composable () -> Unit = { Text(translation["title"]) }
|
||||
override val topBarActions: @Composable RowScope.() -> Unit = {
|
||||
IconButton(onClick = { routes.manageFriendTrackerRepos.navigate() }) {
|
||||
Icon(Icons.Default.Public, contentDescription = translation["manage_repos_description"])
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val density = LocalDensity.current
|
||||
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
var topBarHeight by remember { mutableStateOf(statusBarTopPadding + 96.dp) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = translation["title"] ?: (routeInfo.translatedKey?.value ?: "Catalog"),
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
actions = {
|
||||
IconButton(onClick = { routes.manageFriendTrackerRepos.navigate() }) {
|
||||
Icon(Icons.Default.Public, contentDescription = translation["manage_repos_description"], tint = Color.White)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
.onGloballyPositioned {
|
||||
val newHeight = with(density) { it.size.height.toDp() }
|
||||
if (newHeight != topBarHeight) topBarHeight = newHeight
|
||||
}
|
||||
)
|
||||
AvailableRulesTab(topBarHeight = topBarHeight)
|
||||
}
|
||||
}
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
AvailableRulesTab()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package me.rhunk.snapenhance.ui.manager.pages.tracker
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.tracker
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -18,6 +19,7 @@ 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.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -26,8 +28,8 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -38,6 +40,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -45,12 +49,13 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.rhunk.snapenhance.common.data.ExportedTrackerData
|
||||
import me.rhunk.snapenhance.common.data.ExportType
|
||||
import me.rhunk.snapenhance.storage.getTrackerRule
|
||||
import me.rhunk.snapenhance.storage.getTrackerRulesDesc
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.util.saveFile
|
||||
import me.eternal.purrfectsnap.common.data.ExportedTrackerData
|
||||
import me.eternal.purrfectsnap.common.data.ExportType
|
||||
import me.eternal.purrfectsnap.storage.getTrackerRule
|
||||
import me.eternal.purrfectsnap.storage.getTrackerRulesDesc
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.saveFile
|
||||
import org.json.JSONArray
|
||||
|
||||
class FriendTrackerConfigExportScreen : Routes.Route() {
|
||||
@@ -85,21 +90,41 @@ class FriendTrackerConfigExportScreen : Routes.Route() {
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = {
|
||||
routes.activityLauncher.saveFile("friend_tracker_config.json", "application/json") { uri ->
|
||||
runCatching {
|
||||
context.androidContext.contentResolver.openOutputStream(android.net.Uri.parse(uri))?.use {
|
||||
trackerData?.let { data ->
|
||||
context.gson.toJson(data).byteInputStream().copyTo(it)
|
||||
context.shortToast(translation["exported_toast"])
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
routes.activityLauncher.saveFile("friend_tracker_config.json", "application/json") { uri ->
|
||||
runCatching {
|
||||
context.androidContext.contentResolver.openOutputStream(android.net.Uri.parse(uri))?.use {
|
||||
trackerData?.let { data ->
|
||||
context.gson.toJson(data).byteInputStream().copyTo(it)
|
||||
context.shortToast(translation["exported_toast"])
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.longToast(translation.format("export_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
}
|
||||
}.onFailure {
|
||||
context.longToast(translation.format("export_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDownward,
|
||||
contentDescription = translation["save_button"],
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Text(translation["save_button"])
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.ui.manager.pages.tracker
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.tracker
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
@@ -43,8 +43,8 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import me.rhunk.snapenhance.common.data.ExportedTrackerData
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.common.data.ExportedTrackerData
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import org.json.JSONArray
|
||||
|
||||
class FriendTrackerConfigImportScreen : Routes.Route() {
|
||||
@@ -0,0 +1,907 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.tracker
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.AutoGraph
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.Rule
|
||||
import androidx.compose.material.icons.filled.SaveAlt
|
||||
import androidx.compose.material.icons.filled.UploadFile
|
||||
import androidx.compose.material.icons.filled.FileOpen
|
||||
import androidx.compose.material.icons.filled.Store
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.storage.*
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
|
||||
import me.eternal.purrfectsnap.ui.util.openFile
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.pagerTabIndicatorOffset
|
||||
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
class FriendTrackerManagerRoot : Routes.Route() {
|
||||
enum class FilterType {
|
||||
CONVERSATION, USERNAME, EVENT
|
||||
}
|
||||
|
||||
override val translation by lazy { context.translation.getCategory("manager.friend_tracker") }
|
||||
private val titles by lazy {
|
||||
listOf(
|
||||
translation["rules_tab"],
|
||||
translation["logs_tab"]
|
||||
)
|
||||
}
|
||||
private var currentPage by mutableIntStateOf(0)
|
||||
private lateinit var logDeleteAction : () -> Unit
|
||||
private lateinit var exportAction : () -> Unit
|
||||
|
||||
@Composable
|
||||
private fun TrackerIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val backgroundBrush = remember {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.24f)
|
||||
)
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = shape,
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
modifier = modifier.size(46.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(backgroundBrush, shape)
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(icon, contentDescription = contentDescription, tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackerActionButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val shape = RoundedCornerShape(22.dp)
|
||||
val backgroundBrush = remember {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = shape,
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 16.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
|
||||
modifier = modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(backgroundBrush, shape)
|
||||
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(icon, contentDescription = label, tint = Color.White)
|
||||
Text(label, color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackerPillButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val shape = RoundedCornerShape(18.dp)
|
||||
val backgroundBrush = remember {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.26f)
|
||||
)
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = shape,
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(backgroundBrush, shape)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(icon, contentDescription = label, tint = Color.White)
|
||||
Text(label, color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val topBarActions: @Composable RowScope.() -> Unit = {
|
||||
var showExportDialog by remember { mutableStateOf(false) }
|
||||
var showSingleExportDialog by remember { mutableStateOf(false) }
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var showInvalidImportTypeDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showExportDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showExportDialog = false },
|
||||
title = translation["export_dialog_title"],
|
||||
text = translation["export_logs_dialog_confirm_text"],
|
||||
icon = Icons.Default.SaveAlt,
|
||||
confirmButtonText = translation["export_button"],
|
||||
onConfirm = {
|
||||
showExportDialog = false
|
||||
routes.friendTrackerConfigExport.navigate()
|
||||
},
|
||||
dismissButtonText = translation["button.cancel"],
|
||||
onDismiss = { showExportDialog = false },
|
||||
opaque = true,
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
|
||||
if (showSingleExportDialog) {
|
||||
val rules = rememberAsyncMutableStateList(defaultValue = emptyList()) {
|
||||
context.database.getTrackerRulesDesc()
|
||||
}
|
||||
SelectRuleDialog(
|
||||
onDismissRequest = { showSingleExportDialog = false },
|
||||
rules = rules,
|
||||
onRuleSelected = { rule ->
|
||||
showSingleExportDialog = false
|
||||
routes.friendTrackerConfigExport.navigate {
|
||||
this["rule_id"] = rule.id.toString()
|
||||
}
|
||||
},
|
||||
translation = translation
|
||||
)
|
||||
}
|
||||
|
||||
fun handleImport(type: me.eternal.purrfectsnap.common.data.ExportType) {
|
||||
routes.activityLauncher.openFile("application/json") { uri ->
|
||||
runCatching {
|
||||
val content = context.androidContext.contentResolver.openInputStream(android.net.Uri.parse(uri))?.use {
|
||||
it.readBytes().toString(Charsets.UTF_8)
|
||||
} ?: return@runCatching
|
||||
val exportedData = context.gson.fromJson(content, me.eternal.purrfectsnap.common.data.ExportedTrackerData::class.java)
|
||||
if (exportedData.type != type) {
|
||||
showInvalidImportTypeDialog = true
|
||||
return@runCatching
|
||||
}
|
||||
routes.friendTrackerConfigJsonForImport = content
|
||||
routes.friendTrackerConfigImport.navigate()
|
||||
}.onFailure {
|
||||
context.longToast("Failed to read file: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showInvalidImportTypeDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showInvalidImportTypeDialog = false },
|
||||
title = { Text(translation["invalid_import_type_dialog_title"]) },
|
||||
text = { Text(translation["invalid_import_type_dialog_text"]) },
|
||||
confirmButton = {
|
||||
Button(onClick = { showInvalidImportTypeDialog = false }) {
|
||||
Text(translation["button.ok"])
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showImportDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showImportDialog = false },
|
||||
title = translation["import_dialog_title"],
|
||||
text = translation["import_dialog_subtitle"] ?: translation["import_dialog_title"],
|
||||
icon = Icons.Default.FolderOpen,
|
||||
confirmButtonText = translation["bulk_import_button"],
|
||||
onConfirm = {
|
||||
showImportDialog = false
|
||||
handleImport(me.eternal.purrfectsnap.common.data.ExportType.BULK)
|
||||
},
|
||||
dismissButtonText = translation["individual_import_button"],
|
||||
onDismiss = {
|
||||
showImportDialog = false
|
||||
handleImport(me.eternal.purrfectsnap.common.data.ExportType.SINGLE)
|
||||
},
|
||||
opaque = true,
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
|
||||
if (currentPage == 0) {
|
||||
TrackerIconButton(
|
||||
icon = Icons.Default.FolderOpen,
|
||||
contentDescription = translation["import_button_description"],
|
||||
onClick = { showImportDialog = true }
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TrackerIconButton(
|
||||
icon = Icons.Default.SaveAlt,
|
||||
contentDescription = translation["export_button_description"],
|
||||
onClick = { showExportDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
override val floatingActionButton: @Composable () -> Unit = {
|
||||
when (currentPage) {
|
||||
1 -> {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
TrackerActionButton(
|
||||
label = translation["export_button"],
|
||||
icon = Icons.Default.SaveAlt,
|
||||
onClick = { context.coroutineScope.launch { exportAction() } }
|
||||
)
|
||||
TrackerActionButton(
|
||||
label = translation["delete_button"],
|
||||
icon = Icons.Default.DeleteOutline,
|
||||
onClick = { context.coroutineScope.launch { logDeleteAction() } }
|
||||
)
|
||||
}
|
||||
}
|
||||
0 -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.End) {
|
||||
TrackerActionButton(
|
||||
label = translation["catalog_button"],
|
||||
icon = Icons.Default.Store,
|
||||
onClick = { routes.friendTrackerCatalog.navigate() }
|
||||
)
|
||||
TrackerActionButton(
|
||||
label = translation["add_rule_button"],
|
||||
icon = Icons.Default.Add,
|
||||
onClick = { routes.editRule.navigate() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfigRulesTab() {
|
||||
val updateRules = rememberAsyncUpdateDispatcher()
|
||||
val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) {
|
||||
context.database.getTrackerRulesDesc()
|
||||
}
|
||||
@Composable
|
||||
fun EmptyState(text: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 50.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(62.dp)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
|
||||
)
|
||||
),
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Filled.AutoGraph, contentDescription = text, tint = Color.White)
|
||||
}
|
||||
}
|
||||
Text(text, color = Color.White, fontWeight = FontWeight.ExtraBold)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(bottom = routes.bottomPadding)
|
||||
) {
|
||||
item {
|
||||
if (rules.isEmpty()) {
|
||||
EmptyState(translation["no_rules_found"])
|
||||
}
|
||||
}
|
||||
items(rules, key = { it.id }) { rule ->
|
||||
val ruleName by rememberAsyncMutableState(defaultValue = rule.name) {
|
||||
context.database.getTrackerRule(rule.id)?.name ?: translation["empty_rule_name"]
|
||||
}
|
||||
val eventCount by rememberAsyncMutableState(defaultValue = 0) {
|
||||
context.database.getTrackerEvents(rule.id).size
|
||||
}
|
||||
val scopeCount by rememberAsyncMutableState(defaultValue = 0) {
|
||||
context.database.getRuleTrackerScopes(rule.id).size
|
||||
}
|
||||
var enabled by rememberAsyncMutableState(defaultValue = rule.enabled) {
|
||||
context.database.getTrackerRule(rule.id)?.enabled ?: false
|
||||
}
|
||||
|
||||
val ruleShape = RoundedCornerShape(20.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
routes.editRule.navigate {
|
||||
this["rule_id"] = rule.id.toString()
|
||||
}
|
||||
}
|
||||
.padding(8.dp),
|
||||
shape = ruleShape,
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.5f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(PurrfectPalette.cardOverlay, ruleShape)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(54.dp)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.3f)
|
||||
)
|
||||
),
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Default.Rule, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(ruleName, fontSize = 18.sp, fontWeight = FontWeight.ExtraBold, color = Color.White)
|
||||
Text(
|
||||
buildString {
|
||||
append(eventCount)
|
||||
append(" ")
|
||||
append(translation["events_suffix"])
|
||||
if (scopeCount > 0) {
|
||||
append(" • ")
|
||||
append(scopeCount)
|
||||
append(" ")
|
||||
append(translation["scopes_suffix"])
|
||||
}
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
if (scopeCount > 0) {
|
||||
val scopesBitmoji = rememberAsyncMutableStateList(defaultValue = emptyList()) {
|
||||
context.database.getRuleTrackerScopes(rule.id, limit = 8).mapNotNull {
|
||||
context.database.getFriendInfo(it.key)?.let { friend ->
|
||||
friend.selfieId to friend.bitmojiId
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy((-10).dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
scopesBitmoji.take(4).forEach { friend ->
|
||||
BitmojiImage(
|
||||
size = 34,
|
||||
modifier = Modifier
|
||||
.border(BorderStroke(1.dp, Color.White), CircleShape)
|
||||
.background(Color.White, CircleShape)
|
||||
.clip(CircleShape),
|
||||
context = context,
|
||||
url = BitmojiSelfie.getBitmojiSelfie(friend.first, friend.second, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D),
|
||||
)
|
||||
}
|
||||
if (scopeCount > scopesBitmoji.size) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(
|
||||
text = "+${scopeCount - scopesBitmoji.size}",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
|
||||
) {
|
||||
Text(
|
||||
text = translation[if (enabled) "enabled_label" else "disabled_label"],
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = {
|
||||
enabled = it
|
||||
context.database.setTrackerRuleState(rule.id, it)
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState(initialPage = 0) { titles.size }
|
||||
currentPage = pagerState.currentPage
|
||||
var showExportDialog by remember { mutableStateOf(false) }
|
||||
var showSingleExportDialog by remember { mutableStateOf(false) }
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var showInvalidImportTypeDialog by remember { mutableStateOf(false) }
|
||||
|
||||
fun handleImport(type: me.eternal.purrfectsnap.common.data.ExportType) {
|
||||
routes.activityLauncher.openFile("application/json") { uri ->
|
||||
runCatching {
|
||||
val content = context.androidContext.contentResolver.openInputStream(android.net.Uri.parse(uri))?.use {
|
||||
it.readBytes().toString(Charsets.UTF_8)
|
||||
} ?: return@runCatching
|
||||
val exportedData = context.gson.fromJson(content, me.eternal.purrfectsnap.common.data.ExportedTrackerData::class.java)
|
||||
if (exportedData.type != type) {
|
||||
showInvalidImportTypeDialog = true
|
||||
return@runCatching
|
||||
}
|
||||
routes.friendTrackerConfigJsonForImport = content
|
||||
routes.friendTrackerConfigImport.navigate()
|
||||
}.onFailure {
|
||||
context.longToast("Failed to read file: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = context.translation["manager.routes.friend_tracker"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 20.sp
|
||||
)
|
||||
Text(
|
||||
text = titles.getOrNull(pagerState.currentPage) ?: "",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
if (pagerState.currentPage == 0) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
TrackerPillButton(
|
||||
label = translation["import_button"],
|
||||
icon = Icons.Default.FolderOpen,
|
||||
onClick = { showImportDialog = true }
|
||||
)
|
||||
TrackerPillButton(
|
||||
label = translation["export_button"],
|
||||
icon = Icons.Default.SaveAlt,
|
||||
onClick = { showExportDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
titles.forEachIndexed { i, text ->
|
||||
val selected = pagerState.currentPage == i
|
||||
Surface(
|
||||
modifier = Modifier.weight(1f).clip(RoundedCornerShape(18.dp)).clickable {
|
||||
coroutineScope.launch { pagerState.animateScrollToPage(i) }
|
||||
},
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (selected) Color.White.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.06f),
|
||||
border = if (selected) BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))) else BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(text = text, color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||
state = pagerState
|
||||
) { page ->
|
||||
when (page) {
|
||||
1 -> LogsTab(
|
||||
context = context,
|
||||
activityLauncherHelper = activityLauncherHelper,
|
||||
deleteAction = { logDeleteAction = it },
|
||||
exportAction = { exportAction = it },
|
||||
bottomPadding = routes.bottomPadding
|
||||
)
|
||||
0 -> ConfigRulesTab()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showExportDialog) {
|
||||
ChoiceDialog(
|
||||
onDismissRequest = { showExportDialog = false },
|
||||
title = translation["export_dialog_title"],
|
||||
choices = listOf(
|
||||
translation["bulk_export_button"] to { Icon(Icons.Default.UploadFile, translation["bulk_export_button"]) },
|
||||
translation["individual_export_button"] to { Icon(Icons.Default.FileOpen, translation["individual_export_button"]) }
|
||||
),
|
||||
onChoiceSelected = { index ->
|
||||
showExportDialog = false
|
||||
when (index) {
|
||||
0 -> routes.friendTrackerConfigExport.navigate()
|
||||
1 -> showSingleExportDialog = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showSingleExportDialog) {
|
||||
val rules = rememberAsyncMutableStateList(defaultValue = emptyList()) {
|
||||
context.database.getTrackerRulesDesc()
|
||||
}
|
||||
SelectRuleDialog(
|
||||
onDismissRequest = { showSingleExportDialog = false },
|
||||
rules = rules,
|
||||
onRuleSelected = { rule ->
|
||||
showSingleExportDialog = false
|
||||
routes.friendTrackerConfigExport.navigate {
|
||||
this["rule_id"] = rule.id.toString()
|
||||
}
|
||||
},
|
||||
translation = translation
|
||||
)
|
||||
}
|
||||
|
||||
if (showInvalidImportTypeDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showInvalidImportTypeDialog = false },
|
||||
title = { Text(translation["invalid_import_type_dialog_title"]) },
|
||||
text = { Text(translation["invalid_import_type_dialog_text"]) },
|
||||
confirmButton = {
|
||||
Button(onClick = { showInvalidImportTypeDialog = false }) {
|
||||
Text(translation["button.ok"])
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showImportDialog) {
|
||||
ChoiceDialog(
|
||||
onDismissRequest = { showImportDialog = false },
|
||||
title = translation["import_dialog_title"],
|
||||
choices = listOf(
|
||||
translation["bulk_import_button"] to { Icon(Icons.Default.UploadFile, translation["bulk_import_button"]) },
|
||||
translation["individual_import_button"] to { Icon(Icons.Default.FileOpen, translation["individual_import_button"]) }
|
||||
),
|
||||
onChoiceSelected = { index ->
|
||||
showImportDialog = false
|
||||
when (index) {
|
||||
0 -> handleImport(me.eternal.purrfectsnap.common.data.ExportType.BULK)
|
||||
1 -> handleImport(me.eternal.purrfectsnap.common.data.ExportType.SINGLE)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectRuleDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
rules: List<me.eternal.purrfectsnap.common.data.TrackerRule>,
|
||||
onRuleSelected: (me.eternal.purrfectsnap.common.data.TrackerRule) -> Unit,
|
||||
translation: me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismissRequest) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(translation["manager.friend_tracker.select_rule_to_export_title"], style = MaterialTheme.typography.headlineSmall)
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(rules) { rule ->
|
||||
ElevatedCard(
|
||||
onClick = { onRuleSelected(rule) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = rule.name,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(translation["button.cancel"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoiceDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
title: String,
|
||||
choices: List<Pair<String, @Composable () -> Unit>>,
|
||||
onChoiceSelected: (Int) -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismissRequest) {
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
Surface(
|
||||
shape = shape,
|
||||
color = Color.Transparent,
|
||||
shadowElevation = 20.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.5f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, shape)
|
||||
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
choices.forEachIndexed { index, (text, icon) ->
|
||||
SelectButton(
|
||||
onClick = { onChoiceSelected(index) },
|
||||
text = text,
|
||||
leadingIcon = icon
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectButton(
|
||||
onClick: () -> Unit,
|
||||
text: String,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (leadingIcon != null) {
|
||||
leadingIcon()
|
||||
}
|
||||
Text(text = text, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Friend Tracker Rule Repositories
|
||||
|
||||
Friend Tracker Rule Repositories are a way to share your custom friend tracker rules with others. You can create a repository with your rules and share the URL with others to import them into SnapEnhance.
|
||||
Friend Tracker Rule Repositories are a way to share your custom friend tracker rules with others. You can create a repository with your rules and share the URL with others to import them into PurrfectSnap.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
@@ -34,4 +34,4 @@ Here is an example of an `index.json` file:
|
||||
|
||||
### Rule File
|
||||
|
||||
The rule file itself is a standard SnapEnhance friend tracker rule JSON file. You can export an existing rule from SnapEnhance to get a template. The file must be a valid JSON file.
|
||||
The rule file itself is a standard PurrfectSnap friend tracker rule JSON file. You can export an existing rule from PurrfectSnap to get a template. The file must be a valid JSON file.
|
||||
@@ -0,0 +1,808 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.tracker
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.SaveAlt
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
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 java.util.Date
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import com.google.gson.stream.JsonWriter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.TrackerLog
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.TrackerEventType
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.storage.getFriendInfo
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
|
||||
import me.eternal.purrfectsnap.ui.util.saveFile
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import java.text.DateFormat
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LogsTab(
|
||||
context: RemoteSideContext,
|
||||
activityLauncherHelper: ActivityLauncherHelper,
|
||||
deleteAction: (() -> Unit) -> Unit,
|
||||
exportAction: (() -> Unit) -> Unit,
|
||||
bottomPadding: Dp,
|
||||
) {
|
||||
val translation = remember { context.translation.getCategory("manager.friend_tracker") }
|
||||
val trackerTranslation = remember { context.translation.getCategory("tracker") }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val logs = remember { mutableStateListOf<TrackerLog>() }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var pageIndex by remember { mutableIntStateOf(0) }
|
||||
var filterType by remember { mutableStateOf(FriendTrackerManagerRoot.FilterType.USERNAME) }
|
||||
var reverseSortOrder by remember { mutableStateOf(true) }
|
||||
val sinceDatePickerState = rememberDatePickerState(
|
||||
initialDisplayMode = DisplayMode.Picker
|
||||
)
|
||||
|
||||
var filter by remember { mutableStateOf("") }
|
||||
var searchTimeoutJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
fun getPaginatedLogs(pageIndex: Int) = context.messageLogger.getLogs(
|
||||
pageIndex = pageIndex,
|
||||
pageSize = 30,
|
||||
timestamp = sinceDatePickerState.selectedDateMillis,
|
||||
reverseOrder = reverseSortOrder,
|
||||
filter = {
|
||||
when (filterType) {
|
||||
FriendTrackerManagerRoot.FilterType.USERNAME -> it.username.contains(filter, ignoreCase = true)
|
||||
FriendTrackerManagerRoot.FilterType.CONVERSATION -> it.conversationTitle?.contains(filter, ignoreCase = true) == true || (it.username == filter && !it.isGroup)
|
||||
FriendTrackerManagerRoot.FilterType.EVENT -> it.eventType.contains(filter, ignoreCase = true)
|
||||
}
|
||||
})
|
||||
|
||||
fun formatICanSeeYouDuration(value: Long?): String {
|
||||
if (value == null || value < 0) return trackerTranslation.get("logs.log_entry.i_can_see_you_not_available")
|
||||
val totalSeconds = (value / 1000).coerceAtLeast(0)
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
val hourUnit = trackerTranslation.get("logs.log_entry.i_can_see_you_unit_hour")
|
||||
val minuteUnit = trackerTranslation.get("logs.log_entry.i_can_see_you_unit_minute")
|
||||
val secondUnit = trackerTranslation.get("logs.log_entry.i_can_see_you_unit_second")
|
||||
val parts = mutableListOf<String>()
|
||||
if (hours > 0) parts.add("${hours}${hourUnit}")
|
||||
if (minutes > 0 || hours > 0) parts.add("${minutes}${minuteUnit}")
|
||||
parts.add("${seconds}${secondUnit}")
|
||||
return parts.joinToString(" ")
|
||||
}
|
||||
|
||||
fun formatICanSeeYouTime(value: Long?): String {
|
||||
if (value == null || value < 0) return trackerTranslation.get("logs.log_entry.i_can_see_you_not_available")
|
||||
return DateFormat.getTimeInstance(DateFormat.MEDIUM).format(Date(value))
|
||||
}
|
||||
|
||||
fun buildICanSeeYouDetails(data: String): String {
|
||||
val values = data.split("|").map { it.toLongOrNull() ?: -1L }.let {
|
||||
if (it.size >= 3) it else it + List(3 - it.size) { -1L }
|
||||
}
|
||||
val entered = values[0].takeIf { it >= 0 }
|
||||
val exited = values[1].takeIf { it >= 0 }
|
||||
val duration = values[2].takeIf { it >= 0 }
|
||||
val enteredLabel = trackerTranslation.get("logs.log_entry.i_can_see_you_entered")
|
||||
val leftLabel = trackerTranslation.get("logs.log_entry.i_can_see_you_left")
|
||||
val durationLabel = trackerTranslation.get("logs.log_entry.i_can_see_you_duration")
|
||||
return listOf(
|
||||
"$enteredLabel: ${formatICanSeeYouTime(entered)}",
|
||||
"$durationLabel: ${formatICanSeeYouDuration(duration)}",
|
||||
"$leftLabel: ${formatICanSeeYouTime(exited)}"
|
||||
).joinToString(" • ")
|
||||
}
|
||||
|
||||
suspend fun loadNewLogs() {
|
||||
withContext(Dispatchers.IO) {
|
||||
getPaginatedLogs(pageIndex).let {
|
||||
withContext(Dispatchers.Main) {
|
||||
logs.addAll(it)
|
||||
pageIndex += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetAndLoadLogs() {
|
||||
isLoading = true
|
||||
logs.clear()
|
||||
pageIndex = 0
|
||||
loadNewLogs()
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var showExportSelectionDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
deleteAction { showDeleteDialog = true }
|
||||
exportAction { showExportSelectionDialog = true }
|
||||
}
|
||||
|
||||
if (showDeleteDialog) {
|
||||
val deleteCoroutineScope = rememberCoroutineScope { Dispatchers.IO }
|
||||
var deleteLogsTask by remember { mutableStateOf<Job?>(null) }
|
||||
var deletedLogsCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
fun deleteLogs() {
|
||||
deleteLogsTask = deleteCoroutineScope.launch {
|
||||
var index = 0
|
||||
while (true) {
|
||||
val newLogs = getPaginatedLogs(index++)
|
||||
if (newLogs.isEmpty()) {
|
||||
break
|
||||
}
|
||||
newLogs.forEach {
|
||||
context.messageLogger.deleteTrackerLog(it.id)
|
||||
deletedLogsCount++
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
delay(500)
|
||||
resetAndLoadLogs()
|
||||
context.shortToast(translation.format("deleted_logs_toast", "count" to deletedLogsCount.toString()))
|
||||
showDeleteDialog = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
deleteLogsTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = translation["delete_logs_dialog_title"],
|
||||
text = if (deleteLogsTask != null) translation.format("deleting_logs_dialog_text", "count" to deletedLogsCount.toString()) else translation["delete_logs_dialog_confirm_text"],
|
||||
icon = Icons.Default.DeleteOutline,
|
||||
confirmButtonText = translation["delete_button"],
|
||||
onConfirm = { if (deleteLogsTask == null) deleteLogs() },
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
loading = deleteLogsTask != null,
|
||||
opaque = true,
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
|
||||
if (showExportSelectionDialog) {
|
||||
val exportCoroutineScope = rememberCoroutineScope { Dispatchers.IO }
|
||||
var exportTask by remember { mutableStateOf<Job?>(null) }
|
||||
var exportType by remember { mutableStateOf("json") }
|
||||
|
||||
fun exportLogs() {
|
||||
activityLauncherHelper.saveFile("tracker_logs_${System.currentTimeMillis()}.$exportType") { uri ->
|
||||
exportTask = exportCoroutineScope.launch {
|
||||
context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use {
|
||||
val writer = it.writer()
|
||||
val jsonWriter by lazy {
|
||||
JsonWriter(writer).apply {
|
||||
setIndent(" ")
|
||||
beginArray()
|
||||
}
|
||||
}
|
||||
|
||||
var index = 0
|
||||
while (true) {
|
||||
val newLogs = getPaginatedLogs(index++)
|
||||
if (newLogs.isEmpty()) {
|
||||
break
|
||||
}
|
||||
newLogs.forEach { log ->
|
||||
when (exportType) {
|
||||
"json" -> {
|
||||
jsonWriter.jsonValue(log.toJson().toString())
|
||||
}
|
||||
"csv" -> {
|
||||
writer.write(log.toCsv())
|
||||
writer.write("\n")
|
||||
}
|
||||
}
|
||||
writer.flush()
|
||||
}
|
||||
}
|
||||
when (exportType) {
|
||||
"json" -> {
|
||||
jsonWriter.endArray()
|
||||
jsonWriter.close()
|
||||
}
|
||||
"csv" -> writer.close()
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
invokeOnCompletion {
|
||||
exportTask = null
|
||||
showExportSelectionDialog = false
|
||||
if (it == null) {
|
||||
context.shortToast(translation["exported_logs_toast"])
|
||||
} else {
|
||||
context.log.error("Failed to export logs", it)
|
||||
context.shortToast(translation["export_logs_failed_toast"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showExportSelectionDialog = false },
|
||||
title = translation["export_logs_dialog_title"],
|
||||
text = translation["export_logs_dialog_confirm_text"],
|
||||
icon = Icons.Default.SaveAlt,
|
||||
customContent = {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
.padding(vertical = 8.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(translation.format("export_as_button", "type" to exportType.uppercase()), color = Color.White)
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||
}
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
listOf("json", "csv").forEach { type ->
|
||||
DropdownMenuItem(onClick = {
|
||||
exportType = type
|
||||
expanded = false
|
||||
}, text = {
|
||||
Text(type.uppercase(), color = Color.White)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButtonText = translation["export_button"],
|
||||
onConfirm = { if (exportTask == null) exportLogs() },
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
onDismiss = {
|
||||
exportTask?.cancel()
|
||||
exportTask = null
|
||||
showExportSelectionDialog = false
|
||||
},
|
||||
loading = exportTask != null,
|
||||
opaque = true,
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun FilterSelection(
|
||||
selectionExpanded: MutableState<Boolean>
|
||||
) {
|
||||
var dropDownExpanded by remember { mutableStateOf(false) }
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDatePicker) {
|
||||
Dialog(onDismissRequest = { showDatePicker = false }) {
|
||||
val dialogShape = RoundedCornerShape(22.dp)
|
||||
Surface(
|
||||
shape = dialogShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
|
||||
shadowElevation = 16.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, dialogShape)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
translation["pick_a_date_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
DatePicker(
|
||||
state = sinceDatePickerState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.White.copy(alpha = 0.02f), RoundedCornerShape(18.dp)),
|
||||
colors = DatePickerDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
titleContentColor = Color.White,
|
||||
headlineContentColor = Color.White,
|
||||
weekdayContentColor = Color.White.copy(alpha = 0.9f),
|
||||
subheadContentColor = PurrfectPalette.textSecondary,
|
||||
selectedDayContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
|
||||
selectedDayContentColor = Color.Black,
|
||||
todayContentColor = Color.White,
|
||||
todayDateBorderColor = PurrfectPalette.glowSecondary,
|
||||
dayContentColor = Color.White.copy(alpha = 0.85f),
|
||||
disabledDayContentColor = Color.White.copy(alpha = 0.35f),
|
||||
dividerColor = Color.White.copy(alpha = 0.14f),
|
||||
navigationContentColor = Color.White
|
||||
)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
|
||||
) {
|
||||
Surface(
|
||||
onClick = {
|
||||
showDatePicker = false
|
||||
sinceDatePickerState.selectedDateMillis = null
|
||||
},
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(
|
||||
context.translation["button.cancel"],
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
onClick = { showDatePicker = false },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
|
||||
) {
|
||||
Text(
|
||||
context.translation["button.ok"],
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = selectionExpanded.value,
|
||||
onDismissRequest = { selectionExpanded.value = false },
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
containerColor = Color.Transparent,
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(16.dp))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(16.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
val rowHSpacing = 10.dp
|
||||
|
||||
Text(
|
||||
translation["filters_title"],
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(rowHSpacing),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(translation["search_by_label"], color = Color.White)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = dropDownExpanded,
|
||||
onExpandedChange = { dropDownExpanded = it },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.menuAnchor()
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, Color.White.copy(alpha = 0.2f), RoundedCornerShape(12.dp)),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White.copy(alpha = 0.06f)
|
||||
) {
|
||||
Text(
|
||||
filterType.name,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
ExposedDropdownMenu(
|
||||
expanded = dropDownExpanded,
|
||||
onDismissRequest = { dropDownExpanded = false },
|
||||
containerColor = Color(0xFF101220),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
FriendTrackerManagerRoot.FilterType.entries.forEach { type ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
filter = ""
|
||||
filterType = type
|
||||
dropDownExpanded = false
|
||||
coroutineScope.launch { resetAndLoadLogs() }
|
||||
},
|
||||
text = { Text(type.name, color = Color.White) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(rowHSpacing),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(translation["reverse_order_checkbox"], color = Color.White)
|
||||
Switch(
|
||||
checked = reverseSortOrder,
|
||||
onCheckedChange = {
|
||||
reverseSortOrder = it
|
||||
selectionExpanded.value = false
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(rowHSpacing),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(translation[if (reverseSortOrder) "since_label" else "until_label"], color = Color.White)
|
||||
val dateLabel = remember(showDatePicker) {
|
||||
sinceDatePickerState.selectedDateMillis?.let {
|
||||
DateFormat.getDateInstance().format(it)
|
||||
} ?: translation["pick_a_date_button"]
|
||||
}
|
||||
Surface(
|
||||
onClick = { showDatePicker = true },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 6.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.26f)
|
||||
)
|
||||
),
|
||||
RoundedCornerShape(14.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.FolderOpen, contentDescription = null, tint = Color.White.copy(alpha = 0.9f))
|
||||
Text(dateLabel, color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
var showAutoComplete by remember { mutableStateOf(false) }
|
||||
val showFilterSelection = remember { mutableStateOf(false) }
|
||||
val inputShape = RoundedCornerShape(18.dp)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showAutoComplete,
|
||||
onExpandedChange = { showAutoComplete = it },
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
shape = inputShape,
|
||||
color = Color.Transparent,
|
||||
shadowElevation = 0.dp,
|
||||
tonalElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, inputShape)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { showFilterSelection.value = !showFilterSelection.value },
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.size(46.dp)
|
||||
) {
|
||||
Icon(Icons.Default.FilterList, contentDescription = translation["filter_button_description"], tint = Color.White)
|
||||
}
|
||||
FilterSelection(showFilterSelection)
|
||||
if (showFilterSelection.value) {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
coroutineScope.launch { resetAndLoadLogs() }
|
||||
}
|
||||
}
|
||||
}
|
||||
TextField(
|
||||
value = filter,
|
||||
onValueChange = {
|
||||
filter = it
|
||||
coroutineScope.launch {
|
||||
searchTimeoutJob?.cancel()
|
||||
searchTimeoutJob = coroutineScope.launch {
|
||||
delay(200)
|
||||
showAutoComplete = true
|
||||
resetAndLoadLogs()
|
||||
}
|
||||
}
|
||||
},
|
||||
placeholder = { Text(translation["search_placeholder"], color = PurrfectPalette.textSecondary) },
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
cursorColor = Color.White
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp),
|
||||
textStyle = LocalTextStyle.current.copy(color = Color.White),
|
||||
trailingIcon = {
|
||||
if (filter.isNotEmpty()) {
|
||||
IconButton(onClick = {
|
||||
filter = ""
|
||||
coroutineScope.launch { resetAndLoadLogs() }
|
||||
}) {
|
||||
Icon(Icons.Default.Clear, contentDescription = translation["clear_button_description"], tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showAutoComplete,
|
||||
onDismissRequest = { showAutoComplete = false },
|
||||
properties = PopupProperties(focusable = false),
|
||||
containerColor = Color(0xFF161821),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
tonalElevation = 12.dp
|
||||
) {
|
||||
val suggestedEntries = remember(filter) { mutableStateListOf<String>() }
|
||||
|
||||
LaunchedEffect(filter) {
|
||||
suggestedEntries.clear()
|
||||
launch(Dispatchers.IO) {
|
||||
suggestedEntries.addAll(
|
||||
when (filterType) {
|
||||
FriendTrackerManagerRoot.FilterType.USERNAME -> context.messageLogger.findUsername(filter)
|
||||
FriendTrackerManagerRoot.FilterType.CONVERSATION -> context.messageLogger.findConversation(filter) + context.messageLogger.findUsername(filter)
|
||||
FriendTrackerManagerRoot.FilterType.EVENT -> TrackerEventType.entries.filter { it.name.contains(filter, ignoreCase = true) }.map { it.key }
|
||||
}.take(5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suggestedEntries.forEach { entry ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
filter = entry
|
||||
coroutineScope.launch { resetAndLoadLogs() }
|
||||
showAutoComplete = false
|
||||
},
|
||||
text = { Text(entry, color = Color.White) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(bottom = bottomPadding)
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (logs.isEmpty() && !isLoading) {
|
||||
Column(
|
||||
modifier = Modifier.padding(top = 40.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(58.dp)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
|
||||
)
|
||||
),
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Filled.History, contentDescription = translation["no_logs_found"], tint = Color.White)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
translation["no_logs_found"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items(logs, key = { it.userId + it.id }) { log ->
|
||||
var databaseFriend by remember { mutableStateOf<MessagingFriendInfo?>(null) }
|
||||
LaunchedEffect(Unit) {
|
||||
launch(Dispatchers.IO) {
|
||||
databaseFriend = context.database.getFriendInfo(log.userId)
|
||||
}
|
||||
}
|
||||
val cardShape = RoundedCornerShape(18.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
shape = cardShape,
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, cardShape)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
|
||||
BitmojiImage(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.size(58.dp),
|
||||
size = 58,
|
||||
context = context,
|
||||
url = databaseFriend?.takeIf { it.bitmojiId != null }?.let {
|
||||
BitmojiSelfie.getBitmojiSelfie(it.selfieId, it.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D)
|
||||
},
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
val eventLabel = trackerTranslation["logs.log_entry.events.${log.eventType}"]
|
||||
val conversationLabel = log.conversationTitle ?: trackerTranslation["logs.log_entry.unknown_conversation"]
|
||||
val eventTextTemplate = trackerTranslation["logs.log_entry.event_text"]
|
||||
val eventText = eventTextTemplate
|
||||
.replace("{friend}", databaseFriend?.displayName ?: log.username)
|
||||
.replace("{event}", eventLabel)
|
||||
.replace("{conversation}", conversationLabel)
|
||||
|
||||
Text(databaseFriend?.displayName?.let {
|
||||
"$it (${log.username})"
|
||||
} ?: log.username, lineHeight = 20.sp, fontWeight = FontWeight.ExtraBold, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 15.sp, color = Color.White)
|
||||
Text(eventText, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, lineHeight = 15.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, color = PurrfectPalette.textSecondary)
|
||||
if (log.eventType == TrackerEventType.I_CAN_SEE_YOU.key) {
|
||||
Text(
|
||||
buildICanSeeYouDetails(log.data),
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
lineHeight = 15.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
Text(
|
||||
DateFormat.getDateTimeInstance().format(log.timestamp),
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
lineHeight = 15.sp,
|
||||
color = Color.White.copy(alpha = 0.82f)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
context.messageLogger.deleteTrackerLog(log.id)
|
||||
logs.remove(log)
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.DeleteOutline, contentDescription = translation["delete_button_description"], tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
LaunchedEffect(pageIndex) {
|
||||
loadNewLogs()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.tracker
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.ui.zIndex
|
||||
import androidx.core.net.toUri
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getUrlFromClipboard
|
||||
import me.eternal.purrfectsnap.storage.addRepo
|
||||
import me.eternal.purrfectsnap.storage.getRepositories
|
||||
import me.eternal.purrfectsnap.storage.removeRepo
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class ManageFriendTrackerReposSection: Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.friend_tracker_repos") }
|
||||
private val refreshTrigger = mutableStateOf(0)
|
||||
private val okHttpClient by lazy { OkHttpClient() }
|
||||
|
||||
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 = translation["invalid_repo_title"],
|
||||
text = errorDialogMessage,
|
||||
icon = Icons.Default.Error,
|
||||
confirmButtonText = translation["button.ok"],
|
||||
onConfirm = { showErrorDialog = false },
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showAddDialog = true },
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Icon(Icons.Default.Public, contentDescription = null, tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(translation["add_repo_button"])
|
||||
}
|
||||
|
||||
if (showAddDialog) {
|
||||
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
|
||||
|
||||
var url by remember { mutableStateOf("") }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
|
||||
Dialog(onDismissRequest = { showAddDialog = false }) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 16.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
|
||||
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.18f)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Public,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = translation["add_repo_dialog_title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Text(
|
||||
text = translation["manager.dialogs.scripting.repo_hint"]
|
||||
?: translation["repo_url_label"]
|
||||
?: "",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onGloballyPositioned { focusRequester.requestFocus() },
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text(translation["repo_url_label"], color = PurrfectPalette.textSecondary) },
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
cursorColor = PurrfectPalette.glowSecondary,
|
||||
focusedLabelColor = PurrfectPalette.textSecondary,
|
||||
unfocusedLabelColor = PurrfectPalette.textSecondary
|
||||
)
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
context.androidContext.getUrlFromClipboard()?.let { url = it }
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
|
||||
) {
|
||||
TextButton(onClick = { showAddDialog = false }) {
|
||||
Text(translation["button.cancel"], color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
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("rules")
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
context.database.addRepo("friend_tracker", modifiedUrl)
|
||||
context.shortToast(translation["repo_added_toast"])
|
||||
showAddDialog = false
|
||||
refreshTrigger.value++
|
||||
} else {
|
||||
errorDialogMessage = translation["invalid_repo_error"]
|
||||
showErrorDialog = true
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to add repository", it)
|
||||
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = Color.White, strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text(translation["add_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val repositories by remember(refreshTrigger.value) {
|
||||
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("friend_tracker") })
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
var topBarHeight by remember { mutableStateOf(statusBarTopPadding + 96.dp) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: (translation["title"] ?: "Repositories"),
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
.onGloballyPositioned {
|
||||
val newHeight = with(density) { it.size.height.toDp() }
|
||||
if (newHeight != topBarHeight) topBarHeight = newHeight
|
||||
}
|
||||
)
|
||||
if (repositories.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AestheticEmptyState(
|
||||
icon = Icons.Default.Public,
|
||||
title = translation["no_repos_added"],
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 22.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 12.dp,
|
||||
top = topBarHeight + 12.dp,
|
||||
end = 12.dp,
|
||||
bottom = 18.dp + routes.bottomPadding
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
items(repositories) { url ->
|
||||
val (repoName, author) = remember(url) {
|
||||
url.removePrefix("https://raw.githubusercontent.com/").split("/").let { it[1] to it[0] }
|
||||
}
|
||||
var showRemoveDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.18f)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Public,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = repoName,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = author,
|
||||
fontSize = 13.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { showRemoveDialog = true },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(translation["remove_button"])
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = showRemoveDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showRemoveDialog = false },
|
||||
title = translation["remove_repo_dialog_title"],
|
||||
text = translation["remove_repo_dialog_text"],
|
||||
icon = Icons.Default.Error,
|
||||
confirmButtonText = translation["remove_button"],
|
||||
dismissButtonText = translation["button.cancel"],
|
||||
onDismiss = { showRemoveDialog = false },
|
||||
onConfirm = {
|
||||
context.database.removeRepo("friend_tracker", url)
|
||||
showRemoveDialog = false
|
||||
refreshTrigger.value++
|
||||
},
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package me.rhunk.snapenhance.ui.manager.pages.tracker
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.tracker
|
||||
|
||||
import me.rhunk.snapenhance.common.data.ExportedTrackerData
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.common.data.ExportedTrackerData
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import org.json.JSONArray
|
||||
|
||||
data class ImportedFeature(
|
||||
@@ -0,0 +1,39 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Centralized palette for the premium PurrfectSnap look.
|
||||
* Avoids relying on MaterialTheme for tinting so we can keep a consistent brand glow everywhere.
|
||||
*/
|
||||
object PurrfectPalette {
|
||||
val backgroundGradient = Brush.verticalGradient(
|
||||
listOf(
|
||||
Color(0xFF261F58),
|
||||
Color(0xFF302A6D),
|
||||
Color(0xFF241F52)
|
||||
)
|
||||
)
|
||||
|
||||
val panelGradient = Brush.linearGradient(
|
||||
listOf(
|
||||
Color(0xFF5C4B99),
|
||||
Color(0xFF322B5E),
|
||||
Color(0xFF1B1836)
|
||||
)
|
||||
)
|
||||
|
||||
val glowPrimary = Color(0xFF8C7BFF)
|
||||
val glowSecondary = Color(0xFF5FD8FF)
|
||||
val iconTint = Color.White
|
||||
val textPrimary = Color.White
|
||||
val textSecondary = Color(0xFFD9D3FF)
|
||||
val cardOverlayColor = Color(0xFF2A2452).copy(alpha = 0.95f)
|
||||
val cardOverlay = Brush.linearGradient(
|
||||
listOf(
|
||||
Color(0xFF2A2452).copy(alpha = 0.95f),
|
||||
Color(0xFF1A143A).copy(alpha = 0.92f)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
@file:OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||
package me.rhunk.snapenhance.ui.overlay
|
||||
package me.eternal.purrfectsnap.ui.overlay
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Intent
|
||||
@@ -7,27 +7,34 @@ import android.graphics.drawable.ColorDrawable
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.arthenica.ffmpegkit.Packages.getPackageName
|
||||
import me.rhunk.snapenhance.R
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.common.ui.createComposeView
|
||||
import me.rhunk.snapenhance.ui.manager.Navigation
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
|
||||
import me.eternal.purrfectsnap.common.ui.ThemeMode
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeView
|
||||
import me.eternal.purrfectsnap.ui.manager.Navigation
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
|
||||
|
||||
class RemoteOverlay(
|
||||
@@ -58,7 +65,7 @@ class RemoteOverlay(
|
||||
val navigation = remember { Navigation(context, navHostController) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
containerColor = Color.Transparent,
|
||||
topBar = { navigation.TopBar() }
|
||||
) { innerPadding ->
|
||||
navigation.NavContent(
|
||||
@@ -107,15 +114,33 @@ class RemoteOverlay(
|
||||
|
||||
dialog.setContentView(
|
||||
createComposeView(context.androidContext) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 20.dp)
|
||||
.clip(shape = MaterialTheme.shapes.large),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
OverlayContent(route)
|
||||
AppMaterialTheme(themeMode = ThemeMode.DARK) {
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides Color.White,
|
||||
LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(color = Color.White))
|
||||
) {
|
||||
val overlayShape = MaterialTheme.shapes.large
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 12.dp, end = 12.dp)
|
||||
.clip(overlayShape),
|
||||
shape = overlayShape,
|
||||
color = Color.Transparent,
|
||||
contentColor = Color.White,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(overlayShape)
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
OverlayContent(route)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -125,4 +150,3 @@ class RemoteOverlay(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user