Major Update!

This features a new bypass, new theme, etc
This commit is contained in:
ΞTΞRNAL
2025-12-23 17:09:18 +05:30
parent e85a54d0b1
commit 544d618f87
685 changed files with 63893 additions and 81215 deletions

12
.gitignore vendored
View File

@@ -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.*

View File

@@ -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")
}

View File

@@ -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.**

View File

@@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
0.7

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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(

View File

@@ -1,4 +1,4 @@
package me.rhunk.snapenhance
package me.eternal.purrfectsnap
import android.app.Activity
import android.content.Context

View File

@@ -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

View File

@@ -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,

View File

@@ -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() {

View File

@@ -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() {
}
}
}

View File

@@ -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?) {

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -1,4 +1,4 @@
package me.rhunk.snapenhance.scripting
package me.eternal.purrfectsnap.scripting
import android.net.Uri
import androidx.documentfile.provider.DocumentFile

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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(

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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) }
}
}

View File

@@ -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)

View File

@@ -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))
}
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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"
}

View File

@@ -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)

View File

@@ -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)
}
}
}
}

View File

@@ -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)
)
)
}
}

View File

@@ -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",
)
}
}

View File

@@ -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(
),
))
}
}
}

View File

@@ -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> {

View File

@@ -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

View File

@@ -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 ->

View File

@@ -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) {
})
}
}

View File

@@ -1,4 +1,4 @@
package me.rhunk.snapenhance.storage
package me.eternal.purrfectsnap.storage
import androidx.core.database.getStringOrNull

View File

@@ -1,4 +1,4 @@
package me.rhunk.snapenhance.storage
package me.eternal.purrfectsnap.storage
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking

View File

@@ -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))
}

View File

@@ -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 ->

View File

@@ -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() }
}
}
}
}

View File

@@ -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())
}
}

View File

@@ -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

View File

@@ -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."

View File

@@ -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()
}
}
}
}

View File

@@ -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)
}

View File

@@ -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())

View File

@@ -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)
}
}
}
}
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -1,4 +1,4 @@
package me.rhunk.snapenhance.ui.manager.data
package me.eternal.purrfectsnap.ui.manager.data
data class SnapchatAppInfo(

View File

@@ -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)

View File

@@ -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

View File

@@ -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
)
}
}
}
}
}
}
}
}
}

View File

@@ -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)
}
}
}
}
}
}
}
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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))
}
}
}
}

View File

@@ -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")
}
}

View File

@@ -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))
}
}
}
}
}
}

View File

@@ -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"])
}
}
}
}
}
}

View File

@@ -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

View File

@@ -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
}) {

View File

@@ -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
)
}
}
}
}
}
}
}
}
}

View File

@@ -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"])
}

View File

@@ -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) = {}
}

View File

@@ -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)
}
}
}
}
}
}
}
}

View File

@@ -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() {
}
}
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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))
}
}
}
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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))
}
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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"])
}
}
)

View File

@@ -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() {

View File

@@ -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))
}
}
}

View File

@@ -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.

View 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()
}
}
}
}
}

View File

@@ -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
)
}
}
}
}
}
}
}
}
}

View File

@@ -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(

View File

@@ -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)
)
)
}

View File

@@ -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